/**
 * report-store.ts
 *
 * Persistent report store backed by the Sanity Content Lake.
 * Provides write, read, and lineage-query operations for evaluation reports.
 *
 * The Sanity Content Lake is the system of record (P1: immutable events).
 * Reports are stored as typed documents in the shared dataset, enabling
 * GROQ queries, Studio dashboards, and webhook-driven notifications.
 *
 * If Sanity is unreachable, the store logs a warning and returns gracefully
 * (P5: local-first — the pipeline never fails because of a store write).
 *
 * @see docs/design-docs/report-store/architecture.md
 * @see docs/design-docs/report-store/domain-model.md
 */
import { ReportSchema } from "./_vendor/ailf-core/index.js";
import { getAilfSanityClient } from "./sanity/client.js";
import { compare } from "./pipeline/compare.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const REPORT_TYPE = "ailf.report";
// ---------------------------------------------------------------------------
// Report Store
// ---------------------------------------------------------------------------
export class ReportStore {
    client;
    constructor(options = {}) {
        if (options.client) {
            this.client = options.client;
        }
        else {
            this.client = getAilfSanityClient({
                ...(options.dataset ? { dataset: options.dataset } : {}),
                ...(options.projectId ? { projectId: options.projectId } : {}),
                ...(options.token ? { token: options.token } : {}),
            });
        }
    }
    /**
     * Auto-compare: find the most recent comparable report and compute
     * a ComparisonReport using the existing compare() primitive.
     *
     * Baseline selection order:
     * 1. Pinned baseline — if `provenance.lineage.comparedAgainst` is already
     *    set (from `compareBaselineReportId`, e.g. a doc-impact run), compare
     *    against that exact report. An explicitly pinned baseline NEVER falls
     *    back to fuzzy matching: silently comparing against a different report
     *    than the recorded lineage is the bug this branch exists to prevent.
     *    If the pinned report can't be loaded, return null (the caller keeps
     *    the requested id on lineage but records no comparison).
     * 2. Explicit lineage — if `provenance.lineage.rerunOf` is set,
     *    compare against that specific report (deterministic re-run comparison)
     * 3. Fuzzy matching — most recent report with same mode + source name
     *
     * Returns the comparison plus the baseline report ID so the caller
     * can record `provenance.lineage.comparedAgainst`. For a pinned baseline
     * the returned id equals the pinned id, so the caller's write-back is a
     * no-op rather than an override.
     *
     * @param scopedAreas When provided, the baseline's scores are filtered to
     *   only include these areas before comparison. This prevents mismatched
     *   areas from polluting the overall delta (e.g., release auto-scope
     *   evaluates only GROQ but the baseline has all areas).
     * @returns The comparison result with baseline ID, or null if no baseline found
     */
    async autoCompare(currentSummary, provenance, completedAt, scopedAreas) {
        let baseline = null;
        // 1. Pinned baseline (compareBaselineReportId, surfaced as
        //    lineage.comparedAgainst before this call). The evaluator pinned it;
        //    never silently fall back to fuzzy, which would compare against a
        //    different report than the recorded lineage claims.
        const pinnedBaselineId = provenance.lineage?.comparedAgainst;
        if (pinnedBaselineId) {
            baseline = await this.read(pinnedBaselineId);
            if (baseline) {
                console.log(`  📌 Comparing against pinned baseline: ${pinnedBaselineId}`);
            }
            else {
                console.warn(`  ⚠️  Pinned baseline ${pinnedBaselineId} not found; recording it on lineage but skipping comparison`);
                return null;
            }
        }
        // 2. Prefer explicit lineage source (deterministic re-run comparison)
        const rerunSourceId = provenance.lineage?.rerunOf;
        if (!baseline && rerunSourceId) {
            baseline = await this.read(rerunSourceId);
            if (baseline) {
                console.log(`  🔗 Comparing against lineage source: ${rerunSourceId}`);
            }
            else {
                console.warn(`  ⚠️  Lineage source ${rerunSourceId} not found, falling back to fuzzy match`);
            }
        }
        // 3. Fall back to fuzzy matching
        if (!baseline) {
            baseline = await this.findComparableBaseline({
                before: completedAt,
                mode: provenance.mode,
                source: { name: provenance.source.name },
            });
        }
        if (!baseline) {
            return null;
        }
        try {
            // When auto-scope is active, filter the baseline to only include
            // areas that were actually evaluated. This produces a fair
            // comparison where the overall delta reflects only tested areas.
            let baselineSummary = baseline.summary;
            if (scopedAreas && scopedAreas.size > 0) {
                const filteredScores = baselineSummary.scores.filter((s) => scopedAreas.has(s.feature));
                if (filteredScores.length > 0 &&
                    filteredScores.length < baselineSummary.scores.length) {
                    const len = filteredScores.length;
                    const avgScore = filteredScores.reduce((s, sc) => s + sc.totalScore, 0) / len;
                    const avgDocLift = filteredScores.reduce((s, sc) => s + sc.docLift, 0) / len;
                    baselineSummary = {
                        ...baselineSummary,
                        overall: {
                            ...baselineSummary.overall,
                            avgScore,
                            avgDocLift,
                        },
                        scores: filteredScores,
                    };
                    console.log(`  🎯 Scoped baseline to ${filteredScores.length} of ${baseline.summary.scores.length} areas for comparison`);
                }
            }
            const comparison = compare(baselineSummary, currentSummary);
            return { baselineReportId: baseline.id, comparison };
        }
        catch (error) {
            console.warn(`  ⚠️  Auto-comparison failed: ${error instanceof Error ? error.message : String(error)}`);
            return null;
        }
    }
    /**
     * Find a report by its evaluation fingerprint (cross-environment cache lookup).
     *
     * Returns the most recent non-debug report whose provenance contains a
     * matching `evalFingerprint`. Used by the pipeline to skip the expensive
     * eval step when identical inputs have already been evaluated.
     *
     * Advisory lookup: a `ReportSchemaValidationError` from a corrupt prior
     * doc is logged + counted, then null is returned so the current eval
     * proceeds. Use `read(id)` when callers ask for a specific document by id.
     *
     * @returns The cached report, or null if no match or on error
     * @see docs/design-docs/content-lake-eval-caching.md
     */
    async findByFingerprint(fingerprint) {
        try {
            const groq = `*[_type == $type
        && provenance.evalFingerprint == $fingerprint
      ] | order(completedAt desc) [0]`;
            const doc = await this.client.fetch(groq, { fingerprint, type: REPORT_TYPE });
            return doc ? toReport(doc) : null;
        }
        catch (error) {
            // Advisory lookup — a single corrupt prior doc must not break the
            // current eval. Log loudly + emit a counter so ops can alert, and
            // return null so the caller treats it as "no comparable cache hit".
            // Direct read(id) keeps the rethrow behavior because the caller asked
            // for that specific document and silent-null would mask the bug.
            if (error instanceof ReportSchemaValidationError) {
                logAdvisoryQuerySchemaFailure({
                    query: "findByFingerprint",
                    context: { fingerprint },
                    error,
                });
                return null;
            }
            console.warn(`  ⚠️  Failed to query cached report by fingerprint: ${error instanceof Error ? error.message : String(error)}`);
            return null;
        }
    }
    /**
     * Find the most recent comparable report for auto-comparison.
     *
     * Uses GROQ to match on key provenance dimensions (mode, source name)
     * and returns the most recent report before the given timestamp.
     *
     * "Comparable" means: same evaluation mode + same source name.
     * More granular matching (areas, models) can be added as needed.
     *
     * Advisory lookup: see `findByFingerprint` — a malformed prior baseline
     * is logged + counted and null is returned so the current run still
     * publishes its report.
     *
     * @see docs/design-docs/report-store/architecture.md — Auto-comparison
     */
    async findComparableBaseline(query) {
        try {
            const groq = `*[_type == $type
        && provenance.mode == $mode
        ${query.source?.name ? "&& provenance.source.name == $sourceName" : ""}
        ${query.before ? "&& completedAt < $before" : ""}
      ] | order(completedAt desc) [0]`;
            const params = {
                mode: query.mode,
                type: REPORT_TYPE,
            };
            if (query.source?.name) {
                params.sourceName = query.source.name;
            }
            if (query.before) {
                params.before = query.before;
            }
            const doc = await this.client.fetch(groq, params);
            return doc ? toReport(doc) : null;
        }
        catch (error) {
            // Advisory lookup — see findByFingerprint for rationale. A malformed
            // prior baseline returns null + counter so the current run still
            // publishes; direct read(id) preserves the rethrow.
            if (error instanceof ReportSchemaValidationError) {
                logAdvisoryQuerySchemaFailure({
                    query: "findComparableBaseline",
                    context: {
                        mode: query.mode,
                        sourceName: query.source?.name,
                        before: query.before,
                    },
                    error,
                });
                return null;
            }
            console.warn(`  ⚠️  Failed to query comparable baseline: ${error instanceof Error ? error.message : String(error)}`);
            return null;
        }
    }
    /**
     * Fetch the most recent report from the Content Lake.
     *
     * Mirrors the API gateway's `ReportStoreApi.latest()` signature
     * (`packages/api/src/lib/sanity.ts`). Used by `ailf interpret latest`
     * when no explicit report ID is supplied.
     *
     * @param query Optional narrowing by `mode` and/or `source.name`.
     * @returns The most recent matching report, or null if none exist
     *   or on API failure. Schema-validation errors are advisory (logged
     *   and null-returned) per the same rationale as `findByFingerprint`.
     */
    async latest(query) {
        try {
            let groq = `*[_type == $type`;
            const params = { type: REPORT_TYPE };
            if (query?.mode) {
                groq += ` && provenance.mode == $mode`;
                params.mode = query.mode;
            }
            if (query?.source) {
                groq += ` && provenance.source.name == $source`;
                params.source = query.source;
            }
            groq += `] | order(completedAt desc) [0]`;
            const doc = await this.client.fetch(groq, params);
            return doc ? toReport(doc) : null;
        }
        catch (error) {
            // Advisory lookup — a malformed prior doc must not break a read-only
            // CLI invocation. Log and return null so the caller surfaces a
            // user-friendly "no report found" error instead of a Zod stack trace.
            if (error instanceof ReportSchemaValidationError) {
                logAdvisoryQuerySchemaFailure({
                    query: "latest",
                    context: { mode: query?.mode, sourceName: query?.source },
                    error,
                });
                return null;
            }
            console.warn(`  ⚠️  Failed to fetch latest report from Sanity: ${error instanceof Error ? error.message : String(error)}`);
            return null;
        }
    }
    /**
     * Read a report by its ID.
     *
     * @throws {ReportSchemaValidationError} if the stored document fails the
     *   W0191 runtime schema gate. Sanity API failures still return null.
     */
    async read(id) {
        try {
            const doc = await this.client.fetch(`*[_type == $type && reportId == $id][0]`, { id, type: REPORT_TYPE });
            return doc ? toReport(doc) : null;
        }
        catch (error) {
            // W0191: schema-validation errors are bugs, not outages — surface them.
            if (error instanceof ReportSchemaValidationError)
                throw error;
            console.warn(`  ⚠️  Failed to read report from Sanity: ${error instanceof Error ? error.message : String(error)}`);
            return null;
        }
    }
    /**
     * Load every published report whose `completedAt` falls within the lookback
     * window, oldest-first (so a digest's first/last deltas read chronologically).
     *
     * Used by the notification sweep (M6): the sweep scopes each `daily`/`weekly`
     * channel's roll-up against the same reports the pure matcher sees, so it
     * needs full `Report` objects (provenance + comparison + summary), not the
     * slim digest projection.
     *
     * `completedAt >= $since` is a scalar range comparison — it stays on the
     * indexed aggregate path (unlike array-count predicates, which force a full
     * fetch). A doc that fails the W0191 schema gate is skipped with a warning
     * rather than aborting the sweep: one corrupt historical report must not
     * silence every channel's roll-up. Sanity transport failures return `[]`
     * (P5 local-first).
     */
    async listReportsSince(sinceIso) {
        try {
            const docs = await this.client.fetch(`*[_type == $type && completedAt >= $since] | order(completedAt asc)`, { since: sinceIso, type: REPORT_TYPE });
            const reports = [];
            for (const doc of docs ?? []) {
                try {
                    reports.push(toReport(doc));
                }
                catch (error) {
                    if (error instanceof ReportSchemaValidationError) {
                        console.warn(`  ⚠️  notify-sweep: skipping unparseable report — ${error.message.split("\n")[0]}`);
                        continue;
                    }
                    throw error;
                }
            }
            return reports;
        }
        catch (error) {
            console.warn(`  ⚠️  Failed to list windowed reports from Sanity: ${error instanceof Error ? error.message : String(error)}`);
            return [];
        }
    }
    /**
     * Load a previously-published report's score summary as a baseline
     * for comparison. Returns a discriminated result so the caller can
     * distinguish a genuine 404 (skip compare with a clear reason) from
     * a transport failure (fail the step — the user pinned a baseline
     * and deserves to know it didn't actually compare).
     *
     * The report's `summary` field is a `ReportSummary` — a superset of
     * `ComparableSummary` — so the projection below carries everything
     * the `compare()` primitive needs (`overall`, `perModel`, `scores`)
     * without re-hydrating the slim prose/array fields.
     */
    async loadBaselineFromReport(reportId) {
        try {
            const doc = await this.client.fetch(`*[_type == $type && reportId == $id][0]{ summary }`, { id: reportId, type: REPORT_TYPE });
            const summary = doc?.summary;
            if (!summary)
                return { kind: "not_found" };
            return {
                kind: "ok",
                baseline: {
                    overall: summary.overall,
                    perModel: summary.perModel,
                    scores: summary.scores,
                },
            };
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            return { kind: "error", message };
        }
    }
    /**
     * Write a report to the Sanity Content Lake.
     *
     * Creates an immutable `ailf.report` document. The document _id is
     * prefixed with `report-` for easy GROQ filtering.
     *
     * @returns The report ID on success, null on Sanity API failure (logged,
     *   not thrown — P5 local-first).
     * @throws {ReportSchemaValidationError} if the report fails the W0191
     *   runtime schema gate. Schema drift is a bug, not an outage.
     */
    async write(report) {
        // W0191 runtime gate — parse the wire payload before sending it to the
        // Content Lake so producer-side drift surfaces here rather than as
        // silent undefined-propagation in Studio. Mirrors the W0073 gate in
        // ContentLakeTaskSource. ZodErrors throw; network/auth failures
        // continue to use the P5 log-and-continue path below.
        //
        // The schema validates the wire shape (top-level `id`, not Sanity's
        // `_id`/`reportId`), so we hand it the SanityReportDoc with `id` added.
        // ReportSchema is `.passthrough()` at the top level — the Sanity-only
        // fields (`_id`, `_type`, `reportId`) ride through harmlessly.
        const sanityDoc = toSanityReportDoc(report);
        const parsed = ReportSchema.safeParse({ ...sanityDoc, id: report.id });
        if (!parsed.success) {
            throw new ReportSchemaValidationError(`ReportStore.write: ailf.report "${report.id}" failed schema validation:\n` +
                formatReportZodIssues(parsed.error));
        }
        try {
            await this.client.create(sanityDoc);
            return report.id;
        }
        catch (error) {
            console.warn(`  ⚠️  Failed to write report to Sanity: ${error instanceof Error ? error.message : String(error)}`);
            console.warn("  Report was not persisted. Pipeline results are still available locally.");
            return null;
        }
    }
    /**
     * Patch synthesis telemetry onto a published report (Phase 6 / DIAG-06).
     * Non-fatal on Sanity failure — the on-disk reportSnapshot artifact
     * remains the source of truth. Mirrors `write()` (L379–411) for
     * error handling.
     *
     * Document _id is `report-${reportId}` (see `toSanityReportDoc` L559).
     */
    async patchSynthesis(reportId, telemetry) {
        try {
            await this.client
                .patch(`report-${reportId}`)
                .set({ "summary.synthesis": { diagnosis: telemetry } })
                .commit();
        }
        catch (error) {
            console.warn(`  ⚠️  Failed to patch synthesis telemetry on report ${reportId}: ${error instanceof Error ? error.message : String(error)}`);
        }
    }
    /**
     * Patch a single artifact-manifest entry onto a published report.
     *
     * Used by deferred commands like `ailf interpret` whose post-hoc writer
     * produces a new ArtifactRef *after* the report doc was published. The
     * pipeline path lifts the full manifest into the doc at publish time
     * (publish-report-step.ts:187); this method is the post-hoc equivalent
     * for a single slot.
     *
     * Non-fatal on Sanity failure — mirrors `patchSynthesis` (L423).
     *
     * Document _id is `report-${reportId}` (see `toSanityReportDoc` L559).
     */
    async patchArtifactManifest(reportId, slot, ref) {
        try {
            await this.client
                .patch(`report-${reportId}`)
                .setIfMissing({ "summary.artifactManifest": {} })
                .set({ [`summary.artifactManifest.${slot}`]: ref })
                .commit();
        }
        catch (error) {
            console.warn(`  ⚠️  Failed to patch artifactManifest.${slot} on report ${reportId}: ${error instanceof Error ? error.message : String(error)}`);
        }
    }
    /**
     * Query error arrays from the last N reports for chronic failure detection.
     *
     * Returns a simplified shape with just the report ID and error details,
     * suitable for aggregation by the chronic-failures module.
     *
     * @param limit - Number of recent reports to query (default: 10)
     * @returns Array of { reportId, errors } or empty array on failure
     */
    async queryRecentErrors(limit = 10) {
        try {
            const groq = `*[_type == $type && defined(summary.testSummary.errors)]
        | order(completedAt desc) [0...$limit] {
          "reportId": reportId,
          "errors": summary.testSummary.errors[] { task, model, error }
        }`;
            const results = await this.client.fetch(groq, { limit, type: REPORT_TYPE });
            return results ?? [];
        }
        catch (error) {
            console.warn(`  ⚠️  Failed to query recent errors: ${error instanceof Error ? error.message : String(error)}`);
            return [];
        }
    }
}
/**
 * Generate a UUID v7 (time-sortable) for report identification.
 *
 * UUID v7 encodes the current timestamp in the high bits, making reports
 * naturally sort by creation time in both GROQ and string comparisons.
 *
 * Uses crypto.randomUUID() as a base and overwrites the timestamp portion.
 */
