// KueSync — licensing & 7-day trial
// © 2026 Krometis Analytics. All rights reserved.
//
// Offline-first: license keys are Ed25519-signed tokens verified locally against the bundled
// public key (no license server needed at the venue). A 7-day trial is tracked via a first-run
// timestamp persisted in the module config. Pure crypto below — no Companion imports — so it is
// unit-testable on its own.

import { verify, createPublicKey } from 'node:crypto'
import { PUBLIC_KEY_PEM } from './public-key.js'

// The free trial is 7 days.
export const TRIAL_DAYS = 7
const DAY_MS = 86400_000
// 0.11.3 (WS3): once expiry is SUSPECTED (key/trial lapsed on a trusted clock, or it cannot be verified), the
// operator gets this long — full function — to connect to the internet and verify/renew before enforcement
// becomes CONFIRMED. 1 hour.
export const LICENSE_GRACE_MS = Number(process.env.KUESYNC_LICENSE_GRACE_MS) || 3600_000
// 0.11.4 (G2 watermark rung): after the 1-hour grace, an expired control enters WATERMARK — cues STILL WORK,
// but a growing "UNLICENSED — kuesync.com" mark is drawn on the output/clock/cue-lights. Only after this much
// longer window does enforcement become CONFIRMED (the hard stop: slides stop + LIC:ERR). This is the
// recoverable, high-leverage middle rung the user chose ("watermark, then hard-stop"). Env-tunable for tests.
// Default 7 days — a genuinely lapsed paying customer who is briefly offline is pressured, never cut mid-show.
export const LICENSE_WATERMARK_MS = Number(process.env.KUESYNC_LICENSE_WATERMARK_MS) || (7 * DAY_MS)
export const ENFORCE = { NONE: 'none', SUSPECTED: 'suspected', WATERMARK: 'watermark', CONFIRMED: 'confirmed' }

// 0.12.51 — SEAT / ACTIVATION ladder. SEPARATE from the expiry ladder above and INTENTIONALLY SHORT:
// it applies ONLY to a machine that is over its seat count, or that never completed its online activation
// within its window. Total window is capped at ~24h (grace + watermark) so an over-deployed/never-activated
// rig lapses within a day, but a RUNNING cue is never interrupted (CONFIRMED only blocks the NEXT command).
// A machine that is WITHIN its entitlement (activated + not seat-denied) is NEVER touched by this. Env-tunable.
export const SEAT_GRACE_MS = Number(process.env.KUESYNC_SEAT_GRACE_MS) || 3600_000            // 1h "connect to activate"
export const SEAT_WATERMARK_MS = Number(process.env.KUESYNC_SEAT_WATERMARK_MS) || (23 * 3600_000) // then ≤23h watermark → 24h total
// Activation windows: a freshly installed key runs fully for this long BEFORE an online activation is required.
export const BOOTSTRAP_MS = Number(process.env.KUESYNC_BOOTSTRAP_MS) || (7 * DAY_MS)           // new (seat-stamped) key: 7 days
export const ACTIVATION_GRACE_MS = Number(process.env.KUESYNC_ACTIVATION_GRACE_MS) || (30 * DAY_MS) // legacy key: 30 days

/**
 * Verify a license key string ("<payloadB64url>.<sigB64url>").
 * Returns { valid, payload?, reason? }.
 */
export function verifyLicenseKey(key, now = Date.now()) {
  if (!key || typeof key !== 'string' || !key.includes('.')) {
    return { valid: false, reason: 'empty or malformed key' }
  }
  const [payloadB64, sigB64] = key.trim().split('.')
  try {
    const ok = verify(
      null,
      Buffer.from(payloadB64),
      createPublicKey(PUBLIC_KEY_PEM),
      Buffer.from(sigB64, 'base64url')
    )
    if (!ok) return { valid: false, reason: 'signature mismatch' }
    const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'))
    if (payload.product !== 'kuesync') return { valid: false, reason: 'wrong product' }
    if (payload.expiry && now > payload.expiry) {
      return { valid: false, reason: 'license expired', payload }
    }
    return { valid: true, payload }
  } catch (e) {
    return { valid: false, reason: `verify error: ${e.message}` }
  }
}

