import { resolve4 } from "node:dns/promises";
import { X509Certificate } from "node:crypto";
import { withTracingSuppressed } from "./trace-suppress.js";
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
/**
 * Map the chart's `app.kubernetes.io/component` label to the
 * TenantService enum used by the GraphQL schema. Unrecognised
 * components (clint agents, registry, helper pods, pre-chart legacy
 * pods without the label) collapse to OTHER.
 */
export function classifyPodService(component) {
    if (component === "connect")
        return "CONNECT";
    if (component === "switchboard")
        return "SWITCHBOARD";
    return "OTHER";
}
/** Uppercase enum values to match GraphQL schema (K8s returns Mixed case).
 *  Handles camelCase (e.g. OutOfSync → OUT_OF_SYNC) and spaces. */
function toUpperEnum(val) {
    return val
        .replace(/([a-z])([A-Z])/g, "$1_$2")
        .toUpperCase()
        .replace(/ /g, "_");
}
// ---------------------------------------------------------------------------
// DB helpers
// ---------------------------------------------------------------------------
export async function upsertEnvironmentStatus(db, row) {
    await db
        .insertInto("environment_status")
        .values(row)
        .onConflict((oc) => oc.column("tenantId").doUpdateSet({
        argoSyncStatus: row.argoSyncStatus,
        argoHealthStatus: row.argoHealthStatus,
        argoLastSyncedAt: row.argoLastSyncedAt,
        argoMessage: row.argoMessage,
        configDriftDetected: row.configDriftDetected,
        tlsCertValid: row.tlsCertValid,
        tlsCertExpiresAt: row.tlsCertExpiresAt,
        domainResolves: row.domainResolves,
        updatedAt: row.updatedAt,
    }))
        .execute();
}
export async function upsertPod(db, row) {
    await db
        .insertInto("environment_pods")
        .values(row)
        .onConflict((oc) => oc.column("id").doUpdateSet({
        phase: row.phase,
        ready: row.ready,
        restartCount: row.restartCount,
        service: row.service,
        component: row.component,
        agent: row.agent,
        updatedAt: row.updatedAt,
    }))
        .execute();
}
export async function insertEvent(db, row) {
    await db
        .insertInto("environment_events")
        .values(row)
        .onConflict((oc) => oc.column("id").doNothing())
        .execute();
}
export async function pruneEvents(db, tenantId, keepCount = 50) {
    const topIds = await db
        .selectFrom("environment_events")
        .select("id")
        .where("tenantId", "=", tenantId)
        .orderBy("timestamp", "desc")
        .limit(keepCount)
        .execute();
    if (topIds.length < keepCount)
        return;
    await db
        .deleteFrom("environment_events")
        .where("tenantId", "=", tenantId)
        .where("id", "not in", topIds.map((r) => r.id))
        .execute();
}
function watchWithReconnect(watch, path, queryParams, onEvent, label) {
    let active = true;
    let consecutiveFailures = 0;
    let requestHandle = null;
    function start() {
        if (!active)
            return;
        watch
            .watch(path, queryParams, (phase, obj) => {
            consecutiveFailures = 0;
            // Watch-event upserts are background poller work — suppress their
            // spans so they don't each become a root transaction. See
            // trace-suppress.ts.
            withTracingSuppressed(() => onEvent(phase, obj)).catch((err) => {
                console.warn(`[watcher:${label}] event handler error`, err);
            });
        }, (err) => {
            if (!active)
                return;
            if (err) {
                consecutiveFailures++;
                console.warn(`[watcher:${label}] watch error (consecutive=${consecutiveFailures})`, err);
            }
            if (consecutiveFailures >= 3) {
                console.warn(`[watcher:${label}] 3 consecutive failures, stopping watcher`);
                active = false;
                return;
            }
            // reconnect
            start();
        })
            .then((handle) => {
            requestHandle = handle;
        })
            .catch((err) => {
            if (!active)
                return;
            consecutiveFailures++;
            console.warn(`[watcher:${label}] failed to start watch`, err);
            if (consecutiveFailures >= 3) {
                console.warn(`[watcher:${label}] 3 consecutive failures, stopping watcher`);
                active = false;
                return;
            }
            start();
        });
    }
    start();
    return {
        abort() {
            active = false;
            requestHandle?.abort();
        },
    };
}
/**
 * Report aggregate DNS + TLS status for the env's configured custom domain.
 *
 * Matches every Ingress in the tenant namespace whose host is either exactly
 * `customDomain` (apex mode — e.g. `admin.vetra.io` itself) or ends with
 * `.<customDomain>` (non-apex mode — e.g. `switchboard.admin.vetra.io`). Runs
 * a DNS resolution and TLS secret inspection per matched ingress and
 * aggregates: `tlsCertValid` / `domainResolves` come back green only if every
 * matched ingress is green, red if any fail, null if nothing matches.
 */
