// SparkSoftware Reporting — licensing (Nova-style: self-hosted code, paid
// licence key required to run).
//
//   • Licence keys are issued by the reports.spark-software.de licence server and
//     signed with Ed25519 — the app verifies them OFFLINE with the public
//     key below. No key can be forged without the private key.
//   • Key format:  SPARK.<base64url payload>.<base64url signature>
//     payload = { id, name, email, plan: monthly|yearly, issued, expires }
//   • Fresh installs run as a FREE TRIAL for 30 days, then lock until a key
//     is activated. The trial clock starts on first boot.
//   • Monthly/yearly keys carry an expiry a few days past the billing
//     period; a daily background check against the licence server picks up
//     renewals automatically (each check returns a freshly-signed key), so
//     an active subscription never lapses — and a cancelled one runs out at
//     the period end. No internet = the signed key still works until its
//     own expiry, nothing phones home beyond this one check.
//
// .env (all optional):
//   LICENSE_KEY=SPARK....        seed the key from config instead of the UI
//   LICENSE_SERVER=https://reports.spark-software.de   renewal endpoint
//   LICENSE_PUBKEY=...           override the baked-in public key (vendor use)

import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', 'data');
const LIC_FILE = path.join(DATA_DIR, 'license.json');

// SparkSoftware licence public key (Ed25519, SPKI DER, base64).
const DEFAULT_PUBKEY = 'MCowBQYDK2VwAyEAR9r0waKCNBEjh8M7TPpiycUgEKNFvH9amzYaCKFfRwc=';

const TRIAL_DAYS = 30;
const GRACE_DAYS = 3;         // soft grace after key expiry before locking
const CHECK_EVERY = 20 * 60 * 60 * 1000; // renewal check ~daily

