import * as plugins from '../../plugins.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { AcmeCertDoc, ProxyCertDoc } from '../../db/index.js';
import { logger } from '../../logger.js';
import { requireOpsAuth } from '../helpers/auth.js';
/**
 * Mirrors `SmartacmeCertMatcher.getCertificateDomainNameByDomainName` from
 * @push.rocks/smartacme. Inlined here because the original is `private` on
 * SmartAcme. The cert identity ('task.vc' for both 'outline.task.vc' and
 * '*.task.vc') is what AcmeCertDoc is keyed by, so two route domains with
 * the same identity share the same underlying ACME cert.
 *
 * Returns undefined for domains with 4+ levels (matching smartacme's
 * "deeper domains not supported" behavior) and for malformed inputs.
 *
 * Exported for unit testing.
 */
export function deriveCertDomainName(domain) {
    if (domain.startsWith('*.')) {
        return domain.slice(2);
    }
    const parts = domain.split('.');
    if (parts.length < 2 || parts.length > 3)
        return undefined;
    return parts.slice(-2).join('.');
}
export class CertificateHandler {
    opsServerRef;
    typedrouter = new plugins.typedrequest.TypedRouter();
    constructor(opsServerRef) {
        this.opsServerRef = opsServerRef;
        this.opsServerRef.typedrouter?.addTypedRouter(this.typedrouter);
        this.registerHandlers();
    }
    async requireAuth(request, requiredScope) {
        const auth = await requireOpsAuth(this.opsServerRef, request, {
            scope: requiredScope,
            requireAdminIdentity: requiredScope?.endsWith(':write'),
        });
        return auth.userId;
    }
    registerHandlers() {
        const router = this.typedrouter;
        // Get Certificate Overview
        router.addTypedHandler(new plugins.typedrequest.TypedHandler('getCertificateOverview', async (dataArg) => {
            await this.requireAuth(dataArg, 'certificates:read');
            const certificates = await this.buildCertificateOverview();
            const summary = this.buildSummary(certificates);
            return { certificates, summary };
        }));
        // Domain-based reprovision (preferred)
        router.addTypedHandler(new plugins.typedrequest.TypedHandler('reprovisionCertificateDomain', async (dataArg) => {
            await this.requireAuth(dataArg, 'certificates:write');
            return this.reprovisionCertificateDomain(dataArg.domain, dataArg.forceRenew);
        }));
        // Delete certificate
        router.addTypedHandler(new plugins.typedrequest.TypedHandler('deleteCertificate', async (dataArg) => {
            await this.requireAuth(dataArg, 'certificates:write');
            return this.deleteCertificate(dataArg.domain);
        }));
        // Export certificate
        router.addTypedHandler(new plugins.typedrequest.TypedHandler('exportCertificate', async (dataArg) => {
            await this.requireAuth(dataArg, 'certificates:read');
            return this.exportCertificate(dataArg.domain);
        }));
        // Import certificate
        router.addTypedHandler(new plugins.typedrequest.TypedHandler('importCertificate', async (dataArg) => {
            await this.requireAuth(dataArg, 'certificates:write');
            return this.importCertificate(dataArg.cert);
        }));
    }
    /**
     * Build domain-centric certificate overview.
     * Instead of one row per route, we produce one row per unique domain.
     */
    async buildCertificateOverview() {
        const dcRouter = this.opsServerRef.dcRouterRef;
        const smartProxy = dcRouter.smartProxy;
        if (!smartProxy)
            return [];
        const routes = smartProxy.routeManager.getRoutes();
        // Phase 1: Collect unique domains with their associated route info
        const domainMap = new Map();
        for (const route of routes) {
            if (!route.name)
                continue;
            const tls = route.action?.tls;
            if (!tls)
                continue;
            // Skip passthrough routes - they don't manage certificates
            if (tls.mode === 'passthrough')
                continue;
            const routeDomains = route.match.domains
                ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
                : [];
            // Determine source
            let source = 'none';
            if (tls.certificate === 'auto') {
                if (smartProxy.settings.certProvisionFunction) {
                    source = 'provision-function';
                }
                else {
                    source = 'acme';
                }
            }
            else if (tls.certificate && typeof tls.certificate === 'object') {
                source = 'static';
            }
            const canReprovision = source === 'acme' || source === 'provision-function';
            const tlsMode = tls.mode;
            for (const domain of routeDomains) {
                const existing = domainMap.get(domain);
                if (existing) {
                    // Add this route name to the existing domain entry
                    if (!existing.routeNames.includes(route.name)) {
                        existing.routeNames.push(route.name);
                    }
                    // Upgrade source if more specific
                    if (existing.source === 'none' && source !== 'none') {
                        existing.source = source;
                        existing.canReprovision = canReprovision;
                    }
                }
                else {
                    domainMap.set(domain, {
                        routeNames: [route.name],
                        source,
                        tlsMode,
                        canReprovision,
                    });
                }
            }
        }
        // Phase 2: Resolve status for each unique domain
        const certificates = [];
        for (const [domain, info] of domainMap) {
            let status = 'unknown';
            let expiryDate;
            let issuedAt;
            let issuer;
            let error;
            // Check event-based status from certificateStatusMap (now keyed by domain)
            const eventStatus = dcRouter.certificateStatusMap.get(domain);
            if (eventStatus) {
                status = eventStatus.status;
                expiryDate = eventStatus.expiryDate;
                issuedAt = eventStatus.issuedAt;
                error = eventStatus.error;
                if (eventStatus.source) {
                    issuer = eventStatus.source;
                }
            }
            // Try SmartProxy certificate status if no event data
            if (status === 'unknown' && info.routeNames.length > 0) {
                try {
                    const rustStatus = await smartProxy.getCertificateStatus(info.routeNames[0]);
                    if (rustStatus) {
                        if (rustStatus.expiresAt > 0) {
                            expiryDate = new Date(rustStatus.expiresAt).toISOString();
                        }
                        if (rustStatus.source)
                            issuer = rustStatus.source;
                        status = rustStatus.isValid ? 'valid' : 'expired';
                    }
                }
                catch {
                    // Rust bridge may not support this command yet — ignore
                }
            }
            // Check persisted cert data from smartdata document classes
            if (status === 'unknown') {
                const cleanDomain = domain.replace(/^\*\.?/, '');
                // SmartAcme stores certs under the base domain (e.g. example.com for api.example.com)
                const parts = cleanDomain.split('.');
                const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
                const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
                    || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
                const proxyDoc = !acmeDoc ? await ProxyCertDoc.findByDomain(domain) : null;
                if (acmeDoc?.validUntil) {
                    expiryDate = new Date(acmeDoc.validUntil).toISOString();
                    if (acmeDoc.created) {
                        issuedAt = new Date(acmeDoc.created).toISOString();
                    }
                    issuer = 'smartacme-dns-01';
                }
                else if (proxyDoc?.publicKey) {
                    // certStore has the cert — parse PEM for expiry
                    try {
                        const x509 = new plugins.crypto.X509Certificate(proxyDoc.publicKey);
                        expiryDate = new Date(x509.validTo).toISOString();
                        issuedAt = new Date(x509.validFrom).toISOString();
                    }
                    catch { /* PEM parsing failed */ }
                    status = 'valid';
                    issuer = 'cert-store';
                }
                else if (acmeDoc || proxyDoc) {
                    status = 'valid';
                    issuer = 'cert-store';
                }
            }
            // Compute status from expiry date
            if (expiryDate && (status === 'valid' || status === 'unknown')) {
                const expiry = new Date(expiryDate);
                const now = new Date();
                const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
                if (daysUntilExpiry < 0) {
                    status = 'expired';
                }
                else if (daysUntilExpiry < 30) {
                    status = 'expiring';
                }
                else {
                    status = 'valid';
                }
            }
            // Static certs with no other info default to 'valid'
            if (info.source === 'static' && status === 'unknown') {
                status = 'valid';
            }
            // ACME/provision-function routes with no cert data are still provisioning
            if (status === 'unknown' && (info.source === 'acme' || info.source === 'provision-function')) {
                status = 'provisioning';
            }
            // Phase 3: Attach backoff info
            let backoffInfo;
            if (dcRouter.certProvisionScheduler) {
                const bi = await dcRouter.certProvisionScheduler.getBackoffInfo(domain);
                if (bi) {
                    backoffInfo = bi;
                }
            }
            if (backoffInfo && status !== 'valid' && status !== 'expiring') {
                status = 'failed';
                error = error || backoffInfo.lastError;
            }
            certificates.push({
                domain,
                routeNames: info.routeNames,
                status,
                source: info.source,
                tlsMode: info.tlsMode,
                expiryDate,
                issuer,
                issuedAt,
                error,
                canReprovision: info.canReprovision,
                backoffInfo,
            });
        }
        return certificates;
    }
    buildSummary(certificates) {
        const summary = { total: 0, valid: 0, expiring: 0, expired: 0, failed: 0, unknown: 0 };
        summary.total = certificates.length;
        for (const cert of certificates) {
            switch (cert.status) {
                case 'valid':
                    summary.valid++;
                    break;
                case 'expiring':
                    summary.expiring++;
                    break;
                case 'expired':
                    summary.expired++;
                    break;
                case 'failed':
                    summary.failed++;
                    break;
                case 'provisioning': // count as unknown
                case 'unknown':
                    summary.unknown++;
                    break;
            }
        }
        return summary;
    }
    /**
     * Domain-based reprovisioning — clears backoff first, refreshes the smartacme
     * cert (when forceRenew is set), then re-applies routes so the running Rust
     * proxy actually picks up the new cert.
     *
     * Why applyRoutes (not smartProxy.provisionCertificate)?
     *   smartProxy.provisionCertificate(routeName) routes through the Rust ACME
     *   path, which is forcibly disabled whenever certProvisionFunction is set
     *   (smart-proxy.ts:168-171). The only path that re-invokes
     *   certProvisionFunction → bridge.loadCertificate is updateRoutes(), which
     *   we trigger via routeConfigManager.applyRoutes().
     */
    async reprovisionCertificateDomain(domain, forceRenew) {
        const dcRouter = this.opsServerRef.dcRouterRef;
        const smartProxy = dcRouter.smartProxy;
        if (!smartProxy) {
            return { success: false, message: 'SmartProxy is not running' };
        }
        // Find routes matching this domain — fail early if none exist
        const routeNames = dcRouter.findRouteNamesForDomain(domain);
        if (routeNames.length === 0) {
            return { success: false, message: `No routes found for domain '${domain}'` };
        }
        // Respect existing backoff unless explicitly overridden. Automated
        // callers (gateway clients retrying cert fetches) used to reset the
        // backoff and trigger a route re-apply on every request, producing a
        // permanent route-update/provisioning cycle for domains whose DNS-01
        // can never succeed.
        const scheduler = dcRouter.certProvisionScheduler;
        if (!forceRenew && scheduler && (await scheduler.isInBackoff(domain))) {
            const info = await scheduler.getBackoffInfo(domain);
            return {
                success: false,
                message: `Domain ${domain} is in provisioning backoff` +
                    `${info ? ` (${info.failures} failures, retry after ${info.retryAfter})` : ''}` +
                    `${info?.lastError ? `: ${info.lastError}` : ''}. Set forceRenew to override.`,
            };
        }
        // Clearing backoff is an explicit operator override
        if (forceRenew && scheduler) {
            await scheduler.clearBackoff(domain);
        }
        // If forceRenew, order a fresh cert from ACME now so it's already in
        // AcmeCertDoc by the time certProvisionFunction is invoked below.
        //
        // includeWildcard: when forcing a non-wildcard subdomain renewal, we still
        // want the wildcard SAN in the order so the new cert keeps covering every
        // sibling. Without this, smartacme defaults to includeWildcard: false and
        // the re-issued cert would have only the base domain as SAN, breaking every
        // sibling subdomain that was previously covered by the same wildcard cert.
        if (forceRenew && dcRouter.smartAcme) {
            let newCert;
            try {
                newCert = await dcRouter.smartAcme.getCertificateForDomain(domain, {
                    forceRenew: true,
                    includeWildcard: !domain.startsWith('*.'),
                });
            }
            catch (err) {
                return { success: false, message: `Failed to renew certificate for ${domain}: ${err.message}` };
            }
            // Propagate the freshly-issued cert PEM to every sibling route domain that
            // shares the same cert identity. Without this, the rust hot-swap (keyed by
            // exact domain in `loaded_certs`) only fires for the clicked route via the
            // fire-and-forget cert provisioning path, leaving siblings serving the
            // stale in-memory cert until the next background reload completes.
            try {
                await this.propagateCertToSiblings(domain, newCert);
            }
            catch (err) {
                // Best-effort: failure here doesn't undo the cert issuance, just log.
                logger.log('warn', `Failed to propagate force-renewed cert to siblings of ${domain}: ${err.message}`);
            }
        }
        // Clear status map entry so it gets refreshed by the certificate-issued event
        dcRouter.certificateStatusMap.delete(domain);
        // Trigger the full route apply pipeline:
        //   applyRoutes → updateRoutes → provisionCertificatesViaCallback →
        //   certProvisionFunction(domain) → smartAcme.getCertificateForDomain →
        //   bridge.loadCertificate → Rust hot-swaps `loaded_certs` →
        //   certificate-issued event → certificateStatusMap updated
        try {
            if (dcRouter.routeConfigManager) {
                await dcRouter.routeConfigManager.applyRoutes();
            }
            else {
                // Fallback when DB is disabled and there is no RouteConfigManager
                await smartProxy.updateRoutes(smartProxy.routeManager.getRoutes());
            }
            return { success: true, message: forceRenew ? `Certificate force-renewed for domain '${domain}'` : `Certificate reprovisioning triggered for domain '${domain}'` };
        }
        catch (err) {
            return { success: false, message: err.message || `Failed to reprovision certificate for ${domain}` };
        }
    }
    /**
     * After a force-renew, walk every route in the smartproxy that resolves to
     * the same cert identity as `forcedDomain` and write the freshly-issued cert
     * PEM into ProxyCertDoc for each. This guarantees that the next applyRoutes
     * → provisionCertificatesViaCallback iteration will hot-swap every sibling's
     * rust loaded_certs entry with the new (correct) PEM, rather than relying on
     * the in-memory cert returned by smartacme's per-domain cache.
     *
     * Why this is necessary:
     *   Rust's `loaded_certs` is a HashMap<domain, TlsCertConfig>. Each
     *   bridge.loadCertificate(domain, ...) only swaps that one entry. The
     *   fire-and-forget cert provisioning path triggered by updateRoutes does
     *   eventually iterate every auto-cert route, but it returns the cached
     *   (broken pre-fix) cert from smartacme's per-domain mutex. With this
     *   helper, ProxyCertDoc is updated synchronously to the correct PEM before
     *   applyRoutes runs, so even the transient window stays consistent.
     */
    async propagateCertToSiblings(forcedDomain, newCert) {
        const dcRouter = this.opsServerRef.dcRouterRef;
        const smartProxy = dcRouter.smartProxy;
        if (!smartProxy)
            return;
        const certIdentity = deriveCertDomainName(forcedDomain);
        if (!certIdentity)
            return;
        // Collect every route domain whose cert identity matches.
        const affected = new Set();
        for (const route of smartProxy.routeManager.getRoutes()) {
            if (!route.match.domains)
                continue;
            const routeDomains = Array.isArray(route.match.domains)
                ? route.match.domains
                : [route.match.domains];
            for (const routeDomain of routeDomains) {
                if (deriveCertDomainName(routeDomain) === certIdentity) {
                    affected.add(routeDomain);
                }
            }
        }
        if (affected.size === 0)
            return;
        // Parse expiry from PEM (defense-in-depth — same pattern as
        // ts/classes.dcrouter.ts:988-995 and the existing certStore.save callback).
        let validUntil = newCert.validUntil;
        let validFrom;
        if (newCert.publicKey) {
            try {
                const x509 = new plugins.crypto.X509Certificate(newCert.publicKey);
                validUntil = new Date(x509.validTo).getTime();
                validFrom = new Date(x509.validFrom).getTime();
            }
            catch { /* fall back to smartacme's value */ }
        }
        // Persist new cert PEM under each affected route domain
        for (const routeDomain of affected) {
            let doc = await ProxyCertDoc.findByDomain(routeDomain);
            if (!doc) {
                doc = new ProxyCertDoc();
                doc.domain = routeDomain;
            }
            doc.publicKey = newCert.publicKey;
            doc.privateKey = newCert.privateKey;
            doc.ca = '';
            doc.validUntil = validUntil || 0;
            doc.validFrom = validFrom || 0;
            await doc.save();
            // Clear status so the next event refresh shows the new cert
            dcRouter.certificateStatusMap.delete(routeDomain);
        }
        logger.log('info', `Propagated force-renewed cert for ${forcedDomain} (cert identity '${certIdentity}') to ${affected.size} sibling route domain(s): ${[...affected].join(', ')}`);
    }
    /**
     * Delete certificate data for a domain from storage
     */
    async deleteCertificate(domain) {
        const dcRouter = this.opsServerRef.dcRouterRef;
        const cleanDomain = domain.replace(/^\*\.?/, '');
        const parts = cleanDomain.split('.');
        const baseDomain = parts.length > 2 ? parts.slice(-2).join('.') : cleanDomain;
        // Delete from smartdata document classes (try base domain first, then exact)
        const acmeDoc = await AcmeCertDoc.findByDomain(baseDomain)
            || (baseDomain !== cleanDomain ? await AcmeCertDoc.findByDomain(cleanDomain) : null);
        if (acmeDoc) {
            await acmeDoc.delete();
        }
        // Try both original domain and clean domain for proxy certs
        for (const d of [domain, cleanDomain]) {
            const proxyDoc = await ProxyCertDoc.findByDomain(d);
            if (proxyDoc) {
                await proxyDoc.delete();
            }
        }
        // Clear from in-memory status map
        dcRouter.certificateStatusMap.delete(domain);
        // Clear backoff info
        if (dcRouter.certProvisionScheduler) {
            await dcRouter.certProvisionScheduler.clearBackoff(domain);
        }
        return { success: true, message: `Certificate data deleted for '${domain}'` };
    }
    /**
     * Export certificate data for a domain as ICert-shaped JSON
     */
    async exportCertificate(domain) {
        const cleanDomain = domain.replace(/^\*\.?/, '');
        // Try AcmeCertDoc first (has full ICert fields)
        const acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
        if (acmeDoc && acmeDoc.publicKey && acmeDoc.privateKey) {
            return {
                success: true,
                cert: {
                    id: acmeDoc.id || plugins.crypto.randomUUID(),
                    domainName: acmeDoc.domainName || domain,
                    created: acmeDoc.created || Date.now(),
                    validUntil: acmeDoc.validUntil || 0,
                    privateKey: acmeDoc.privateKey,
                    publicKey: acmeDoc.publicKey,
                    csr: acmeDoc.csr || '',
                },
            };
        }
        // Fallback: try ProxyCertDoc with original domain, then clean domain
        let proxyDoc = await ProxyCertDoc.findByDomain(domain);
        if (!proxyDoc || !proxyDoc.publicKey) {
            proxyDoc = await ProxyCertDoc.findByDomain(cleanDomain);
        }
        if (proxyDoc && proxyDoc.publicKey && proxyDoc.privateKey) {
            return {
                success: true,
                cert: {
                    id: plugins.crypto.randomUUID(),
                    domainName: domain,
                    created: proxyDoc.validFrom || Date.now(),
                    validUntil: proxyDoc.validUntil || 0,
                    privateKey: proxyDoc.privateKey,
                    publicKey: proxyDoc.publicKey,
                    csr: '',
                },
            };
        }
        return { success: false, message: `No certificate data found for '${domain}'` };
    }
    /**
     * Import a certificate from ICert-shaped JSON
     */
    async importCertificate(cert) {
        // Validate PEM content
        if (!cert.publicKey || !cert.publicKey.includes('-----BEGIN CERTIFICATE-----')) {
            return { success: false, message: 'Invalid publicKey: must contain a PEM-encoded certificate' };
        }
        if (!cert.privateKey || !cert.privateKey.includes('-----BEGIN')) {
            return { success: false, message: 'Invalid privateKey: must contain a PEM-encoded key' };
        }
        const dcRouter = this.opsServerRef.dcRouterRef;
        const cleanDomain = cert.domainName.replace(/^\*\.?/, '');
        // Save to AcmeCertDoc (SmartAcme-compatible)
        let acmeDoc = await AcmeCertDoc.findByDomain(cleanDomain);
        if (!acmeDoc) {
            acmeDoc = new AcmeCertDoc();
            acmeDoc.domainName = cleanDomain;
        }
        acmeDoc.id = cert.id;
        acmeDoc.created = cert.created;
        acmeDoc.validUntil = cert.validUntil;
        acmeDoc.privateKey = cert.privateKey;
        acmeDoc.publicKey = cert.publicKey;
        acmeDoc.csr = cert.csr || '';
        await acmeDoc.save();
        // Also save to ProxyCertDoc (proxy-cert format)
        let proxyDoc = await ProxyCertDoc.findByDomain(cert.domainName);
        if (!proxyDoc) {
            proxyDoc = new ProxyCertDoc();
            proxyDoc.domain = cert.domainName;
        }
        proxyDoc.publicKey = cert.publicKey;
        proxyDoc.privateKey = cert.privateKey;
        proxyDoc.ca = '';
        proxyDoc.validUntil = cert.validUntil;
        proxyDoc.validFrom = cert.created;
        await proxyDoc.save();
        // Update in-memory status map
        dcRouter.certificateStatusMap.set(cert.domainName, {
            status: 'valid',
            source: 'static',
            expiryDate: cert.validUntil ? new Date(cert.validUntil).toISOString() : undefined,
            issuedAt: cert.created ? new Date(cert.created).toISOString() : undefined,
            routeNames: [],
        });
        return { success: true, message: `Certificate imported for '${cert.domainName}'` };
    }
}
//# sourceMappingURL=data:application/json;base64, 