// ── Keysat license verification ──────────────────────────────────────────
//
// Reads a LIC1-... key from disk (or env), verifies its Ed25519 signature
// against the operator's embedded public key, and exposes the resulting
// state + entitlement set to the rest of the server.
//
// Operator config — keep these three constants in sync with what's set in
// the Keysat admin UI:
//   ISSUER_PEM      → assets/issuer.pub (committed; non-secret)
//   PRODUCT_SLUG    → must match the product slug created in Keysat
//   KEYSAT_BASE_URL → optional, only used by online validate() / purchase
//
// Tier model for this app (see KEYSAT_INTEGRATION.md §0):
//   "core"           — required for any business endpoint; unlocks
//                      summarization and BYO Gemini API key
//   "history"        — saved summary library: /api/history*
//   "library"        — bulk import/export: /api/library/*
//   "subscriptions"  — Pro: channel subs, auto-queue, sub-check log
//   "clips"          — Pro: paperclip / clip-collection panel
//
// Tier policies:
//   Core → ["core", "history", "library"]
//   Pro  → ["core", "history", "library", "subscriptions", "clips"]

import fs from "fs";
import path from "path";
import { Verifier, PublicKey, Client } from "@keysat/licensing-client";

export const PRODUCT_SLUG = "recap";
export const KEYSAT_BASE_URL = "https://licensing.keysat.xyz";

const __dirname = path.dirname(new URL(import.meta.url).pathname);
const PEM_PATH = path.join(__dirname, "..", "assets", "issuer.pub");
const ISSUER_PEM = fs.readFileSync(PEM_PATH, "utf8");

// License file lives next to existing config/ and history/ in DATA_DIR.
// On StartOS that's /data; on local Mac dev it's the project root.
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "..");
export const LICENSE_PATH =
  process.env.RECAP_LICENSE_KEY_PATH ||
  path.join(DATA_DIR, "license.txt");

// Grace ceiling for network errors. As long as we successfully validated
// against Keysat within this window, we keep the license live even if
// subsequent online checks fail (Keysat down, customer offline, etc.).
// Past the ceiling, we lock out — otherwise a revoked key on a permanently
// offline machine would never get caught.
const MAX_OFFLINE_DAYS = parseInt(
  process.env.RECAP_MAX_OFFLINE_DAYS || "7",
  10
);
const MAX_OFFLINE_MS = MAX_OFFLINE_DAYS * 24 * 60 * 60 * 1000;

// Sidecar file that tracks last successful online validation. Lets the
// grace window survive restarts.
const STATE_PATH = LICENSE_PATH + ".state.json";

// ── Verifier instance (built once at module load) ─────────────────────────
let verifier = null;
let verifierError = null;
try {
  verifier = new Verifier(PublicKey.fromPem(ISSUER_PEM));
} catch (e) {
  verifierError = e?.message || String(e);
  console.error(`[license] failed to parse embedded public key: ${verifierError}`);
}

// Lazy HTTP client for online validation against the licensing service.
let onlineClient = null;
function getOnlineClient() {
  if (!onlineClient) onlineClient = new Client(KEYSAT_BASE_URL);
  return onlineClient;
}

// ── Helpers ───────────────────────────────────────────────────────────────
function readLicenseString() {
  const fromEnv = (process.env.RECAP_LICENSE_KEY || "").trim();
  if (fromEnv) return fromEnv;
  try {
    const s = fs.readFileSync(LICENSE_PATH, "utf8").trim();
    return s || null;
  } catch {
    return null;
  }
}

function readPersistedState() {
  try {
    return JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
  } catch {
    return null;
  }
}

function writePersistedState(obj) {
  try {
    const tmp = STATE_PATH + ".tmp";
    fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
    fs.renameSync(tmp, STATE_PATH);
  } catch (e) {
    console.error(`[license] failed to write state: ${e?.message || e}`);
  }
}

function clearPersistedState() {
  try {
    fs.unlinkSync(STATE_PATH);
  } catch {}
}

function emptyState(extra = {}) {
  return {
    state: "unlicensed",
    reason: null,
    licenseId: null,
    entitlements: new Set(),
    expiresAt: null,
    isTrial: false,
    lastValidatedAt: null,
    serverStatus: null,
    graceUntil: null,
    ...extra,
  };
}