/**
 * Compute current entitlement from a license key + trial start timestamp.
 *   key        license key string (may be empty)
 *   trialStart ms timestamp of first run (may be undefined)
 *   now        injectable clock for testing
 * Returns { state: 'trial'|'licensed'|'expired', trialDaysLeft, licensedTo, reason }
 */
export function evaluateEntitlement(key, trialStart, now = Date.now()) {
  if (key) {
    const r = verifyLicenseKey(key, now)
    if (r.valid) {
      return { state: 'licensed', trialDaysLeft: 0, licensedTo: r.payload.customer, reason: 'ok' }
    }
    // Invalid/expired key falls back to trial evaluation (so a bad key doesn't grant access).
  }
  if (!trialStart) {
    return { state: 'trial', trialDaysLeft: TRIAL_DAYS, licensedTo: '', reason: 'trial start' }
  }
  const elapsedDays = (now - trialStart) / DAY_MS
  const left = Math.max(0, Math.ceil(TRIAL_DAYS - elapsedDays))
  if (elapsedDays <= TRIAL_DAYS) {
    return { state: 'trial', trialDaysLeft: left, licensedTo: '', reason: 'trial active' }
  }
  return { state: 'expired', trialDaysLeft: 0, licensedTo: '', reason: 'trial expired' }
}

// 0.11.3 (WS3): anti-rollback trusted clock — never earlier than the highest time we have ever observed, so a
// machine cannot dodge expiry by setting its clock backward. Persisted high-water-mark is advanced on every eval.
export function trustedNow(now, highWater) {
  const n = Number(now) || 0, h = Number(highWater) || 0
  return n > h ? n : h
}

/**
 * 0.11.3 (WS3) — FIRM enforcement evaluator. PURE and FAIL-OPEN: any uncertainty or exception returns
 * enforce:'none' (full function). Computes the suspected→confirmed state from ALREADY-CACHED inputs only
 * (never awaits, never touches the network — safe to call on the command path).
 *
 *   key        the installed license key (may be empty → trial)
 *   trialStart first-run ms (may be undefined)
 *   suspectedAt persisted ms when expiry was FIRST suspected (drives the 1-hour grace; survives restarts so it
 *               cannot be reset by relaunching). 0 = not currently suspected.
 *   highWater  persisted anti-rollback high-water-mark (ms)
 *   revoked    true ONLY when a server check-in AFFIRMATIVELY returned revoked/unpaid → confirmed immediately
 *   now        injectable clock
 *
 * Returns { enforce:'none'|'suspected'|'confirmed', state, reason, trialDaysLeft, licensedTo,
 *           graceMsLeft, suspectedAt, highWater }. The caller PERSISTS the returned suspectedAt + highWater.
 */
