// Tests for server/license.js — the offline Ed25519 verifier wrapper.
//
// license.js reads its paths (LICENSE_PATH, STARTOS_CONFIG_PATH) at module
// load time from env vars. To isolate test fixtures, we set RECAP_LICENSE_
// KEY_PATH + DATA_DIR to a tmp dir BEFORE importing the module — which is
// why this file uses dynamic import().

import { test, describe, before, after } from "node:test";
import { strict as assert } from "node:assert";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";

let tmpDir;
let licensePath;
let licenseModule;

before(async () => {
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "recap-license-test-"));
  licensePath = path.join(tmpDir, "license.txt");
  process.env.DATA_DIR = tmpDir;
  process.env.RECAP_LICENSE_KEY_PATH = licensePath;
  // Make sure config dir exists so the fallback read in readLicenseString
  // doesn't fail on a missing parent.
  await fs.mkdir(path.join(tmpDir, "config"), { recursive: true });
  // Dynamic import after env vars are in place.
  licenseModule = await import("../license.js");
});

after(async () => {
  delete process.env.RECAP_LICENSE_KEY_PATH;
  delete process.env.DATA_DIR;
  await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});

describe("checkLicense", () => {
  test("returns 'unlicensed' when no key file exists", async () => {
    await fs.unlink(licensePath).catch(() => {});
    const state = licenseModule.checkLicense();
    assert.equal(state.state, "unlicensed");
    assert.equal(state.reason, null);
    assert.deepEqual([...state.entitlements], []);
  });

  test("returns 'invalid' for a malformed key", async () => {
    await fs.writeFile(licensePath, "garbage-not-a-license");
    const state = licenseModule.checkLicense();
    assert.equal(state.state, "invalid");
    assert.ok(state.reason, "should have a reason");
  });

  test("returns 'invalid' for a LIC1 prefix without valid base32 body", async () => {
    await fs.writeFile(licensePath, "LIC1-not-real-key");
    const state = licenseModule.checkLicense();
    assert.equal(state.state, "invalid");
  });

  test("falls back to startos-config.json when license.txt is missing", async () => {
    await fs.unlink(licensePath).catch(() => {});
    const cfgPath = path.join(tmpDir, "config", "startos-config.json");
    await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-bad-but-detected" }));
    const state = licenseModule.checkLicense();
    // Will be 'invalid' (bad key) but NOT 'unlicensed' — proves the fallback ran.
    assert.equal(state.state, "invalid");
    await fs.unlink(cfgPath).catch(() => {});
  });

  test("license.txt takes priority over startos-config.json", async () => {
    await fs.writeFile(licensePath, "LIC1-from-license-txt");
    const cfgPath = path.join(tmpDir, "config", "startos-config.json");
    await fs.writeFile(cfgPath, JSON.stringify({ recap_license_key: "LIC1-from-config" }));
    const state = licenseModule.checkLicense();
    // Both are bad, but the reason should reflect license.txt's content,
    // not the config's. We can't easily distinguish without a real key,
    // but we can confirm it returns 'invalid' (no precedence-related crash).
    assert.equal(state.state, "invalid");
    await fs.unlink(cfgPath).catch(() => {});
    await fs.unlink(licensePath).catch(() => {});
  });
});

describe("activate / deactivate", () => {
  test("activate rejects keys missing the LIC1- prefix", () => {
    assert.throws(() => licenseModule.activate("not-a-license"), { code: "bad_format" });
    assert.throws(() => licenseModule.activate(""), { code: "bad_format" });
    assert.throws(() => licenseModule.activate(null), { code: "bad_format" });
  });

  test("activate writes a LIC1-prefixed string to license.txt", async () => {
    const fake = "LIC1-fake-but-shaped-correctly";
    const state = licenseModule.activate(fake);
    const written = (await fs.readFile(licensePath, "utf-8")).trim();
    assert.equal(written, fake);
    // Returned state will be 'invalid' (signature fails) — not our concern here.
    assert.ok(["invalid", "licensed"].includes(state.state));
  });

  test("deactivate removes license.txt and the state sidecar", async () => {
    await fs.writeFile(licensePath, "LIC1-anything");
    licenseModule.deactivate();
    await assert.rejects(fs.access(licensePath), /ENOENT/);
    const state = licenseModule.checkLicense();
    assert.equal(state.state, "unlicensed");
  });

  test("deactivate is idempotent on a clean slate", () => {
    // No file → should not throw
    licenseModule.deactivate();
    licenseModule.deactivate();
  });
});

describe("publicView", () => {
  test("never includes the raw license key", () => {
    const state = licenseModule.checkLicense();
    const view = licenseModule.publicView(state);
    // The public view should not contain anything that looks like a license string
    const flat = JSON.stringify(view);
    assert.ok(!flat.includes("LIC1-"));
  });

  test("converts entitlements Set to a sorted array", () => {
    const view = licenseModule.publicView({
      state: "licensed",
      reason: null,
      licenseId: "abc",
      entitlements: new Set(["library", "core", "history"]),
      expiresAt: null,
      isTrial: false,
    });
    assert.deepEqual(view.entitlements, ["core", "history", "library"]);
  });

  test("ISO-formats expiresAt and graceUntil", () => {
    const expiresAt = new Date("2027-01-01T00:00:00.000Z");
    const view = licenseModule.publicView({
      state: "licensed",
      reason: null,
      licenseId: null,
      entitlements: new Set(),
      expiresAt,
      isTrial: false,
    });
    assert.equal(view.expiresAt, "2027-01-01T00:00:00.000Z");
  });

  test("always exposes productSlug = 'recap'", () => {
    const view = licenseModule.publicView({
      state: "unlicensed",
      reason: null,
      licenseId: null,
      entitlements: new Set(),
      expiresAt: null,
      isTrial: false,
    });
    assert.equal(view.productSlug, "recap");
  });
});

describe("has(state, entitlement)", () => {
  test("returns true when entitlement present", () => {
    assert.equal(
      licenseModule.has({ entitlements: new Set(["core"]) }, "core"),
      true
    );
  });

  test("returns false when entitlement absent", () => {
    assert.equal(
      licenseModule.has({ entitlements: new Set(["core"]) }, "subscriptions"),
      false
    );
  });

  test("returns falsy on null state", () => {
    assert.ok(!licenseModule.has(null, "core"));
    assert.ok(!licenseModule.has(undefined, "core"));
    assert.ok(!licenseModule.has({}, "core"));
  });
});
