// 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 }
}

// Thin, NEVER-throwing HTTPS POST to the entitlement endpoint. Sends the current key (proof of account) +
// email; expects a JSON body `{ key: "<signed key>" }` back. Resolves the new key string, or null on ANY
// problem (offline, timeout, non-200, malformed) — the caller treats null as "keep the cached key". `request`
// is injectable for tests (default node:https.request).
export function requestRefresh({ endpoint = DEFAULT_REFRESH_ENDPOINT, currentKey = '', email = '', 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(null) }
    if (u.protocol !== 'https:') return finish(null)   // never send the key over plaintext
    const payload = Buffer.from(JSON.stringify({ product: 'kuesync', key: currentKey, email }), '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(null) }
        let body = ''
        res.setEncoding('utf8')
        res.on('data', (d) => { body += d; if (body.length > 8192) { try { req.destroy() } catch {}; finish(null) } })
        res.on('end', () => {
          try { const j = JSON.parse(body); finish(j && typeof j.key === 'string' && j.key ? j.key : null) }
          catch { finish(null) }
        })
        res.on('error', () => finish(null))
      })
    } catch { return finish(null) }
    req.on('error', () => finish(null))
    req.on('timeout', () => { try { req.destroy() } catch {}; finish(null) })
    try { req.write(payload); req.end() } catch { finish(null) }
  })
}