export function evaluateEnforcement({ key = '', trialStart, suspectedAt = 0, highWater = 0, revoked = false, now = Date.now() } = {}) {
  try {
    const t = trustedNow(now, highWater)
    // A server that affirmatively said this key is revoked/unpaid (a leaked/cracked key) → CONFIRMED at once.
    // No grace, no watermark window: an affirmative bad verdict is harder than a passive time-lapse.
    if (revoked) {
      return { enforce: ENFORCE.CONFIRMED, state: 'expired', reason: 'revoked', trialDaysLeft: 0, licensedTo: '', graceMsLeft: 0, wmFrac: 1, suspectedAt: suspectedAt || t, highWater: t }
    }
    const ent = evaluateEntitlement(key, trialStart, t)
    if (ent.state === 'licensed' || ent.state === 'trial') {
      // Healthy → clear any prior suspicion and advance the high-water mark. NEVER enforces. (== 0.11.2 baseline.)
      return { enforce: ENFORCE.NONE, state: ent.state, reason: ent.reason, trialDaysLeft: ent.trialDaysLeft, licensedTo: ent.licensedTo, graceMsLeft: 0, wmFrac: 0, suspectedAt: 0, highWater: t }
    }
    // Expired (trial or key). The escalation ladder from the FIRST moment suspicion started (persisted sAt):
    //   [0 .. GRACE)                       → SUSPECTED  — full function, "connect to verify" (1 hour)
    //   [GRACE .. GRACE+WATERMARK)         → WATERMARK  — full function, growing "UNLICENSED" mark on output
    //   [GRACE+WATERMARK .. ∞)             → CONFIRMED  — hard stop (slides stop + LIC:ERR)
    const sAt = suspectedAt > 0 ? suspectedAt : t
    const since = t - sAt
    const graceMsLeft = Math.max(0, LICENSE_GRACE_MS - since)
    if (graceMsLeft > 0) {
      return { enforce: ENFORCE.SUSPECTED, state: 'expired', reason: 'expired-grace', trialDaysLeft: 0, licensedTo: '', graceMsLeft, wmFrac: 0, suspectedAt: sAt, highWater: t }
    }
    const intoWm = since - LICENSE_GRACE_MS                 // ms into the watermark window
    if (intoWm < LICENSE_WATERMARK_MS) {
      const wmFrac = LICENSE_WATERMARK_MS > 0 ? Math.min(1, Math.max(0, intoWm / LICENSE_WATERMARK_MS)) : 1   // 0→1 progress (drives the "growing" mark)
      return { enforce: ENFORCE.WATERMARK, state: 'expired', reason: 'expired-watermark', trialDaysLeft: 0, licensedTo: '', graceMsLeft: 0, wmFrac, suspectedAt: sAt, highWater: t }
    }
    return { enforce: ENFORCE.CONFIRMED, state: 'expired', reason: 'expired-past-grace', trialDaysLeft: 0, licensedTo: '', graceMsLeft: 0, wmFrac: 1, suspectedAt: sAt, highWater: t }
  } catch {
    // Fail OPEN — a paying show is NEVER blocked by an evaluator bug.
    return { enforce: ENFORCE.NONE, state: 'licensed', reason: 'eval-error-allow', trialDaysLeft: 0, licensedTo: '', graceMsLeft: 0, wmFrac: 0, suspectedAt: 0, highWater: Number(highWater) || 0 }
  }
}

/**
 * 0.12.51 — SEAT / ACTIVATION enforcement evaluator. PURE and FAIL-OPEN, exactly like evaluateEnforcement:
 * it decides the seat ladder from ALREADY-CACHED inputs only (never awaits, never touches the network — safe
 * on the command path). It governs ONLY seat-cap / online-activation; the key's own expiry is governed
 * separately by evaluateEnforcement and the two never interfere.
 *
 *   hasValidKey  a currently-valid signed key is installed (else this returns NONE — trials/expiry are the
 *                other evaluator's job)
 *   isLegacy     the key predates the seat model (payload.seats == null) → 30-day activation grace, and it is
 *                NEVER seat-denied (Option A: existing customers are not seat-capped)
 *   firstRunAt   ms this key was first installed here (starts the bootstrap/activation clock; 0 = unknown → NONE)
 *   activatedAt  ms of the last successful ONLINE activation (server said seat: ok). 0 = never activated.
 *   seatDenied   true ONLY when a reachable server AFFIRMATIVELY returned seat: denied (over the cap). Offline
 *                never sets this. Legacy keys never set this.
 *   seatSuspectedAt persisted ms when seat/activation trouble was first suspected (drives the ≤24h ladder;
 *                survives restarts so relaunching can't reset it). Caller PERSISTS the returned value.
 *   highWater    anti-rollback high-water-mark (ms)
 *   now          injectable clock
 *
 * Returns { enforce, state:'seat', reason, seatSuspectedAt, highWater, wmFrac }. FAIL-OPEN: no key, still
 * within the activation window, already activated-and-not-denied, or ANY exception → enforce:'none'.
 */