// Definitive server reasons that should immediately invalidate the license.
// rate_limited is intentionally NOT here — that's a transient server-side
// throttle, not a verdict on the key.
const HARD_REJECTIONS = new Set([
  "bad_format",
  "bad_signature",
  "not_found",
  "revoked",
  "suspended",
  "expired",
  "product_mismatch",
  "fingerprint_mismatch",
  "too_many_machines",
  "invalid_state",
]);

// ── Public API ────────────────────────────────────────────────────────────
//
// checkLicense() — read + verify; returns a frozen-ish state object.
// Callers can re-invoke after activation to refresh. This is offline-only:
// it verifies the Ed25519 signature and layers on any persisted online-
// validation state. The hard online check happens in validateOnline().
export function checkLicense() {
  if (verifierError) {
    return emptyState({ state: "invalid", reason: `bad embedded key: ${verifierError}` });
  }
  const raw = readLicenseString();
  if (!raw) return emptyState();

  let base;
  try {
    const ok = verifier.verify(raw);
    const payload = ok.payload || {};
    // Reject keys minted for a different product (same operator, different SKU).
    if (payload.productSlug && payload.productSlug !== PRODUCT_SLUG) {
      return emptyState({ state: "invalid", reason: "product_mismatch" });
    }
    base = emptyState({
      state: "licensed",
      // payload.licenseId is a Uint8Array (raw 16-byte UUID); the canonical
      // string form is licenseUuid. The frontend treats licenseId as a
      // string (calls .slice on it), so always send the string here.
      licenseId: payload.licenseUuid || null,
      entitlements: new Set(payload.entitlements || []),
      expiresAt: payload.expiresAt ? new Date(payload.expiresAt * 1000) : null,
      isTrial: !!(payload.flags & 1),
    });
  } catch (e) {
    return emptyState({ state: "invalid", reason: e?.message || "verify_failed" });
  }

  // Layer on persisted online state. If a previous online check found this
  // key revoked/suspended/etc., honor that until a successful re-check.
  const persisted = readPersistedState();
  if (persisted) {
    if (persisted.lastValidatedAt) {
      base.lastValidatedAt = new Date(persisted.lastValidatedAt);
    }
    if (persisted.serverStatus) base.serverStatus = persisted.serverStatus;
    if (persisted.graceUntil) base.graceUntil = new Date(persisted.graceUntil);
    if (persisted.lastResult && persisted.lastResult !== "ok") {
      // The last conclusive online check rejected this key. Stay invalid
      // until a successful re-check overwrites the sidecar.
      return emptyState({
        state: "invalid",
        reason: persisted.lastResult,
        licenseId: base.licenseId,
        lastValidatedAt: base.lastValidatedAt,
        serverStatus: base.serverStatus,
      });
    }
  }
  return base;
}

// activate(rawKey) — write a pasted key to disk, then re-check.
// Returns the new license state. Throws on bad input format only;
// signature failures surface as state: 'invalid' with a reason.
//
// Clears any persisted online-validation state — a new key gets a fresh
// online check, untainted by what an old key was last told.
export function activate(rawKey) {
  const key = (rawKey || "").trim();
  if (!key.startsWith("LIC1-")) {
    const err = new Error("bad_format");
    err.code = "bad_format";
    throw err;
  }
  // Write atomically-ish: write to temp file then rename.
  const tmp = LICENSE_PATH + ".tmp";
  fs.mkdirSync(path.dirname(LICENSE_PATH), { recursive: true });
  fs.writeFileSync(tmp, key + "\n", { mode: 0o600 });
  fs.renameSync(tmp, LICENSE_PATH);
  clearPersistedState();
  return checkLicense();
}

// deactivate() — remove the on-disk license + persisted online state.
// Idempotent. Returns the new (empty) state.
export function deactivate() {
  try {
    fs.unlinkSync(LICENSE_PATH);
  } catch {}
  clearPersistedState();
  return checkLicense();
}

