/**
 * webhook/handler.ts
 *
 * Platform-agnostic webhook handler for Sanity content change events.
 *
 * Receives Sanity webhook payloads, determines which evaluation areas
 * are affected by the document change, debounces rapid edits, enforces
 * daily budget limits, and dispatches scoped evaluations via GitHub Actions.
 *
 * This handler is stateless between requests (debounce and budget state
 * is held in-memory by the enclosing WebhookHandler instance). It can be
 * mounted in any HTTP framework: Express, Hono, Cloudflare Workers, etc.
 *
 * Flow:
 * 1. Receive Sanity webhook payload
 * 2. Extract document slug from payload
 * 3. Look up affected areas via reverse mapping
 * 4. If no areas affected → ignore (untracked document)
 * 5. Check daily budget → rate-limit if exceeded
 * 6. Push slug into debounce window
 * 7. When debounce window closes → dispatch scoped eval via GitHub Actions
 *
 * @see docs/design-docs/report-store/visibility-workflows.md
 */
import { assessImpact, buildReverseMapping, } from "../pipeline/reverse-mapping.js";
import { createBudgetTracker } from "./budget.js";
import { createDebouncer } from "./debounce.js";
import { dispatchEvaluation } from "./dispatch.js";
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
const DEFAULT_DEBOUNCE_MS = 300_000; // 5 minutes
const DEFAULT_DAILY_BUDGET = 20;
const DEFAULT_REPO = "sanity-labs/ai-literacy-framework";
// ---------------------------------------------------------------------------
// WebhookHandler
// ---------------------------------------------------------------------------
/**
 * A stateful webhook handler that manages debouncing, budgeting,
 * and evaluation dispatch.
 *
 * Create one instance per process and call `handle()` for each
 * incoming webhook payload.
 *
 * ```ts
 * const handler = new WebhookHandler({
 *   githubToken: process.env.GITHUB_TOKEN!,
 *   rootDir: "/path/to/packages/eval",
 * })
 *
 * // In your HTTP handler:
 * app.post("/webhook", async (req) => {
 *   const result = await handler.handle(req.body)
 *   return Response.json(result)
 * })
 * ```
 */
export class WebhookHandler {
    budget;
    config;
    debouncer;
    /** Recent dispatch results (for diagnostics) */
    recentDispatches = [];
    reverseMapping;
    constructor(config) {
        this.config = {
            dailyBudget: config.dailyBudget ?? DEFAULT_DAILY_BUDGET,
            debounceMs: config.debounceMs ?? DEFAULT_DEBOUNCE_MS,
            githubRepo: config.githubRepo ?? DEFAULT_REPO,
            githubToken: config.githubToken,
            rootDir: config.rootDir ?? process.cwd(),
            webhookSecret: config.webhookSecret,
        };
        this.reverseMapping = buildReverseMapping(this.config.rootDir);
        this.budget = createBudgetTracker(this.config.dailyBudget);
        this.debouncer = createDebouncer(this.config.debounceMs, this.onDebounceFlush.bind(this));
    }
    /**
     * Get handler diagnostics (for health check endpoints).
     */
    diagnostics() {
        const budgetState = this.budget.currentState();
        return {
            budget: {
                count: budgetState.count,
                limit: budgetState.limit,
                remaining: this.budget.remaining(),
            },
            pendingSlugs: this.debouncer.pending(),
            recentDispatches: this.recentDispatches.slice(-10),
            trackedSlugs: this.reverseMapping.size,
        };
    }
    /**
     * Handle an incoming Sanity webhook payload.
     *
     * This is the main entry point — call once per webhook request.
     * Returns a result indicating what happened (dispatched, debounced,
     * rate-limited, or ignored).
     */
    handle(payload) {
        // Extract the document slug from the payload
        const slug = extractSlug(payload);
        if (!slug) {
            return {
                reason: "No document slug in payload",
                status: "ignored",
            };
        }
        // Look up affected areas
        const impact = assessImpact([slug], this.reverseMapping);
        if (impact.areas.length === 0) {
            return {
                reason: `Document "${slug}" is not tracked by any evaluation task`,
                status: "ignored",
            };
        }
        // Check daily budget
        if (!this.budget.canDispatch()) {
            const state = this.budget.currentState();
            return {
                dailyBudget: state.limit,
                dailyCount: state.count,
                status: "rate-limited",
            };
        }
        // Push into debounce window
        this.debouncer.push(slug);
        const expiresAt = new Date(Date.now() + this.config.debounceMs).toISOString();
        return {
            pendingSlugs: [...this.debouncer.state().slugs],
            status: "debounced",
            windowExpiresAt: expiresAt,
        };
    }
    /**
     * Force-flush the debounce window (for graceful shutdown).
     */
    async shutdown() {
        await this.debouncer.flush();
    }
    // -------------------------------------------------------------------------
    // Private
    // -------------------------------------------------------------------------
    /**
     * Called when the debounce window closes — dispatches a scoped evaluation.
     */
    async onDebounceFlush(slugs) {
        // Re-check budget (may have been exhausted during debounce window)
        if (!this.budget.canDispatch()) {
            console.warn(`  ⚠️  Budget exhausted during debounce window. Skipping dispatch for: ${slugs.join(", ")}`);
            return;
        }
        const impact = assessImpact(slugs, this.reverseMapping);
        if (impact.areas.length === 0)
            return;
        console.log(`  📤 Dispatching evaluation for areas [${impact.areas.join(", ")}] ` +
            `triggered by document changes: ${slugs.join(", ")}`);
        const result = await dispatchEvaluation({
            areas: impact.areas,
            documentSlug: slugs.join(","),
            taskIds: impact.taskIds,
        }, {
            githubToken: this.config.githubToken,
            repo: this.config.githubRepo,
        });
        // Record dispatch
        this.budget.record();
        this.recentDispatches.push({
            areas: impact.areas,
            result,
            timestamp: new Date().toISOString(),
        });
        // Keep only last 50 dispatches
        if (this.recentDispatches.length > 50) {
            this.recentDispatches.splice(0, this.recentDispatches.length - 50);
        }
        if (result.ok) {
            console.log(`  ✅ Dispatch accepted (HTTP ${result.httpStatus})`);
        }
        else {
            console.warn(`  ⚠️  Dispatch failed: ${result.error}`);
        }
    }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Extract the document slug from a Sanity webhook payload.
 *
 * Sanity webhooks include the document in `result` — the slug is at
 * `result.slug.current` for article documents.
 */
function extractSlug(payload) {
    // Direct slug from projected document
    if (payload.result?.slug?.current) {
        return payload.result.slug.current;
    }
    // Fallback: extract from _id (e.g., "article-groq-introduction")
    // This handles cases where the projection doesn't include slug
    return undefined;
}
