import { withTracingSuppressed } from "./trace-suppress.js";
import { OBSERVABILITY_PULL_USER_AGENT } from "../vetra-housekeeping/policy.js";
const DEFAULT_INTERVAL_MS = 15_000;
const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
const DEFAULT_BASE_DOMAIN = "vetra.io";
// Canonical definition lives in the dependency-free housekeeping policy module
// (imported above); re-export for the worker's existing consumers + tests.
export { OBSERVABILITY_PULL_USER_AGENT };
/**
 * Env statuses whose chart renders `global.disabled=true` — no workload/ingress,
 * so there's nothing to poll. STOPPED is housekeeping sleep (wakeable); the rest
 * are teardown. Polling these is wasteful and, for STOPPED, would re-wake the
 * studio via the activator every tick.
 */
const NO_WORKLOAD_STATUSES = new Set([
    "STOPPED",
    "TERMINATING",
    "DESTROYED",
    "ARCHIVED",
]);
// Mirrors processors/vetra-cloud-environment/gitops.ts resolveGenericHost:
// a single DNS label covered by the *.vetra.io wildcard cert.
function genericHost(subdomain, prefix, isApex) {
    return isApex
        ? `${subdomain}.${DEFAULT_BASE_DOMAIN}`
        : `${subdomain}-${prefix}.${DEFAULT_BASE_DOMAIN}`;
}
function defaultBuildAgentUrl(svc) {
    return `https://${genericHost(svc.subdomain, svc.prefix, svc.isApex)}/_proxy/routes`;
}
function parseClintServices(servicesJson) {
    if (!servicesJson)
        return [];
    try {
        const parsed = JSON.parse(servicesJson);
        if (!Array.isArray(parsed))
            return [];
        return parsed;
    }
    catch {
        return [];
    }
}
function isProxyRoute(v) {
    if (typeof v !== "object" || v === null)
        return false;
    const o = v;
    if (typeof o.prefix !== "string" || o.prefix.length === 0)
        return false;
    if (typeof o.upstream !== "string" || o.upstream.length === 0)
        return false;
    return true;
}
/** Map a route prefix to one of {api-graphql, api-mcp, website}. */
function endpointTypeFromPrefix(prefix) {
    if (prefix.includes("/graphql"))
        return "api-graphql";
    if (prefix.includes("/mcp"))
        return "api-mcp";
    return "website";
}
/** Pull `port` out of an upstream URL, falling back to scheme defaults. */
function portFromUpstream(upstream) {
    try {
        const u = new URL(upstream);
        if (u.port)
            return u.port;
        return u.protocol === "https:" ? "443" : "80";
    }
    catch {
        return "0";
    }
}
export class ClintPullWorker {
    config;
    timer = null;
    buildAgentUrl;
    fetchTimeoutMs;
    constructor(config) {
        this.config = config;
        this.buildAgentUrl = config.buildAgentUrl ?? defaultBuildAgentUrl;
        this.fetchTimeoutMs = config.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
    }
    start() {
        if (this.timer)
            return;
        const interval = this.config.intervalMs ?? DEFAULT_INTERVAL_MS;
        const tick = () => {
            this.tickOnce().catch((err) => {
                this.config.logger.warn(`[clint-pull-worker] tick failed: ${err instanceof Error ? err.message : String(err)}`);
            });
        };
        tick();
        this.timer = setInterval(tick, interval);
    }
    stop() {
        if (this.timer)
            clearInterval(this.timer);
        this.timer = null;
    }
    async tickOnce() {
        // Suppress tracing for the whole tick — these polling DB queries +
        // agent HTTP fetches would otherwise each become a root transaction
        // (volume scales O(tenant-count)). See trace-suppress.ts.
        await withTracingSuppressed(async () => {
            const tuples = await this.listClintServices();
            await Promise.all(tuples.map((t) => this.pullOne(t)));
        });
    }
    async listClintServices() {
        const rows = await this.config.envDb
            .selectFrom("environments")
            .select(["id", "subdomain", "services", "status"])
            .execute();
        const out = [];
        for (const row of rows) {
            if (!row.subdomain)
                continue;
            // Skip envs with no running workload — STOPPED (housekeeping sleep) and the
            // terminal statuses render global.disabled=true, so there's nothing to poll.
            // Polling a STOPPED studio would also hit the wake activator every tick and
            // re-wake it, defeating the sleep. See the studio-housekeeping design.
            if (NO_WORKLOAD_STATUSES.has(row.status ?? ""))
                continue;
            const all = parseClintServices(row.services);
            // Apex = sole enabled service (mirrors gitops effectiveApexType's
            // single-service default) → a Studio's lone CLINT agent is at the apex.
            const enabledCount = all.filter((s) => s.enabled === true).length;
            for (const svc of all) {
                if (svc.type === "CLINT" && svc.enabled === true && svc.prefix) {
                    out.push({
                        documentId: row.id,
                        prefix: svc.prefix,
                        subdomain: row.subdomain,
                        isApex: enabledCount === 1,
                    });
                }
            }
        }
        return out;
    }
    async pullOne(svc) {
        const url = this.buildAgentUrl(svc);
        const ac = new AbortController();
        const t = setTimeout(() => ac.abort(), this.fetchTimeoutMs);
        try {
            // Distinct UA so the housekeeping idle signal + wake activator can exclude
            // this poll as automation (not a "proper request"). See studio-housekeeping.
            const res = await fetch(url, {
                signal: ac.signal,
                headers: { "user-agent": OBSERVABILITY_PULL_USER_AGENT },
            });
            if (!res.ok) {
                this.config.logger.warn(`[clint-pull-worker] ${svc.documentId} ${svc.prefix} ${res.status} from ${url}`);
                return;
            }
            const body = (await res.json());
            const raw = Array.isArray(body) ? body : [];
            const routes = raw.filter(isProxyRoute);
            await this.upsert(svc, routes);
        }
        catch (err) {
            const reason = err instanceof Error ? err.message : String(err);
            this.config.logger.warn(`[clint-pull-worker] ${svc.documentId} ${svc.prefix} fetch failed: ${reason}`);
        }
        finally {
            clearTimeout(t);
        }
    }
    async upsert(svc, routes) {
        const now = new Date().toISOString();
        // The route's prefix is the path under the proxy (e.g. "/switchboard/graphql").
        // We use it as the endpointId — stable, unique within a (documentId, prefix).
        const presented = new Set(routes.map((r) => r.prefix));
        // Delete entries no longer present.
        const existing = await this.config.obsDb
            .selectFrom("clint_runtime_endpoints")
            .select(["id", "endpointId"])
            .where("documentId", "=", svc.documentId)
            .where("prefix", "=", svc.prefix)
            .execute();
        const toDelete = existing
            .filter((r) => !presented.has(r.endpointId))
            .map((r) => r.id);
        if (toDelete.length > 0) {
            await this.config.obsDb
                .deleteFrom("clint_runtime_endpoints")
                .where("id", "in", toDelete)
                .execute();
        }
        // Upsert the rest. Replace `lastSeen` on every tick.
        for (const route of routes) {
            const endpointId = route.prefix;
            const id = `${svc.documentId}|${svc.prefix}|${endpointId}`;
            const values = {
                id,
                documentId: svc.documentId,
                prefix: svc.prefix,
                endpointId,
                type: endpointTypeFromPrefix(route.prefix),
                port: portFromUpstream(route.upstream),
                status: "enabled",
                lastSeen: now,
            };
            // ON CONFLICT for upsert. SQLite + Postgres both support this.
            await this.config.obsDb
                .insertInto("clint_runtime_endpoints")
                .values(values)
                .onConflict((oc) => oc.column("id").doUpdateSet({
                type: values.type,
                port: values.port,
                status: values.status,
                lastSeen: values.lastSeen,
            }))
                .execute();
        }
    }
}
