import { markChangesPushed } from "../../document-models/vetra-cloud-environment/index.js";
import { syncEnvironment, deleteEnvironmentFromGitops, getTenantId } from "./gitops.js";
import { removeEnvironmentRecord } from "./cleanup.js";
import { childLogger } from "document-model";
// Re-export the factory under the codegen-expected name so the auto-
// generated processors/switchboard.ts (which imports
// `vetraCloudEnvironmentFactoryBuilder` from this module) resolves.
export { vetraCloudEnvironmentProcessorFactory as vetraCloudEnvironmentFactoryBuilder } from "./factory.js";
const logger = childLogger(["vetra-cloud-environment-processor"]);
export class VetraCloudEnvironmentProcessor {
    relationalDb;
    dispatch;
    documentView;
    /**
     * Optional — null when OPENBAO_ADDR is unset (local dev, tests). When
     * present, gitops sync routes secret env entries through the encrypted
     * tenant_secrets table so they never land in values.yaml plaintext.
     */
    secretsService;
    constructor(relationalDb, dispatch, documentView, secretsService = null) {
        this.relationalDb = relationalDb;
        this.dispatch = dispatch;
        this.documentView = documentView;
        this.secretsService = secretsService;
    }
    async dispatchAction(documentId, action) {
        try {
            await this.dispatch.execute(documentId, "main", [action]);
            logger.info(`Dispatched ${action.type} for ${documentId}`);
        }
        catch (err) {
            logger.error(`Failed to dispatch ${action.type} for ${documentId}: ${String(err)}`);
        }
    }
    async onOperations(operations) {
        if (operations.length === 0)
            return;
        logger.info(`Received ${operations.length} operations`);
        // Collect deleted document IDs from drive operations
        const deletedIds = new Set();
        for (const { operation, context } of operations) {
            if (context.documentType === "powerhouse/document-drive") {
                const deletedId = await this.handleDriveOperation(operation);
                if (deletedId)
                    deletedIds.add(deletedId);
            }
        }
        // Deduplicate environment operations: only process the last operation per document.
        // Skip documents that were deleted in this same batch to avoid resurrecting them.
        const lastByDocument = new Map();
        // Capture the FIRST user-signed signer per document for the legacy createdBy column.
        // We still populate createdBy for historical reference and to seed the owner-backfill
        // in the observability subgraph, but ownership itself is now sourced from state.owner.
        const firstUserSignerByDocument = new Map();
        for (const entry of operations) {
            if (entry.context.documentType === "powerhouse/vetra-cloud-environment"
                && !deletedIds.has(entry.context.documentId)) {
                lastByDocument.set(entry.context.documentId, entry);
                if (!firstUserSignerByDocument.has(entry.context.documentId)) {
                    const userAddress = extractUserSignerAddress(entry.operation);
                    if (userAddress) {
                        firstUserSignerByDocument.set(entry.context.documentId, userAddress);
                    }
                }
            }
        }
        if (lastByDocument.size > 0) {
            logger.info(`Processing ${lastByDocument.size} environment(s) ` +
                `(deduplicated from ${operations.filter((o) => o.context.documentType === "powerhouse/vetra-cloud-environment").length} operations)`);
        }
        for (const [documentId, { operation, context }] of lastByDocument) {
            let phState = context.resultingState
                ? JSON.parse(context.resultingState)
                : undefined;
            // Fallback: fetch current document state if resultingState is missing
            if (!phState) {
                try {
                    const doc = await this.documentView.get(documentId);
                    phState = doc.state;
                    if (phState) {
                        logger.info(`Fetched current state for ${documentId} (resultingState was missing)`);
                    }
                }
                catch (err) {
                    logger.warn(`Failed to fetch state for ${documentId}: ${String(err)}`);
                }
            }
            if (!phState) {
                logger.warn(`No state available for operation ${operation.index} on ${documentId}`);
                continue;
            }
            const state = phState.global;
            const { owner, label: envLabel, genericSubdomain, customDomain, packages, services, status, autoUpdateChannel } = state;
            const label = envLabel ?? documentId;
            const tenantId = genericSubdomain
                ? getTenantId(genericSubdomain, documentId)
                : null;
            logger.info(`Processing document ${label} (op ${operation.index}): ` +
                `label=${envLabel ?? "unset"}, status=${status ?? "unset"}, ` +
                `subdomain=${genericSubdomain ?? "unset"}, tenantId=${tenantId ?? "unset"}, ` +
                `owner=${owner ?? "unset"}, ` +
                `services=[${services?.map((s) => `${s.type}:${s.enabled}`).join(", ") ?? ""}], ` +
                `packages=[${(packages?.map((p) => `${p.name}@${p.version}`).join(", ")) ?? ""}]`);
            // owner mirrors state.owner, refreshed on every upsert (it can change via
            // a SET_OWNER transfer). Normalize to lowercase for consistent matching
            // against the auth context's user address.
            const ownerNormalized = owner ? owner.toLowerCase() : null;
            const row = {
                name: envLabel ?? null,
                subdomain: genericSubdomain ?? null,
                tenantId,
                customDomain: customDomain?.domain ?? null,
                packages: JSON.stringify(packages ?? []),
                services: JSON.stringify(services ?? []),
                status: status ?? null,
                owner: ownerNormalized,
                autoUpdateChannel: autoUpdateChannel ?? null,
            };
            // createdBy is INSERT-only — never overwritten by later updates.
            // Pulled from the first user-signed op in this batch for this document.
            // Kept for historical reference and to seed the owner-backfill.
            const createdBy = firstUserSignerByDocument.get(documentId) ?? null;
            logger.info(`Upserting environment record for "${label}"` +
                `${createdBy ? ` (createdBy=${createdBy})` : ""}` +
                `${ownerNormalized ? ` (owner=${ownerNormalized})` : ""}`);
            await this.relationalDb
                .insertInto("environments")
                .values({ id: documentId, ...row, createdBy })
                .onConflict((oc) => oc.column("id").doUpdateSet(row))
                .execute();
            // Only sync to git when changes are approved
            if (status === "CHANGES_APPROVED") {
                logger.info(`Triggering gitops sync for "${label}"`);
                try {
                    await syncEnvironment(this.relationalDb, state, documentId, this.secretsService);
                    logger.info(`Gitops sync completed for "${label}"`);
                    // Re-check status before dispatching to avoid duplicate transitions
                    // when multiple processor instances process the same document
                    const freshDoc = await this.documentView.get(documentId);
                    const freshStatus = freshDoc?.state?.global?.status;
                    if (freshStatus !== "CHANGES_APPROVED") {
                        logger.info(`Skipping MARK_CHANGES_PUSHED for "${label}" — status already changed to ${freshStatus}`);
                    }
                    else {
                        await this.dispatchAction(documentId, markChangesPushed({}));
                    }
                }
                catch (error) {
                    logger.error(`Gitops sync failed for "${label}": ${String(error)}`);
                }
            }
            else if (status === "STOPPED") {
                // Housekeeping sleep: re-render so the chart sees global.disabled=true
                // (workload + ingress removed; namespace/PVC/cert kept). No
                // MARK_CHANGES_PUSHED dance — STOPPED is a resting state, not a deploy.
                // (Wake flips the env back to CHANGES_APPROVED, which re-enables via the
                // branch above and runs the normal deploy pipeline.)
                logger.info(`Triggering gitops sync for "${label}" (sleep → disabled)`);
                try {
                    await syncEnvironment(this.relationalDb, state, documentId, this.secretsService);
                    logger.info(`Gitops sleep sync completed for "${label}"`);
                }
                catch (error) {
                    logger.error(`Gitops sleep sync failed for "${label}": ${String(error)}`);
                }
            }
            else {
                // No gitops re-render on a bare ownership transfer (warm-pool claim).
                // ADMINS — the only owner-gated value that remained after the network
                // lock was removed — is now delivered through the tenant Secret in the
                // claim's batched write (one Reloader bounce), so the slow ~30s
                // owner-change gitops rollout is gone. A real config change still
                // renders via the CHANGES_APPROVED path above.
                logger.info(`Skipping gitops sync for "${label}" (status: ${status})`);
            }
        }
    }
    /**
     * Handle a drive-level operation. Returns the deleted document ID if a
     * DELETE_NODE was processed, so the caller can skip syncing it.
     */
    async handleDriveOperation(operation) {
        if (operation.action.type !== "DELETE_NODE")
            return undefined;
        const input = operation.action.input;
        const deletedNodeId = input?.id;
        if (!deletedNodeId)
            return undefined;
        const removed = await removeEnvironmentRecord(this.relationalDb, deletedNodeId);
        if (!removed)
            return deletedNodeId;
        const label = removed.name ?? deletedNodeId;
        logger.info(`Deleting environment record for "${label}"`);
        // Clean up gitops tenant directory
        if (removed.subdomain) {
            const tenantId = getTenantId(removed.subdomain, deletedNodeId);
            logger.info(`Removing gitops tenant "${tenantId}" for deleted environment "${label}"`);
            try {
                await deleteEnvironmentFromGitops(tenantId);
                logger.info(`Gitops cleanup completed for "${label}"`);
            }
            catch (error) {
                logger.error(`Gitops cleanup failed for "${label}": ${String(error)}`);
            }
        }
        return deletedNodeId;
    }
    async onDisconnect() { }
}
/**
 * Extract a user's EthereumAddress from a signed action's signer context.
 * Returns null for unsigned actions, or for actions signed by system identities
 * (e.g. switchboard) where there is no human user.
 *
 * The returned address is lowercased to match the convention used by
 * reactor-api's AuthService when comparing against the configured ADMINS list.
 */
function extractUserSignerAddress(operation) {
    const signer = operation.action?.context?.signer;
    if (!signer)
        return null;
    const userAddress = signer.user?.address;
    if (!userAddress)
        return null;
    // System actions have an empty user address (only an app signer);
    // we only want human creators here.
    if (typeof userAddress !== "string" || userAddress.length === 0)
        return null;
    return userAddress.toLowerCase();
}