const b64u = {
  dec: (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'),
};

function pubKeyObj() {
  const der = Buffer.from(process.env.LICENSE_PUBKEY || DEFAULT_PUBKEY, 'base64');
  return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
}

// Parse + verify a licence key string. Returns { ok, payload?, error? }.
export function verifyKey(keyStr) {
  try {
    const parts = String(keyStr || '').trim().split('.');
    if (parts.length !== 3 || parts[0] !== 'SPARK') return { ok: false, error: 'Not a SparkSoftware licence key.' };
    const payloadBuf = b64u.dec(parts[1]);
    const sig = b64u.dec(parts[2]);
    if (!crypto.verify(null, payloadBuf, pubKeyObj(), sig)) return { ok: false, error: 'Invalid signature — this key was not issued by spark-software.de.' };
    const payload = JSON.parse(payloadBuf.toString('utf8'));
    if (!payload.expires) return { ok: false, error: 'Malformed key.' };
    return { ok: true, payload };
  } catch {
    return { ok: false, error: 'Could not read that key — paste it exactly as issued.' };
  }
}

function loadStore() {
  try { return JSON.parse(fs.readFileSync(LIC_FILE, 'utf8')); } catch { return {}; }
}
function saveStore(o) {
  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(LIC_FILE, JSON.stringify(o, null, 2));
}

// The trial clock: first boot writes installedAt. Deleting data/ resets the
// file, so we also floor against the oldest artefact this install produced
// (users.json / the sites config's own mtime) — honest friction, not DRM.
function installedAt() {
  const store = loadStore();
  if (store.installedAt) return store.installedAt;
  let t = Date.now();
  for (const probe of [path.join(DATA_DIR, 'users.json'), path.join(__dirname, '..', 'sites.config.json')]) {
    try { t = Math.min(t, fs.statSync(probe).mtimeMs); } catch { /* not there yet */ }
  }
  saveStore({ ...store, installedAt: t });
  return t;
}

// Demo mode never locks (the hosted demo and local evaluation), unless
// enforcement is explicitly requested (used by the test-suite).
const enforced = () => process.env.LICENSE_ENFORCE === '1' || process.env.DEMO_MODE !== '1';

// ── the one status call the rest of the app uses ────────────────────────
// → { mode: 'licensed' | 'trial' | 'locked', blocked, daysLeft, plan,
//     licensee, expires, warning? }
export function licenseStatus() {
  if (!enforced()) return { mode: 'licensed', blocked: false, plan: 'demo', licensee: 'Demo mode', daysLeft: 9999 };
  const store = loadStore();
  const keyStr = store.key || process.env.LICENSE_KEY || '';
  if (keyStr) {
    const v = verifyKey(keyStr);
    if (v.ok) {
      const msLeft = v.payload.expires - Date.now();
      const daysLeft = Math.ceil(msLeft / 86400000);
      if (msLeft > -GRACE_DAYS * 86400000) {
        return {
          mode: 'licensed', blocked: false,
          plan: v.payload.plan || 'yearly', licensee: v.payload.name || v.payload.email || '',
          expires: v.payload.expires, daysLeft,
          warning: daysLeft <= 14 ? `Licence ${daysLeft <= 0 ? 'has expired — renewing…' : `expires in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`}. Renewals apply automatically while the subscription is active.` : null,
        };
      }
      return { mode: 'locked', blocked: true, reason: 'licence-expired', plan: v.payload.plan, licensee: v.payload.name || '', expires: v.payload.expires, daysLeft: 0 };
    }
  }
  // No (valid) key → trial window.
  const elapsed = Date.now() - installedAt();
  const daysLeft = Math.max(0, TRIAL_DAYS - Math.floor(elapsed / 86400000));
  if (daysLeft > 0) return { mode: 'trial', blocked: false, daysLeft, trialDays: TRIAL_DAYS };
  return { mode: 'locked', blocked: true, reason: 'trial-ended', daysLeft: 0 };
}

// Activate (or replace) the licence key. Returns the new status or throws.
export function activateKey(keyStr) {
  const v = verifyKey(keyStr);
  if (!v.ok) throw new Error(v.error);
  if (v.payload.expires < Date.now() - GRACE_DAYS * 86400000) {
    throw new Error('That key has expired — renew at spark-software.de and paste the current key.');
  }
  saveStore({ ...loadStore(), key: String(keyStr).trim(), activatedAt: Date.now(), lastCheck: 0 });
  return licenseStatus();
}

// Daily renewal check — asks the licence server for the current state of
// this licence; an active subscription returns a freshly-signed key with
// the new period's expiry. Failures are silent (the signed key on disk
// keeps working until ITS expiry).
export async function refreshLicense(force = false) {
  const store = loadStore();
  const keyStr = store.key || process.env.LICENSE_KEY;
  if (!keyStr) return null;
  if (!force && store.lastCheck && Date.now() - store.lastCheck < CHECK_EVERY) return null;
  const v = verifyKey(keyStr);
  if (!v.ok) return null;
  const server = (process.env.LICENSE_SERVER || 'https://reports.spark-software.de').replace(/\/$/, '');
  try {
    const res = await fetch(server + '/api/validate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      signal: AbortSignal.timeout(10000),
      body: JSON.stringify({ id: v.payload.id, product: 'spark-reporting' }),
    });
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const data = await res.json();
    saveStore({ ...loadStore(), lastCheck: Date.now(), serverStatus: data.status || null });
    if (data.key) {
      const nv = verifyKey(data.key);
      if (nv.ok && nv.payload.expires > (v.payload.expires || 0)) {
        saveStore({ ...loadStore(), key: data.key });
        console.log('[license] renewed — valid until', new Date(nv.payload.expires).toISOString().slice(0, 10));
      }
    }
    return data;
  } catch {
    saveStore({ ...loadStore(), lastCheck: Date.now() });
    return null; // offline is fine — the signed key is the source of truth
  }
}

export function startLicenseRefresh() {
  setTimeout(() => refreshLicense().catch(() => {}), 30 * 1000);
  setInterval(() => refreshLicense().catch(() => {}), 6 * 60 * 60 * 1000);
}