export function generateReportId() {
    const now = Date.now();
    const uuid = crypto.randomUUID();
    // UUID v7: encode 48-bit timestamp in the first 12 hex chars
    const hex = now.toString(16).padStart(12, "0");
    const v7 = hex.slice(0, 8) +
        "-" +
        hex.slice(8, 12) +
        "-7" +
        uuid.slice(15, 18) +
        "-" +
        uuid.slice(19, 23) +
        "-" +
        uuid.slice(24);
    return v7;
}
// ---------------------------------------------------------------------------
// Sanity document → Report mapping
// ---------------------------------------------------------------------------
/**
 * Tagged error thrown by `toReport` and `ReportStore.write` when a Content
 * Lake document fails the W0191 runtime schema gate. Read paths
 * (`read`, `findByFingerprint`, `findComparableBaseline`) re-throw this
 * error rather than swallowing it via the P5 log-and-continue try/catch
 * — schema drift is a bug to be surfaced, not an outage to be tolerated.
 */
export class ReportSchemaValidationError extends Error {
    constructor(message) {
        super(message);
        this.name = "ReportSchemaValidationError";
    }
}
/**
 * Stable log marker for log-aggregator counters. Operators alert on the
 * count of `[report-store.advisory] schema_validation_error` lines per
 * window; the trailing JSON carries enough context (which advisory query,
 * what filter values, error message) to find the offending document
 * without spelunking through GROQ.
 *
 * Emitted via console.error rather than console.warn so it surfaces in
 * the same severity tier as a real failure even though we're swallowing
 * it for the current run.
 */
