// ─────────────────────────────────────────────────────────────────────────────
// Nexus Prime Licensing — LicenseManager
//
// Ed25519-signed JWT validation. Public key is embedded. Private key is kept
// on the Nexus Prime backend (never ships in this package). No network calls —
// validation is fully offline.
//
// License key format:
//   base64url(header).base64url(payload).base64url(signature)
//   header:  { alg: "EdDSA", typ: "NXP" }
//   payload: LicenseClaims
// ─────────────────────────────────────────────────────────────────────────────
import fs from 'fs';
import path from 'path';
import { createPublicKey, verify as cryptoVerify } from 'crypto';
import { resolveNexusStateDir } from '../engines/runtime-registry.js';
import { getCapsForTier, FREE_CAPS } from './caps.js';
// Lazy import to avoid circular dep with engines bootstrapping
async function emitLicenseEvent(eventType, payload) {
    try {
        const { nexusEventBus } = await import('../engines/event-bus.js');
        nexusEventBus.emit(eventType, payload);
    }
    catch { /* non-fatal */ }
}
// ── Embedded Ed25519 public key (SPKI DER, base64) ───────────────────────────
// Replace this with the production key before publishing.
// The matching private key is stored securely on the Nexus Prime licensing backend.
const NEXUS_PUBLIC_KEY_B64 = 'MCowBQYDK2VwAyEAbrBiMBqzIyatM/Q/plA0Dn2Y/TAu2UVmWG8guGI0ElQ=';
const UPGRADE_URL = 'https://nexus-prime.cfd/pricing';
const ACCOUNT_URL = 'https://nexus-prime.cfd/account';
const LICENSE_TEAM_EMAIL = 'adarsh@nexus-prime.cfd';
// Warn at this fraction of the cap (e.g. 0.8 = 80%)
const WARN_THRESHOLD = 0.8;
// Trial configuration. Every install gets a 30-day full-tier trial the first
// time LicenseManager loads with no activated key — Synapse, Architects, all
// paid tools available — so users can actually evaluate the product. The
// trial marker (stateDir/trial.json) records issue + expiry, so subsequent
// loads stay deterministic offline. NEXUS_DISABLE_TRIAL=1 opts out (tests).
const DAY_MS = 24 * 60 * 60 * 1000;
const TRIAL_DURATION_MS = 30 * DAY_MS;
const TRIAL_GRACE_MS = 3 * DAY_MS;
const TRIAL_TIER = 'enterprise';
const TRIAL_MARKER_FILENAME = 'trial.json';
function readTrialMarker(markerPath) {
    try {
        if (!fs.existsSync(markerPath))
            return null;
        const raw = fs.readFileSync(markerPath, 'utf8');
        const parsed = JSON.parse(raw);
        if (typeof parsed.issuedAt !== 'number' || typeof parsed.expiresAt !== 'number')
            return null;
        const tier = parsed.tier === 'pro' || parsed.tier === 'team' || parsed.tier === 'enterprise'
            ? parsed.tier
            : TRIAL_TIER;
        return {
            issuedAt: parsed.issuedAt,
            expiresAt: parsed.expiresAt,
            tier,
            orgId: typeof parsed.orgId === 'string' ? parsed.orgId : null,
        };
    }
    catch {
        return null;
    }
}
function writeTrialMarker(markerPath, marker) {
    try {
        fs.mkdirSync(path.dirname(markerPath), { recursive: true });
        fs.writeFileSync(markerPath, JSON.stringify(marker, null, 2), 'utf8');
    }
    catch {
        // best-effort: trial still applies in memory even if we can't persist it.
    }
}
function clampTrialMarker(marker, now) {
    const issuedAt = Number.isFinite(marker.issuedAt) && marker.issuedAt <= now + DAY_MS
        ? marker.issuedAt
        : now;
    const maxExpiresAt = issuedAt + TRIAL_DURATION_MS;
    const expiresAt = Number.isFinite(marker.expiresAt) && marker.expiresAt >= issuedAt
        ? Math.min(marker.expiresAt, maxExpiresAt)
        : maxExpiresAt;
    const normalized = {
        issuedAt,
        expiresAt,
        tier: TRIAL_TIER,
        orgId: marker.orgId ?? null,
    };
    return {
        marker: normalized,
        changed: normalized.issuedAt !== marker.issuedAt
            || normalized.expiresAt !== marker.expiresAt
            || normalized.tier !== marker.tier
            || normalized.orgId !== marker.orgId,
    };
}
function daysUntil(timestamp, now) {
    return Math.max(0, Math.ceil((timestamp - now) / DAY_MS));
}
function buildActivationMotion(phase, daysRemaining) {
    if (phase === 'expired') {
        return {
            phase: 'expired',
            required: true,
            title: 'Trial expired',
            message: `The trial window is over. Request a license in the Nexus account page or email ${LICENSE_TEAM_EMAIL}. The team will share licenses within 24 hours.`,
            action: 'Request license',
            url: ACCOUNT_URL,
            command: 'nexus-prime license activate <token>',
        };
    }
    if (phase === 'activation') {
        return {
            phase: 'activation',
            required: false,
            title: 'Activate license',
            message: `The 3-day no-license grace period is over, but the 30-day trial remains active for ${daysRemaining} day${daysRemaining === 1 ? '' : 's'}. Keep running Nexus Prime and ask the user to request or activate a license soon. Request one in the Nexus account page or email ${LICENSE_TEAM_EMAIL}; the team will share licenses within 24 hours.`,
            action: 'Request license',
            url: ACCOUNT_URL,
            command: 'nexus-prime license activate <token>',
        };
    }
    return {
        phase: 'grace',
        required: false,
        title: '3-day no-license grace',
        message: `Nexus Prime is in the first 3-day no-license grace period. No license is mandatory during these first 3 days; ask the user to request a license soon so approvals can be shared within 24 hours by the Nexus Prime team.`,
        action: 'Request license',
        url: ACCOUNT_URL,
        command: 'nexus-prime license activate <token>',
    };
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function base64urlDecode(s) {
    return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function decodeJwtPayload(token) {
    const parts = token.trim().split('.');
    if (parts.length !== 3)
        return null;
    const [b64Header, b64Payload, b64Sig] = parts;
    try {
        return {
            header: JSON.parse(base64urlDecode(b64Header).toString('utf8')),
            payloadRaw: base64urlDecode(b64Payload).toString('utf8'),
            payloadBytes: base64urlDecode(b64Payload),
            signatureBytes: base64urlDecode(b64Sig),
            signingInput: `${b64Header}.${b64Payload}`,
        };
    }
    catch {
        return null;
    }
}
function buildFreeStatus(degradedReason) {
    return {
        valid: !degradedReason,
        tier: 'free',
        caps: FREE_CAPS,
        expiresAt: null,
        orgId: null,
        degradedReason,
    };
}
function buildTrialStatus(marker, now = Date.now()) {
    const graceEndsAt = marker.issuedAt + TRIAL_GRACE_MS;
    const daysRemaining = daysUntil(marker.expiresAt, now);
    const trialPhase = now >= graceEndsAt ? 'activation' : 'grace';
    const trialDaysElapsed = Math.max(0, Math.floor((now - marker.issuedAt) / DAY_MS));
    return {
        valid: true,
        tier: marker.tier,
        caps: getCapsForTier(marker.tier),
        expiresAt: marker.expiresAt,
        orgId: marker.orgId,
        trial: true,
        trialPhase,
        trialIssuedAt: marker.issuedAt,
        trialGraceEndsAt: graceEndsAt,
        trialDaysElapsed,
        trialDaysRemaining: daysRemaining,
        activationRequired: false,
        activationMotion: buildActivationMotion(trialPhase, daysRemaining),
    };
}
function buildTrialExpiredStatus(marker, now = Date.now()) {
    return {
        ...buildFreeStatus('trial-expired'),
        trial: false,
        trialPhase: 'expired',
        trialIssuedAt: marker.issuedAt,
        trialGraceEndsAt: marker.issuedAt + TRIAL_GRACE_MS,
        trialDaysElapsed: Math.max(0, Math.floor((now - marker.issuedAt) / DAY_MS)),
        trialDaysRemaining: 0,
        activationRequired: true,
        activationMotion: buildActivationMotion('expired', 0),
    };
}
function validateClaims(claims) {
    if (!claims || typeof claims !== 'object')
        return false;
    const c = claims;
    return (typeof c.plan === 'string' &&
        ['free', 'pro', 'team', 'enterprise'].includes(c.plan) &&
        typeof c.seats === 'number' &&
        typeof c.expires === 'number' &&
        typeof c.iat === 'number');
}
// ── LicenseManager ───────────────────────────────────────────────────────────
export class LicenseManager {
    keyPath;
    trialPath;
    stateDir;
    status;
    constructor(stateDir) {
        const dir = stateDir ?? resolveNexusStateDir();
        this.stateDir = dir;
        this.keyPath = path.join(dir, 'license.key');
        this.trialPath = path.join(dir, TRIAL_MARKER_FILENAME);
        this.status = this.loadAndValidate();
    }
    getStatus() {
        return this.status;
    }
    activate(keyData) {
        const prevTier = this.status.tier;
        const validated = this.parseAndVerify(keyData.trim());
        if (validated.valid) {
            try {
                fs.mkdirSync(path.dirname(this.keyPath), { recursive: true });
                fs.writeFileSync(this.keyPath, keyData.trim(), 'utf8');
            }
            catch {
                // Best-effort disk write — still cache in memory
            }
        }
        this.status = validated;
        if (validated.valid && validated.tier !== prevTier) {
            void emitLicenseEvent('license.tierChanged', { from: prevTier, to: validated.tier, reason: 'activate' });
        }
        return this.status;
    }
    deactivate() {
        const prevTier = this.status.tier;
        try {
            if (fs.existsSync(this.keyPath)) {
                fs.unlinkSync(this.keyPath);
            }
        }
        catch {
            // Best-effort
        }
        // Fall back to whichever local status applies — trial if still in window,
        // free otherwise. Avoids dropping users into a broken-looking runtime
        // simply because they removed an explicit license key mid-trial.
        this.status = this.resolveTrialStatus();
        if (prevTier !== this.status.tier) {
            void emitLicenseEvent('license.tierChanged', { from: prevTier, to: this.status.tier, reason: 'deactivate' });
        }
    }
    /**
     * Check whether `current` usage is approaching or at the cap for `type`.
     * Returns null if unlimited (Infinity cap) or if still under 80%.
     */
    checkCap(type, current) {
        const caps = this.status.caps;
        let limit;
        switch (type) {
            case 'memory_entries':
                limit = caps.maxMemoryEntries;
                break;
            case 'projects':
                limit = caps.maxProjects;
                break;
            case 'operatives':
                limit = caps.maxOperatives;
                break;
        }
        if (!Number.isFinite(limit))
            return null; // Unlimited — no cap
        const ratio = current / limit;
        if (ratio < WARN_THRESHOLD)
            return null; // Well within cap
        return {
            exceeded: current >= limit,
            warning: current < limit,
            current,
            limit,
            tier: this.status.tier,
            upgradeUrl: UPGRADE_URL,
        };
    }
    // ── Private ────────────────────────────────────────────────────────────────
    loadAndValidate() {
        try {
            if (!fs.existsSync(this.keyPath)) {
                return this.resolveTrialStatus();
            }
            const raw = fs.readFileSync(this.keyPath, 'utf8').trim();
            return this.parseAndVerify(raw);
        }
        catch {
            return buildFreeStatus('malformed');
        }
    }
    /**
     * Auto-issued local trial. Every install gets 30 days of Pro caps the first
     * time it boots without an activated key — this keeps the runtime usable
     * while signup/activation flows run, and avoids "catalog degraded" surfaces
     * dropping users into a broken-looking shell when the website is briefly
     * unreachable. Set NEXUS_DISABLE_TRIAL=1 to opt out.
     */
    resolveTrialStatus() {
        if (process.env.NEXUS_DISABLE_TRIAL === '1') {
            return buildFreeStatus('not-activated');
        }
        const now = Date.now();
        let marker = readTrialMarker(this.trialPath);
        if (!marker) {
            marker = {
                issuedAt: now,
                expiresAt: now + TRIAL_DURATION_MS,
                tier: TRIAL_TIER,
                orgId: null,
            };
            writeTrialMarker(this.trialPath, marker);
        }
        else {
            const normalized = clampTrialMarker(marker, now);
            marker = normalized.marker;
            if (normalized.changed) {
                writeTrialMarker(this.trialPath, marker);
            }
        }
        if (marker.expiresAt <= now) {
            return buildTrialExpiredStatus(marker, now);
        }
        return buildTrialStatus(marker, now);
    }
    /** Returns the path of the trial marker (for tests + status surfaces). */
    getTrialMarkerPath() {
        return this.trialPath;
    }
    parseAndVerify(token) {
        const decoded = decodeJwtPayload(token);
        if (!decoded)
            return buildFreeStatus('malformed');
        // Verify Ed25519 signature
        let signatureValid = false;
        try {
            const pubKey = createPublicKey({
                key: Buffer.from(NEXUS_PUBLIC_KEY_B64, 'base64'),
                format: 'der',
                type: 'spki',
            });
            signatureValid = cryptoVerify(null, Buffer.from(decoded.signingInput, 'utf8'), pubKey, decoded.signatureBytes);
        }
        catch {
            return buildFreeStatus('signature-invalid');
        }
        if (!signatureValid)
            return buildFreeStatus('signature-invalid');
        let claims;
        try {
            claims = JSON.parse(decoded.payloadRaw);
        }
        catch {
            return buildFreeStatus('malformed');
        }
        if (!validateClaims(claims))
            return buildFreeStatus('malformed');
        // Check expiry
        if (claims.expires > 0 && Date.now() > claims.expires) {
            return buildFreeStatus('expired');
        }
        const tier = claims.plan;
        const caps = claims.caps ?? getCapsForTier(tier);
        return {
            valid: true,
            tier,
            caps,
            expiresAt: claims.expires > 0 ? claims.expires : null,
            orgId: claims.orgId ?? null,
        };
    }
}
// ── Singleton ─────────────────────────────────────────────────────────────────
let _shared = null;
export function getSharedLicenseManager() {
    if (!_shared) {
        _shared = new LicenseManager();
    }
    return _shared;
}
/** Reset the singleton (for tests only). */
export function _resetSharedLicenseManager() {
    _shared = null;
}
