// KueSync — seamless license renewal (0.10.0)
// © 2026 Krometis Analytics. All rights reserved.
//
// Offline-first stays offline-first: at the venue, entitlement is still decided PURELY by the locally
// verified, signed key (see license.js) — this module never gates the show on the network. What it adds is
// a quiet, best-effort PULL so a *renewal* doesn't need a human to copy-paste a new key:
//
//   When the PRIMARY's paid key is within its renewal window (or just lapsed), it asks our server for the
//   NEWEST key issued to the SAME account, proving identity by presenting its CURRENT key. The server
//   (already mints + emails keys on each PayPal renewal) returns the latest signed key. The client then
//   VERIFIES that key locally with the bundled public key and installs it ONLY IF it is genuinely newer —
//   then re-authorizes the fleet over the existing secured link. A spoofed/MITM endpoint can do nothing:
//   it cannot forge a signature, and a stale/older key is rejected by the newer-expiry check.
//
// This file is split into PURE decision helpers (unit-testable without the private signing key) and a thin,
// never-throwing HTTPS transport. The manager wires them; nothing here imports Companion or touches disk.

import https from 'node:https'
import { verifyLicenseKey } from './license.js'

// Built-in entitlement endpoint. Override per-deployment via config.licenseRefreshUrl (https only).
export const DEFAULT_REFRESH_ENDPOINT = 'https://kuesync.com/api/license/refresh'

const DAY_MS = 86400_000

// Decode a key's payload WITHOUT requiring it to be currently valid (we still want the account + expiry of
// an already-expired key so we can ask for its successor). Returns the payload object or null.
export function decodeKeyPayload(key) {
  if (!key || typeof key !== 'string' || !key.includes('.')) return null
  try {
    const r = verifyLicenseKey(key, 0)   // now=0 → never trips the expiry branch; signature must still pass
    if (r && r.payload) return r.payload
  } catch { /* fall through */ }
  // Even if verify throws/fails, try a best-effort decode for the account fields (used only to address the
  // refresh request; the RESULT is always signature-checked before install).
  try { return JSON.parse(Buffer.from(key.split('.')[0], 'base64url').toString('utf8')) } catch { return null }
}

// PURE: should the primary attempt a refresh right now?
//   haveKey      — is a license key present at all? (no key ⇒ trial ⇒ never call out)
//   expiry       — the current key's expiry (ms) or null/0 for a perpetual key
//   now          — clock (ms)
//   lastTryAt    — last attempt (ms) for throttling
//   minIntervalMs— don't retry more often than this (default 6h)
//   renewWindowMs— start refreshing this long before expiry (default 7 days)
// Returns { go:boolean, reason:string }.
export function shouldRefresh({ haveKey, expiry, now, lastTryAt = 0, minIntervalMs = 6 * 3600_000, renewWindowMs = 7 * DAY_MS }) {
  if (!haveKey) return { go: false, reason: 'no-key' }            // trial machines never phone home
  if (lastTryAt && (now - lastTryAt) < minIntervalMs) return { go: false, reason: 'throttled' }
  if (!expiry) return { go: false, reason: 'perpetual' }         // a non-expiring key never needs refreshing
  if (now >= expiry) return { go: true, reason: 'expired' }
  if ((expiry - now) <= renewWindowMs) return { go: true, reason: 'renew-window' }
  return { go: false, reason: 'not-due' }
}

// PURE: is `newPayload` a strictly-better entitlement than `curPayload` for the SAME account? Guards against
// downgrade (older/equal expiry) and cross-account swaps (an attacker can't substitute someone else's key,
// even a validly-signed one). Email match is case-insensitive; we also accept a customer match as a fallback
// when one side lacks an email. Returns { ok, reason }.
export function isNewerEntitlement(newPayload, curPayload) {
  if (!newPayload) return { ok: false, reason: 'no-payload' }
  if (newPayload.product !== 'kuesync') return { ok: false, reason: 'wrong-product' }
  if (curPayload) {
    const e1 = String(curPayload.email || '').trim().toLowerCase()
    const e2 = String(newPayload.email || '').trim().toLowerCase()
    const c1 = String(curPayload.customer || '').trim().toLowerCase()
    const c2 = String(newPayload.customer || '').trim().toLowerCase()
    const emailMatch = e1 && e2 && e1 === e2
    const customerMatch = c1 && c2 && c1 === c2
    // If we have an email on both sides it MUST match; otherwise fall back to a customer match.
    if (e1 && e2) { if (!emailMatch) return { ok: false, reason: 'different-account' } }
    else if (!customerMatch) return { ok: false, reason: 'account-unverified' }
  }
  const curExp = Number(curPayload && curPayload.expiry) || 0
  const newExp = Number(newPayload.expiry) || Number.POSITIVE_INFINITY   // a perpetual successor is "newer"
  if (newExp <= curExp) return { ok: false, reason: 'not-newer' }
  return { ok: true, reason: 'ok' }
}