function logAdvisoryQuerySchemaFailure(input) {
    const payload = {
        query: input.query,
        context: input.context,
        error: input.error.message,
    };
    console.error(`[report-store.advisory] schema_validation_error ${JSON.stringify(payload)}`);
}
export function toSanityReportDoc(report) {
    const slim = report.comparison ? stripComparisonBulk(report.comparison) : null;
    // Denormalize the regressed-area count so the dashboard preset filter stays
    // on the indexed Aggregate path (see PersistedComparison). A comparison may
    // omit `regressed` entirely (older/partial shapes); treat a missing array as
    // zero regressions, matching the prior `count(comparison.regressed) > 0`
    // semantics where an absent array never matched.
    const comparison = slim
        ? { ...slim, regressedCount: slim.regressed?.length ?? 0 }
        : null;
    return {
        _id: `report-${report.id}`,
        _type: REPORT_TYPE,
        comparison,
        completedAt: report.completedAt,
        ...(report.degraded ? { degraded: report.degraded } : {}),
        durationMs: report.durationMs,
        provenance: report.provenance,
        reportId: report.id,
        summary: {
            ...report.summary,
            // Artifact references live inside summary in Sanity so they're
            // projected automatically by the reportDetailQuery (D0032)
            ...(report.artifactManifest
                ? { artifactManifest: report.artifactManifest }
                : {}),
        },
        tag: report.tag ?? null,
        title: report.title ?? null,
        ...(report.validity ? { validity: report.validity } : {}),
    };
}
/**
 * Convert a raw Sanity document to a typed Report.
 *
 * The Sanity document shape mirrors the Report type but includes Sanity
 * metadata (_id, _type, _rev, etc.) that we strip.
 */