export function evaluateSeatEnforcement({ hasValidKey = false, isLegacy = false, firstRunAt = 0, activatedAt = 0, seatDenied = false, seatSuspectedAt = 0, highWater = 0, now = Date.now() } = {}) {
  try {
    const t = trustedNow(now, highWater)
    // Seat logic only applies to a currently-valid key. No key / expired key → the expiry evaluator owns it.
    if (!hasValidKey) return { enforce: ENFORCE.NONE, state: 'seat', reason: 'no-valid-key', seatSuspectedAt: 0, highWater: t, wmFrac: 0 }
    // Legacy keys are NEVER seat-denied (Option A). Treat any stale seatDenied on a legacy key as cleared.
    const denied = isLegacy ? false : !!seatDenied
    // HEALTHY: successfully activated and not currently denied → never enforce; clear any prior suspicion.
    if (activatedAt > 0 && !denied) {
      return { enforce: ENFORCE.NONE, state: 'seat', reason: 'activated', seatSuspectedAt: 0, highWater: t, wmFrac: 0 }
    }
    // Still inside the activation window (freshly installed, not yet activated, not affirmatively denied)
    // → FULL function. New key: 7 days. Legacy key: 30 days.
    const windowMs = isLegacy ? ACTIVATION_GRACE_MS : BOOTSTRAP_MS
    if (!denied && firstRunAt > 0 && (t - firstRunAt) < windowMs) {
      return { enforce: ENFORCE.NONE, state: 'seat', reason: 'activation-window', seatSuspectedAt: 0, highWater: t, wmFrac: 0 }
    }
    if (!denied && !firstRunAt) {
      // We don't know when this key was installed yet → fail-open until firstRunAt is set (caller sets it).
      return { enforce: ENFORCE.NONE, state: 'seat', reason: 'no-firstrun', seatSuspectedAt: 0, highWater: t, wmFrac: 0 }
    }
    // Over the window without activation, OR affirmatively seat-denied → walk the SHORT (≤24h) ladder.
    const sAt = seatSuspectedAt > 0 ? seatSuspectedAt : t
    const since = t - sAt
    const graceLeft = Math.max(0, SEAT_GRACE_MS - since)
    if (graceLeft > 0) {
      return { enforce: ENFORCE.SUSPECTED, state: 'seat', reason: denied ? 'seat-denied-grace' : 'activation-grace', seatSuspectedAt: sAt, highWater: t, wmFrac: 0 }
    }
    const intoWm = since - SEAT_GRACE_MS
    if (intoWm < SEAT_WATERMARK_MS) {
      const wmFrac = SEAT_WATERMARK_MS > 0 ? Math.min(1, Math.max(0, intoWm / SEAT_WATERMARK_MS)) : 1
      return { enforce: ENFORCE.WATERMARK, state: 'seat', reason: denied ? 'seat-denied-watermark' : 'activation-watermark', seatSuspectedAt: sAt, highWater: t, wmFrac }
    }
    return { enforce: ENFORCE.CONFIRMED, state: 'seat', reason: denied ? 'seat-denied' : 'not-activated', seatSuspectedAt: sAt, highWater: t, wmFrac: 1 }
  } catch {
    // Fail OPEN — a paying show is NEVER blocked by an evaluator bug.
    return { enforce: ENFORCE.NONE, state: 'seat', reason: 'eval-error-allow', seatSuspectedAt: 0, highWater: Number(highWater) || 0, wmFrac: 0 }
  }
}

/**
 * Stateful wrapper used by the Companion module. Persists trialStart through a save callback
 * (Companion's saveConfig), and answers "may I send a command right now?".
 */
export class LicenseManager {
  constructor({ key = '', trialStart, saveTrialStart, now = () => Date.now() } = {}) {
    this.key = key
    this.trialStart = trialStart
    this.saveTrialStart = saveTrialStart || (() => {})
    this.now = now
    // Begin the trial clock on first ever run if unlicensed and unstarted.
    if (!this.trialStart && !verifyLicenseKey(this.key).valid) {
      this.trialStart = this.now()
      this.saveTrialStart(this.trialStart)
    }
  }

  setKey(key) {
    this.key = key || ''
  }

  evaluate() {
    return evaluateEntitlement(this.key, this.trialStart, this.now())
  }

  /** Commands are permitted in trial and licensed states, blocked once expired. */
  canSendCommands() {
    return this.evaluate().state !== 'expired'
  }
}