async function checkCustomDomain(kc, coreApi, tenantId, customDomain) {
    const result = {
        domainResolves: null,
        tlsCertValid: null,
        tlsCertExpiresAt: null,
    };
    if (!customDomain)
        return result;
    try {
        const { NetworkingV1Api } = await import("@kubernetes/client-node");
        const networkingApi = kc.makeApiClient(NetworkingV1Api);
        const ingressResponse = await networkingApi.listNamespacedIngress({ namespace: tenantId });
        const ingresses = ingressResponse;
        const suffix = `.${customDomain}`;
        const matching = (ingresses.items ?? []).filter((ing) => {
            const host = ing.spec?.rules?.[0]?.host;
            return !!host && (host === customDomain || host.endsWith(suffix));
        });
        if (matching.length === 0)
            return result;
        let resolvesAll = true;
        let certsAllValid = true;
        let sawAnyCert = false;
        let earliestExpiry = null;
        for (const ing of matching) {
            const host = ing.spec.rules[0].host;
            // DNS check
            try {
                const addresses = await resolve4(host);
                if (addresses.length === 0)
                    resolvesAll = false;
            }
            catch {
                resolvesAll = false;
            }
            // TLS check — read cert-manager-created Secret
            const tlsSecretName = ing.spec?.tls?.[0]?.secretName;
            if (!tlsSecretName) {
                // Ingress without TLS declared — can't judge cert.
                continue;
            }
            try {
                const secret = (await coreApi.readNamespacedSecret({
                    name: tlsSecretName,
                    namespace: tenantId,
                }));
                const certB64 = secret.data?.["tls.crt"];
                if (!certB64) {
                    certsAllValid = false;
                    continue;
                }
                sawAnyCert = true;
                const pem = Buffer.from(certB64, "base64").toString("utf-8");
                const expiresAt = extractCertExpiry(pem);
                if (!expiresAt)
                    continue; // couldn't parse, don't penalise
                if (expiresAt <= new Date())
                    certsAllValid = false;
                if (earliestExpiry === null || expiresAt < earliestExpiry) {
                    earliestExpiry = expiresAt;
                }
            }
            catch {
                // Secret not found yet (cert-manager still issuing, or was deleted)
                certsAllValid = false;
            }
        }
        result.domainResolves = resolvesAll ? 1 : 0;
        result.tlsCertValid = sawAnyCert ? (certsAllValid ? 1 : 0) : 0;
        result.tlsCertExpiresAt = earliestExpiry ? earliestExpiry.toISOString() : null;
    }
    catch (err) {
        console.warn(`[domain-check] error checking custom domain for ${tenantId}:`, err);
    }
    return result;
}
/** Extract the notAfter date from a PEM certificate. */
function extractCertExpiry(pem) {
    try {
        const cert = new X509Certificate(pem);
        return new Date(cert.validTo);
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// Reconciliation (lists current state and upserts to DB)
// ---------------------------------------------------------------------------
async function reconcile(db, envDb, k8sToken, k8sApiUrl) {
    try {
        const { KubeConfig, CustomObjectsApi, CoreV1Api } = await import("@kubernetes/client-node");
        const kc = new KubeConfig();
        if (k8sToken) {
            const server = k8sApiUrl || "https://kubernetes.default.svc";
            kc.loadFromOptions({
                clusters: [{ name: "cluster", server, skipTLSVerify: true }],
                users: [{ name: "user", token: k8sToken }],
                contexts: [{ name: "ctx", cluster: "cluster", user: "user" }],
                currentContext: "ctx",
            });
        }
        else {
            kc.loadFromCluster();
        }
        const customApi = kc.makeApiClient(CustomObjectsApi);
        const coreApi = kc.makeApiClient(CoreV1Api);
        // List ArgoCD apps
        const appsResponse = await customApi.listClusterCustomObject({
            group: "argoproj.io",
            version: "v1alpha1",
            plural: "applications",
        });
        const appsBody = appsResponse;
        for (const app of appsBody.items ?? []) {
            const tenantId = app.metadata?.labels?.["tenant"];
            if (!tenantId)
                continue;
            const syncStatus = app.status?.sync?.status ?? "Unknown";
            const healthStatus = app.status?.health?.status ?? "Unknown";
            // Prefer `reconciledAt` over `operationState.finishedAt`: the latter
            // only advances when argo performs an actual sync operation, so if
            // argo reconciles a new commit and finds the manifests already match
            // the cluster, finishedAt stays stale. reconciledAt updates on every
            // reconcile (polling or webhook-triggered), which is what the
            // deployment reconciler really wants to check "has argo seen our
            // push?". Fall back to finishedAt for apps that haven't reported
            // reconciledAt yet.
            const lastSyncedAt = app.status?.reconciledAt ??
                app.status?.operationState?.finishedAt ??
                null;
            const message = app.status?.health?.message ??
                app.status?.operationState?.message ??
                null;
            const driftDetected = (app.status?.conditions ?? []).some((c) => c.type === "ComparisonError")
                ? 1
                : 0;
            // Fetch the env's configured custom domain so the check targets the
            // exact host the user configured (apex mode) or its <svc>.<domain>
            // additional ingresses (non-apex) — not whichever ingress happens to
            // have "-custom-" in its name.
            const envRow = (await envDb
                .selectFrom("environments")
                .select(["customDomain"])
                .where("tenantId", "=", tenantId)
                .executeTakeFirst());
            const customDomain = envRow?.customDomain ?? null;
            const domainCheck = await checkCustomDomain(kc, coreApi, tenantId, customDomain);
            await upsertEnvironmentStatus(db, {
                tenantId,
                argoSyncStatus: toUpperEnum(syncStatus),
                argoHealthStatus: toUpperEnum(healthStatus),
                argoLastSyncedAt: lastSyncedAt,
                argoMessage: message,
                configDriftDetected: driftDetected,
                tlsCertValid: domainCheck.tlsCertValid,
                tlsCertExpiresAt: domainCheck.tlsCertExpiresAt,
                domainResolves: domainCheck.domainResolves,
                updatedAt: new Date().toISOString(),
            });
        }
        // List all pods across all namespaces (filtered by tenant namespace in DB)
        const podsResponse = await coreApi.listPodForAllNamespaces({});
        const podsBody = podsResponse;
        for (const pod of podsBody.items ?? []) {
            const tenantId = pod.metadata?.namespace;
            const name = pod.metadata?.name;
            if (!tenantId || !name)
                continue;
            const containerStatuses = pod.status?.containerStatuses ?? [];
            const ready = containerStatuses.every((cs) => cs.ready) ? 1 : 0;
            const restartCount = containerStatuses.reduce((sum, cs) => sum + (cs.restartCount ?? 0), 0);
            const labels = pod.metadata?.labels ?? {};
            const component = labels["app.kubernetes.io/component"] ?? null;
            const agent = labels["clint.vetra.io/agent"] ?? null;
            await upsertPod(db, {
                id: `${tenantId}/${name}`,
                tenantId,
                name,
                service: classifyPodService(component),
                component,
                agent,
                phase: toUpperEnum(pod.status?.phase ?? "Unknown"),
                ready,
                restartCount,
                updatedAt: new Date().toISOString(),
            });
        }
    }
    catch (err) {
        console.warn("[reconcile] error during reconciliation", err);
    }
}
// ---------------------------------------------------------------------------
// startWatchers
// ---------------------------------------------------------------------------
export function startWatchers(deps) {
    const { db, envDb, k8sToken, k8sApiUrl = "" } = deps;
    const watcherAborts = [];
    // Reconciliation interval (60 seconds). Suppress tracing for the whole
    // reconcile — its pg queries + k8s API calls are fixed-cadence poller
    // work, not request-driven, and scale O(tenant-count). See
    // trace-suppress.ts.
    const intervalId = setInterval(() => {
        void withTracingSuppressed(() => reconcile(db, envDb, k8sToken, k8sApiUrl));
    }, 60_000);
    // Run an initial reconcile
    void withTracingSuppressed(() => reconcile(db, envDb, k8sToken, k8sApiUrl));
    // Start K8s watches via dynamic import so tests don't require a live cluster
    (async () => {
        try {
            const { KubeConfig, Watch } = await import("@kubernetes/client-node");
            const kc = new KubeConfig();
            if (k8sToken) {
                const server = k8sApiUrl || "https://kubernetes.default.svc";
                kc.loadFromOptions({
                    clusters: [{ name: "cluster", server, skipTLSVerify: true }],
                    users: [{ name: "user", token: k8sToken }],
                    contexts: [{ name: "ctx", cluster: "cluster", user: "user" }],
                    currentContext: "ctx",
                });
            }
            else {
                // Use in-cluster SA token (mounted at /var/run/secrets/...)
                kc.loadFromCluster();
            }
            const watch = new Watch(kc);
            // ArgoCD applications watcher
            const argoAbort = watchWithReconnect(watch, "/apis/argoproj.io/v1alpha1/applications", {}, async (_phase, obj) => {
                const app = obj;
                const tenantId = app.metadata?.labels?.["tenant"];
                if (!tenantId)
                    return;
                const syncStatus = app.status?.sync?.status ?? "Unknown";
                const healthStatus = app.status?.health?.status ?? "Unknown";
                // Prefer reconciledAt (advances on every reconcile, incl. no-op syncs)
                // over operationState.finishedAt (only set on an actual sync op, often
                // null) — matching the periodic poll path. Using finishedAt here left
                // argoLastSyncedAt null until the 60s poll, stalling the deployment
                // reconciler's "argo saw our push" check by up to a minute.
                const lastSyncedAt = app.status?.reconciledAt ??
                    app.status?.operationState?.finishedAt ??
                    null;
                const message = app.status?.health?.message ??
                    app.status?.operationState?.message ??
                    null;
                const driftDetected = (app.status?.conditions ?? []).some((c) => c.type === "ComparisonError")
                    ? 1
                    : 0;
                await upsertEnvironmentStatus(db, {
                    tenantId,
                    argoSyncStatus: toUpperEnum(syncStatus),
                    argoHealthStatus: toUpperEnum(healthStatus),
                    argoLastSyncedAt: lastSyncedAt,
                    argoMessage: message,
                    configDriftDetected: driftDetected,
                    tlsCertValid: null,
                    tlsCertExpiresAt: null,
                    domainResolves: null,
                    updatedAt: new Date().toISOString(),
                });
                // Also record sync/health changes as deployment events
                const eventType = healthStatus === "Degraded" || healthStatus === "Missing"
                    ? "WARNING"
                    : "NORMAL";
                const reason = syncStatus === "Synced" && healthStatus === "Healthy"
                    ? "SyncSucceeded"
                    : syncStatus === "OutOfSync"
                        ? "SyncOutOfSync"
                        : `${syncStatus}/${healthStatus}`;
                await insertEvent(db, {
                    id: `argo-${tenantId}-${Date.now()}`,
                    tenantId,
                    type: eventType,
                    reason,
                    message: message ?? `Sync: ${syncStatus}, Health: ${healthStatus}`,
                    involvedObject: `Application/${tenantId}`,
                    timestamp: lastSyncedAt ?? new Date().toISOString(),
                });
                await pruneEvents(db, tenantId);
            }, "argo-apps");
            watcherAborts.push(argoAbort);
            // Pod watcher
            const podAbort = watchWithReconnect(watch, "/api/v1/pods", {}, async (_phase, obj) => {
                const pod = obj;
                const tenantId = pod.metadata?.namespace;
                const name = pod.metadata?.name;
                if (!tenantId || !name)
                    return;
                const containerStatuses = pod.status?.containerStatuses ?? [];
                const ready = containerStatuses.every((cs) => cs.ready) ? 1 : 0;
                const restartCount = containerStatuses.reduce((sum, cs) => sum + (cs.restartCount ?? 0), 0);
                const labels = pod.metadata?.labels ?? {};
                const component = labels["app.kubernetes.io/component"] ?? null;
                const agent = labels["clint.vetra.io/agent"] ?? null;
                await upsertPod(db, {
                    id: `${tenantId}/${name}`,
                    tenantId,
                    name,
                    service: classifyPodService(component),
                    component,
                    agent,
                    phase: toUpperEnum(pod.status?.phase ?? "Unknown"),
                    ready,
                    restartCount,
                    updatedAt: new Date().toISOString(),
                });
            }, "pods");
            watcherAborts.push(podAbort);
            // Event watcher
            const eventAbort = watchWithReconnect(watch, "/api/v1/events", {}, async (_phase, obj) => {
                const event = obj;
                const id = event.metadata?.uid;
                const tenantId = event.involvedObject?.namespace;
                if (!id || !tenantId)
                    return;
                const involvedObject = event.involvedObject
                    ? `${event.involvedObject.kind ?? ""}/${event.involvedObject.name ?? ""}`
                    : "";
                await insertEvent(db, {
                    id,
                    tenantId,
                    type: toUpperEnum(event.type ?? "Normal"),
                    reason: event.reason ?? "",
                    message: event.message ?? "",
                    involvedObject,
                    timestamp: event.lastTimestamp ??
                        event.eventTime ??
                        new Date().toISOString(),
                });
                await pruneEvents(db, tenantId);
            }, "events");
            watcherAborts.push(eventAbort);
        }
        catch (err) {
            console.warn("[startWatchers] failed to start K8s watches, relying on reconcile loop only", err);
        }
    })();
    return {
        stop() {
            clearInterval(intervalId);
            for (const handle of watcherAborts) {
                handle.abort();
            }
        },
    };
}