export function toReport(doc) {
    // W0191 runtime gate — parse the raw Sanity document before constructing
    // the typed Report. The schema has `id` at the top level (matching the
    // wire shape used on write); the stored document carries the same value
    // under `reportId`, so build the parse candidate from `reportId` and
    // surface the document `_id` (or `reportId`) on parse failures so
    // operators can locate the malformed record.
    const reportIdRaw = doc.reportId;
    const candidate = {
        ...doc,
        id: reportIdRaw,
    };
    const parsed = ReportSchema.safeParse(candidate);
    if (!parsed.success) {
        const docKey = typeof doc._id === "string" && doc._id.length > 0
            ? doc._id
            : typeof reportIdRaw === "string" && reportIdRaw.length > 0
                ? reportIdRaw
                : "<unknown>";
        throw new ReportSchemaValidationError(`ReportStore.toReport: ailf.report "${docKey}" failed schema ` +
            `validation:\n${formatReportZodIssues(parsed.error)}`);
    }
    const summary = doc.summary;
    const artifactManifest = summary?.artifactManifest;
    return {
        artifactManifest,
        comparison: doc.comparison,
        completedAt: doc.completedAt,
        degraded: doc.degraded,
        durationMs: doc.durationMs,
        id: doc.reportId,
        provenance: doc.provenance,
        summary: doc.summary,
        tag: doc.tag,
        title: doc.title,
        validity: doc.validity,
    };
}
/**
 * Format the first 5 ZodError issues for human-readable error output.
 * Mirrors the W0073 ContentLakeTaskSource formatter so the two Content
 * Lake gates produce comparable error shapes.
 */
function formatReportZodIssues(error) {
    const issues = error.issues
        .slice(0, 5)
        .map((i) => `  [${i.path.join(".")}]: ${i.message}`)
        .join("\n");
    const more = error.issues.length > 5
        ? `\n  …and ${error.issues.length - 5} more issue(s)`
        : "";
    return `${issues}${more}`;
}
/**
 * Remove the `baseline` and `experiment` ScoreSummary objects from a
 * ComparisonReport, producing a slim copy suitable for persistence.
 *
 * These fields are redundant in the stored document:
 * - `experiment` is byte-for-byte identical to `report.summary`
 * - `baseline` is fetchable via `provenance.lineage.comparedAgainst`
 *
 * Everything else (deltas, areas, classifications) is preserved.
 */
export function stripComparisonBulk(comparison) {
    const { baseline: _, experiment: __, ...slim } = comparison;
    return slim;
}