// PURE: should a fetched key REPLACE the current one? Verifies the SIGNATURE + not-expired (via the injected
// verifier, default the real one) AND that it is newer for the same account. `verify` is injectable so the
// account/expiry comparison can be unit-tested without the private signing key. Returns { ok, payload?, reason }.
export function acceptRefreshedKey(newKey, curPayload, now = Date.now(), verify = verifyLicenseKey) {
  const r = verify(newKey, now)
  if (!r || !r.valid) return { ok: false, reason: (r && r.reason) || 'invalid-signature' }
  const newer = isNewerEntitlement(r.payload, curPayload)
  if (!newer.ok) return { ok: false, reason: newer.reason }
  return { ok: true, payload: r.payload }
}

// 0.11.4 (G1): a check-in result. `reachable` distinguishes "server answered" from "offline" so the caller can
// fail OPEN on offline (never block) yet ACT on an affirmative verdict. `revoked` is true ONLY when the server
// affirmatively says this key is revoked/unpaid/refunded → the agent escalates straight to CONFIRMED. `key` is a
// newer signed key for a renewal (still signature-checked before install). Absent fields default to safe values.
const UNREACHABLE = { key: null, revoked: false, reachable: false }

// PURE: interpret a 200 response body into the check-in verdict. Tolerant of missing fields. Exported for tests.
export function parseRefreshBody(body) {
  try {
    const j = JSON.parse(body)
    if (!j || typeof j !== 'object') return { key: null, revoked: false, seat: undefined, reachable: true }
    const revoked = j.revoked === true || j.status === 'revoked' || j.status === 'unpaid' || j.status === 'refunded'
    const key = (typeof j.key === 'string' && j.key) ? j.key : null
    // 0.12.51 seat/activation verdict. Only the two explicit values are honoured; anything else = no verdict.
    const seat = (j.seat === 'ok' || j.seat === 'denied') ? j.seat : undefined
    return { key, revoked, seat, reachable: true }
  } catch { return { key: null, revoked: false, seat: undefined, reachable: true } }   // reached the server, but malformed → no verdict
}

// Thin, NEVER-throwing HTTPS POST to the entitlement endpoint. Sends the current key (proof of account) + email
// (minimum identifier). Resolves a `{ key, revoked, reachable }` verdict; on ANY transport problem (offline,
// timeout, non-200, bad URL, plaintext) it resolves UNREACHABLE — the caller keeps the cached key and NEVER
// blocks. `request` is injectable for tests (default node:https.request).
export function requestRefresh({ endpoint = DEFAULT_REFRESH_ENDPOINT, currentKey = '', email = '', keyId = '', pub = '', fleetId = '', role = '', ts = 0, sig = '', timeoutMs = 8000, request } = {}) {
  return new Promise((resolve) => {
    let done = false
    const finish = (v) => { if (!done) { done = true; resolve(v) } }
    let u
    try { u = new URL(endpoint) } catch { return finish(UNREACHABLE) }
    if (u.protocol !== 'https:') return finish(UNREACHABLE)   // never send the key over plaintext
    // 0.12.51: carry the machine's signed identity + fleet/role for online seat activation. All optional — a
    // pre-0.12.51 agent (or the module) sends none, and the server treats a keyId-less check-in as legacy
    // (never seat-denied), so this is fully back-compatible.
    const reqBody = { product: 'kuesync', key: currentKey, email }
    if (keyId) reqBody.keyId = keyId
    if (pub) reqBody.pub = pub
    if (fleetId) reqBody.fleetId = fleetId
    if (role) reqBody.role = role
    if (ts) reqBody.ts = ts
    if (sig) reqBody.sig = sig
    const payload = Buffer.from(JSON.stringify(reqBody), 'utf8')
    const opts = {
      method: 'POST',
      hostname: u.hostname,
      port: u.port || 443,
      path: u.pathname + (u.search || ''),
      headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length, 'User-Agent': 'KueSync-Agent' },
      timeout: timeoutMs,
    }
    const reqFn = request || https.request
    let req
    try {
      req = reqFn(opts, (res) => {
        if (res.statusCode !== 200) { try { res.resume() } catch {}; return finish(UNREACHABLE) }
        let body = ''
        res.setEncoding('utf8')
        res.on('data', (d) => { body += d; if (body.length > 8192) { try { req.destroy() } catch {}; finish(parseRefreshBody(body)) } })
        res.on('end', () => finish(parseRefreshBody(body)))
        res.on('error', () => finish(UNREACHABLE))
      })
    } catch { return finish(UNREACHABLE) }
    req.on('error', () => finish(UNREACHABLE))
    req.on('timeout', () => { try { req.destroy() } catch {}; finish(UNREACHABLE) })
    try { req.write(payload); req.end() } catch { finish(UNREACHABLE) }
  })
}
