/**
 * Bridge sync worker — polls LocalSqliteStore for rows in enabled domains,
 * builds sanitized SyncProjectionEnvelopes, POSTs them to the hosted
 * receiver, and records per-row attempt/success/failure into `sync_state`.
 *
 * Worker is a no-op when `sync.enabled` is false or when `sync.endpointUrl`
 * is missing. Enabled workers sign every request with an HMAC over the exact
 * body hash and canonical route. Failures use bounded sanitized reason codes; the
 * worker never logs raw responses.
 */
import { createHash, createHmac, randomUUID } from "node:crypto";
import { buildOnboardingSync, buildPermissionActivitySync, buildProofEvidenceSync, buildRepositorySync, buildReviewQueueSync, buildRunSync, buildRuntimeEventSync, buildSettingsSync, } from "./syncProjectionContract.js";
export const SYNC_FAILURE_REASON_CODES = {
    http_4xx: "sync_http_4xx",
    http_5xx: "sync_http_5xx",
    network_error: "sync_network_error",
    local_state_error: "sync_local_state_error",
    retry_budget_exhausted: "sync_retry_budget_exhausted",
};
/** Default outbound sync POST timeout: 10 seconds. */
export const DEFAULT_SYNC_TIMEOUT_MS = 10_000;
const DEFAULT_POSTER = async (url, body, init = {}) => {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? DEFAULT_SYNC_TIMEOUT_MS);
    try {
        const r = await fetch(url, {
            method: "POST",
            headers: { "content-type": "application/json", ...init.headers },
            body: JSON.stringify(body),
            signal: controller.signal,
        });
        return { ok: r.ok, status: r.status };
    }
    catch {
        return { ok: false, status: 0 };
    }
    finally {
        clearTimeout(timeout);
    }
};
export function buildSyncSigningHeaders(input) {
    return buildSignedRequestHeaders({
        method: "POST",
        path: canonicalSyncPath(input.domain),
        audience: input.audience,
        keyId: input.keyId,
        workspaceId: input.workspaceId,
        body: input.body,
        signingKey: input.signingKey,
        ...(input.now ? { now: input.now } : {}),
        ...(input.nonce ? { nonce: input.nonce } : {}),
    });
}
/**
 * Builds the X-PatchTrail-Sync-* HMAC headers for an arbitrary signed request
 * (method + canonical path). Sync projection push is the POST-to-domain
 * specialization (see buildSyncSigningHeaders); the Layer 2 command channel
 * reuses this same scheme for pull/ack/events so no new auth primitive is
 * introduced. The canonical string is METHOD\npath\ntimestamp\nnonce\n
 * workspaceId\nbodyHash\naudience\nkeyId — byte-identical to the backend
 * verifier.
 */