// validateOnline() — call licensing.keysat.xyz/v1/validate, merge the
// response into a fresh state object, and persist last-validated info.
//
// Behavior:
//   • Server says ok=true   → state stays/becomes "licensed", entitlements
//                             and expiry refreshed from server.
//   • Server says ok=false with a hard reason (revoked, suspended, expired,
//     not_found, product_mismatch, fingerprint_mismatch, too_many_machines)
//                            → state becomes "invalid"; persisted so
//                              checkLicense() keeps rejecting after restart.
//   • Server says rate_limited → treated as a transient error; state kept.
//   • Network error / timeout → state kept up to MAX_OFFLINE_DAYS since the
//                              last successful validate. Past the ceiling,
//                              state becomes "invalid" with reason
//                              "validation_overdue".
//
// Always returns a state object — never throws.
export async function validateOnline() {
  const local = checkLicense();
  // No key, or local sig failure — no point asking the server.
  if (local.state !== "licensed" && local.state !== "invalid") return local;
  const raw = readLicenseString();
  if (!raw) return local;

  let resp;
  try {
    resp = await getOnlineClient().validate(raw, { productSlug: PRODUCT_SLUG });
  } catch (e) {
    return applyNetworkErrorGrace(local, e?.code || e?.message || "network_error");
  }

  const now = new Date();

  if (resp.ok) {
    const next = emptyState({
      state: "licensed",
      licenseId: resp.licenseId || local.licenseId,
      entitlements: new Set(resp.entitlements || [...local.entitlements]),
      expiresAt: resp.expiresAt ? new Date(resp.expiresAt) : local.expiresAt,
      isTrial: resp.isTrial != null ? !!resp.isTrial : local.isTrial,
      lastValidatedAt: now,
      serverStatus: resp.status || "active",
      graceUntil: resp.graceUntil ? new Date(resp.graceUntil) : null,
    });
    writePersistedState({
      lastValidatedAt: now.toISOString(),
      serverStatus: next.serverStatus,
      lastResult: "ok",
      graceUntil: next.graceUntil ? next.graceUntil.toISOString() : null,
    });
    return next;
  }

  const reason = resp.reason || "rejected";

  if (reason === "rate_limited") {
    // Transient. Don't change state.
    return applyNetworkErrorGrace(local, "rate_limited");
  }

  if (HARD_REJECTIONS.has(reason)) {
    console.warn(`[license] online validation rejected: ${reason}`);
    writePersistedState({
      lastValidatedAt: now.toISOString(),
      serverStatus: resp.status || reason,
      lastResult: reason,
    });
    return emptyState({
      state: "invalid",
      reason,
      licenseId: local.licenseId,
      lastValidatedAt: now,
      serverStatus: resp.status || reason,
    });
  }

  // Unknown reason — be conservative, treat as transient so we don't lock
  // out paying users on an SDK/server version mismatch.
  console.warn(`[license] online validation returned unknown reason: ${reason}`);
  return applyNetworkErrorGrace(local, reason);
}

function applyNetworkErrorGrace(local, errorReason) {
  const persisted = readPersistedState();
  const lastValidatedAt = persisted?.lastValidatedAt
    ? new Date(persisted.lastValidatedAt)
    : null;

  if (!lastValidatedAt) {
    // Never successfully validated online. Allow offline state but flag it
    // — the periodic poller will keep trying.
    console.warn(
      `[license] online validation unavailable (${errorReason}); no prior successful check yet`
    );
    return { ...local, lastValidatedAt: null };
  }

  const ageMs = Date.now() - lastValidatedAt.getTime();
  if (ageMs > MAX_OFFLINE_MS) {
    const ageDays = (ageMs / 86400000).toFixed(1);
    console.warn(
      `[license] online validation overdue: ${ageDays}d since last successful check (max ${MAX_OFFLINE_DAYS}d). Locking out.`
    );
    return emptyState({
      state: "invalid",
      reason: "validation_overdue",
      licenseId: local.licenseId,
      lastValidatedAt,
      serverStatus: persisted?.serverStatus || null,
    });
  }

  console.warn(
    `[license] online validation skipped (${errorReason}); within ${MAX_OFFLINE_DAYS}d grace`
  );
  return { ...local, lastValidatedAt };
}

// publicView(state) — safe shape for /api/license-status responses.
// Never leaks the raw license key (it's a bearer credential).
export function publicView(state) {
  return {
    state: state.state,
    reason: state.reason,
    licenseId: state.licenseId,
    entitlements: [...state.entitlements].sort(),
    expiresAt: state.expiresAt ? state.expiresAt.toISOString() : null,
    isTrial: !!state.isTrial,
    productSlug: PRODUCT_SLUG,
    keysatBaseUrl: KEYSAT_BASE_URL,
    licensePath: LICENSE_PATH,
    lastValidatedAt: state.lastValidatedAt
      ? state.lastValidatedAt.toISOString()
      : null,
    serverStatus: state.serverStatus || null,
    graceUntil: state.graceUntil ? state.graceUntil.toISOString() : null,
  };
}

// has(state, entitlement) — convenience wrapper for feature gates.
export function has(state, entitlement) {
  return state && state.entitlements && state.entitlements.has(entitlement);
}