export function buildSignedRequestHeaders(input) {
    const timestamp = (input.now ?? new Date()).toISOString();
    const nonce = input.nonce ?? randomUUID();
    const bodyJson = typeof input.body === "string" ? input.body : JSON.stringify(input.body);
    const bodyHash = createHash("sha256").update(bodyJson).digest("hex");
    const canonical = [
        input.method,
        input.path,
        timestamp,
        nonce,
        input.workspaceId,
        bodyHash,
        input.audience,
        input.keyId,
    ].join("\n");
    const signature = createHmac("sha256", Buffer.from(input.signingKey, "base64url"))
        .update(canonical)
        .digest("base64url");
    return {
        "X-PatchTrail-Sync-Key-Id": input.keyId,
        "X-PatchTrail-Sync-Audience": input.audience,
        "X-PatchTrail-Sync-Timestamp": timestamp,
        "X-PatchTrail-Sync-Nonce": nonce,
        "X-PatchTrail-Sync-Workspace-Id": input.workspaceId,
        "X-PatchTrail-Sync-Body-Sha256": bodyHash,
        "X-PatchTrail-Sync-Signature": signature,
    };
}
function canonicalSyncPath(domain) {
    return `/api/v1/sync/projections/${domain}`;
}
function emptyDomainCounts() {
    const out = {};
    const domains = [
        "runs",
        "runtimeEvents",
        "proofEvidence",
        "permissionActivity",
        "repositories",
        "onboarding",
        "settings",
        "reviewQueue",
    ];
    for (const d of domains)
        out[d] = { posted: 0, succeeded: 0, failed: 0 };
    return out;
}
function reasonForStatus(status) {
    if (status === 0)
        return SYNC_FAILURE_REASON_CODES.network_error;
    if (status >= 500)
        return SYNC_FAILURE_REASON_CODES.http_5xx;
    return SYNC_FAILURE_REASON_CODES.http_4xx;
}
function countFailure(result, domain) {
    result.failed += 1;
    result.perDomain[domain].failed += 1;
}
export async function runSyncOnce(store, config, options) {
    const result = {
        enabled: config.enabled,
        posted: 0,
        succeeded: 0,
        failed: 0,
        skipped: 0,
        perDomain: emptyDomainCounts(),
    };
    if (!config.enabled || !config.endpointUrl)
        return result;
    if (!config.signingKey)
        return result;
    if (!config.keyId || !config.audience)
        return result;
    const signingKey = config.signingKey;
    const keyId = config.keyId;
    const audience = config.audience;
    const poster = options.poster ?? DEFAULT_POSTER;
    const deviceSigner = options.deviceSigner;
    // A workspace's paired device id, resolved once per cycle. Only a device that
    // has completed pairing (carries a device public key) is eligible to scope a
    // push; an unpaired or absent device leaves the push workspace-scoped.
    const pairedDeviceCache = new Map();
    function pairedDeviceIdForWorkspace(workspaceId) {
        const cached = pairedDeviceCache.get(workspaceId);
        if (cached !== undefined)
            return cached;
        let deviceId = null;
        try {
            const device = store.findDeviceByWorkspace(workspaceId);
            deviceId = device && device.devicePublicKeySpki ? device.id : null;
        }
        catch {
            deviceId = null;
        }
        pairedDeviceCache.set(workspaceId, deviceId);
        return deviceId;
    }
    // Binds a push to its workspace's paired device (Layer 3 Decision 2): the
    // device signs the request's HMAC signature value, proving possession of the
    // key the owner registered. Fail-closed — if there is no paired device or the
    // signer yields nothing, the device headers are omitted and the push stays
    // workspace-scoped, so the bridge never claims a device id it cannot sign for.
    async function withDeviceScope(headers, workspaceId) {
        if (!deviceSigner)
            return headers;
        const deviceId = pairedDeviceIdForWorkspace(workspaceId);
        if (!deviceId)
            return headers;
        const hmacSignature = headers["X-PatchTrail-Sync-Signature"];
        if (!hmacSignature)
            return headers;
        const deviceSignature = await deviceSigner(deviceId, hmacSignature);
        if (!deviceSignature)
            return headers;
        return {
            ...headers,
            "X-PatchTrail-Sync-Device-Id": deviceId,
            "X-PatchTrail-Device-Signature": deviceSignature,
        };
    }
    async function attempt(domain, envelope) {
        if (!envelope) {
            result.skipped += 1;
            return;
        }
        let existing;
        try {
            existing = store.findSyncState(domain, envelope.idempotencyKey);
        }
        catch {
            countFailure(result, domain);
            return;
        }
        if (existing?.lastSuccessAt) {
            // Already synced; skip.
            result.skipped += 1;
            return;
        }
        if (existing && existing.retryCount >= config.retryBudget) {
            safeRecordSyncAttempt({
                domain,
                idempotencyKey: envelope.idempotencyKey,
                workspaceId: envelope.workspaceId,
                rowId: envelope.rowId,
                success: false,
                failureReason: SYNC_FAILURE_REASON_CODES.retry_budget_exhausted,
            });
            countFailure(result, domain);
            return;
        }
        result.posted += 1;
        result.perDomain[domain].posted += 1;
        const url = `${config.endpointUrl}/${domain}`;
        const headers = await withDeviceScope(buildSyncSigningHeaders({
            url,
            domain,
            audience,
            keyId,
            workspaceId: envelope.workspaceId,
            body: envelope,
            signingKey,
        }), envelope.workspaceId);
        let response;
        try {
            response = await poster(url, envelope, {
                headers,
                timeoutMs: config.timeoutMs,
            });
        }
        catch {
            response = { ok: false, status: 0 };
        }
        if (response.ok) {
            const recorded = safeRecordSyncAttempt({
                domain,
                idempotencyKey: envelope.idempotencyKey,
                workspaceId: envelope.workspaceId,
                rowId: envelope.rowId,
                success: true,
            });
            if (recorded) {
                result.succeeded += 1;
                result.perDomain[domain].succeeded += 1;
            }
            else {
                countFailure(result, domain);
            }
        }
        else {
            safeRecordSyncAttempt({
                domain,
                idempotencyKey: envelope.idempotencyKey,
                workspaceId: envelope.workspaceId,
                rowId: envelope.rowId,
                success: false,
                failureReason: reasonForStatus(response.status),
            });
            countFailure(result, domain);
        }
    }
    function safeRecordSyncAttempt(input) {
        try {
            store.recordSyncAttempt(input);
            return true;
        }
        catch {
            return false;
        }
    }
    function readLocalState(domain, read) {
        try {
            return read();
        }
        catch {
            countFailure(result, domain);
            return null;
        }
    }
    async function attemptBuilt(domain, build) {
        let envelope;
        try {
            envelope = build();
        }
        catch {
            countFailure(result, domain);
            return;
        }
        await attempt(domain, envelope);
    }
    for (const workspaceId of options.workspaceIds) {
        const runBackedDomainsEnabled = config.domains.runs ||
            config.domains.runtimeEvents ||
            config.domains.proofEvidence;
        let runs = [];
        let runBackedStateAvailable = true;
        if (runBackedDomainsEnabled) {
            try {
                runs = store.listRunsForWorkspace(workspaceId, 200);
            }
            catch {
                if (config.domains.runs)
                    countFailure(result, "runs");
                if (config.domains.runtimeEvents)
                    countFailure(result, "runtimeEvents");
                if (config.domains.proofEvidence)
                    countFailure(result, "proofEvidence");
                runBackedStateAvailable = false;
            }
        }
        if (runBackedStateAvailable && config.domains.runs) {
            for (const run of runs) {
                await attemptBuilt("runs", () => buildRunSync(run));
            }
        }
        if (runBackedStateAvailable && config.domains.runtimeEvents) {
            for (const run of runs) {
                const page = readLocalState("runtimeEvents", () => store.listRuntimeEventsForRun(run.id, { limit: 200 }));
                if (!page)
                    continue;
                for (const evt of page.events) {
                    await attemptBuilt("runtimeEvents", () => buildRuntimeEventSync(evt, workspaceId));
                }
            }
        }
        if (runBackedStateAvailable && config.domains.proofEvidence) {
            for (const run of runs) {
                const evidence = readLocalState("proofEvidence", () => store.findRunEvidence(run.id));
                if (evidence) {
                    await attemptBuilt("proofEvidence", () => buildProofEvidenceSync(evidence, workspaceId));
                }
            }
        }
        if (config.domains.permissionActivity) {
            const page = readLocalState("permissionActivity", () => store.listPermissionActivity({ workspaceId, limit: 200 }));
            if (page) {
                for (const evt of page.events)
                    await attemptBuilt("permissionActivity", () => buildPermissionActivitySync(evt));
            }
        }
        if (config.domains.repositories) {
            const rows = readLocalState("repositories", () => store.listRepositoryBindings(workspaceId));
            if (rows) {
                for (const row of rows)
                    await attemptBuilt("repositories", () => buildRepositorySync(row));
            }
        }
        if (config.domains.onboarding) {
            const rows = readLocalState("onboarding", () => store.listOnboardingGates(workspaceId));
            if (rows) {
                for (const row of rows)
                    await attemptBuilt("onboarding", () => buildOnboardingSync(row));
            }
        }
        if (config.domains.settings) {
            const rows = readLocalState("settings", () => store.listSettingsReadiness(workspaceId));
            if (rows) {
                for (const row of rows)
                    await attemptBuilt("settings", () => buildSettingsSync(row));
            }
        }
        if (config.domains.reviewQueue) {
            const decisions = readLocalState("reviewQueue", () => store.listReviewDecisions(workspaceId));
            if (decisions) {
                const latestByReview = new Map(decisions.map((d) => [d.reviewId, d]));
                const rows = readLocalState("reviewQueue", () => store.listReviewQueue(workspaceId));
                if (rows) {
                    for (const row of rows)
                        await attemptBuilt("reviewQueue", () => buildReviewQueueSync(row, latestByReview.get(row.id)));
                }
            }
        }
    }
    return result;
}
