/**
 * SanityDocFetcher — full adapter for the DocFetcher port.
 *
 * Fetches documentation from the Sanity Content Lake with support for:
 * - Basic slug-based fetching (GROQ → Portable Text → Markdown)
 * - Perspective-aware fetching (content release diffing)
 * - Document overlays (replace/append canonical docs by document ID)
 * - Direct URL fetching (markdown endpoints + content negotiation)
 * - Custom HTTP headers
 * - Document manifest (traceability: _id, _rev, slug, title)
 * - Release impact metadata (added/modified/removed/unchanged)
 *
 * The pipeline step (FetchDocsStep) calls ctx.docFetcher.fetch() and
 * writes the returned metadata to disk for downstream consumption.
 */
import { mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { canonicalDocRefLabel, isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../../_vendor/ailf-core/index.js";
import { buildContextDocPath, buildSanityAnchor, } from "../../_vendor/ailf-shared/index.js";
import { fetchUrlContent, } from "../../pipeline/fetch-url-content.js";
import { createPerspectiveClient, createPublishedClient, getSanityClient, } from "../../sanity/client.js";
import { extractSymbolsForDoc, renderDocument, slugForDoc, } from "../../sanity/document-renderers.js";
import { buildPerspectivePinWarnings, } from "../../sanity/perspective-pin-guard.js";
import { computeUncoveredDocs } from "../../sanity/release-coverage.js";
import { mergeSymbolIndexes, renderSymbolIndex, symbolIndexTierBreakdown, } from "../../sanity/symbol-index.js";
import { extractRelevanceIdentifiers } from "../../sanity/typedoc-declarations.js";
import { ALL_ARTICLES_QUERY, ALL_FEATURE_AREAS, ARTICLE_BY_ID_QUERY, ARTICLE_BY_SLUG_QUERY, ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, ARTICLE_SLUG_BY_PATH_QUERY, ARTICLE_SLUG_BY_SECTION_PATH_QUERY, ARTICLES_IN_RELEASE_QUERY, ARTICLES_METADATA_BY_SLUGS_QUERY, DOCS_BY_IDS_QUERY, FEATURE_AREA_QUERIES, } from "../../sanity/queries.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Escape `{{` and `}}` so Promptfoo's Nunjucks template engine doesn't
 * try to interpret them as template variables (e.g. Vue `{{ post.title }}`
 * in Nuxt docs would break Nunjucks parsing).
 */
function escapeNunjucks(text) {
    return text.replace(/\{\{|\}\}/g, (match) => match === "{{" ? '{{ "{{" }}' : '{{ "}}" }}');
}
function estimateTokens(text) {
    return Math.ceil(text.length / 4);
}
/**
 * Read a `DocumentForRender` field that the GROQ projection set as a
 * scalar string, gracefully returning `undefined` when the projection
 * shape is empty/unexpected (defensive for partial overlay docs).
 */
function readString(doc, key) {
    const value = doc[key];
    return typeof value === "string" ? value : undefined;
}
/**
 * Bridge DocSourceConfig (domain type) to getSanityClient overrides.
 *
 * DocSourceConfig is the port's domain type; the Sanity client expects
 * its own config shape. This bridges the gap.
 */
function toSanityOverrides(source) {
    if (!source)
        return undefined;
    const overrides = {};
    if (source.dataset)
        overrides.dataset = source.dataset;
    if (source.projectId)
        overrides.projectId = source.projectId;
    return Object.keys(overrides).length > 0 ? overrides : undefined;
}
/**
 * Build the source-tagged locator (D0062) for a manifest entry.
 *
 * The single point where the resolved source coordinates (projectId,
 * dataset, perspective) combine with a document's Sanity-native `_id`/`_rev`
 * to map them onto the locator's `documentId`/`revision` — keeping the
 * reconciliation a single source of truth. The `{kind, projectId, dataset}`
 * anchor (with its `?? DEFAULT_DOCS_*` fallbacks for a direct caller that omits
 * them) comes from the shared {@link buildSanityAnchor}, the one
 * anchor-construction source of truth shared with the release locator builder
 * (D0063). `perspective` and `revision` are omitted when absent (perspective
 * absent ⇒ "published").
 */
function buildSanityLocator(source, documentId, revision) {
    return {
        ...buildSanityAnchor(source),
        documentId,
        ...(source?.perspective ? { perspective: source.perspective } : {}),
        ...(revision ? { revision } : {}),
    };
}
/**
 * Build the `FetchMetadata.perspectiveExpansion` envelope from the
 * collected perspective refs and their fetch-time slug expansion.
 *
 * Perspective doc refs expand one-to-many only during fetch, so this is
 * the single point where the expansion (release → slugs) and the
 * attribution (task → releases) are both known. Returning them together
 * lets the orchestration step attribute perspective-expanded slugs back
 * to their owning tasks during release auto-scope computation.
 *
 * Exported for unit testing.
 */
export function buildPerspectiveExpansion(perspectiveRefs, perspectiveToSlugs) {
    const slugsByPerspective = {};
    for (const [perspective, slugs] of perspectiveToSlugs) {
        slugsByPerspective[perspective] = [...slugs];
    }
    const perspectivesByTask = {};
    for (const { perspective, taskId } of perspectiveRefs) {
        const perspectives = (perspectivesByTask[taskId] ??= []);
        if (!perspectives.includes(perspective)) {
            perspectives.push(perspective);
        }
    }
    return { perspectivesByTask, slugsByPerspective };
}
export class SanityDocFetcher {
    rootDir;
    features;
    /**
     * Per-URL cache of attachment-body fetches. A single `typesReference` doc is
     * now fetched once for the grader symbol index and again for each task that
     * renders it (relevance-scoped per D0069 §5), so without this a 658 KB
     * typedoc would be downloaded N+1 times per run. Sanity file-asset URLs are
     * content-addressed (the asset id is in the path), so a cached body never
     * goes stale. The promise is cached so concurrent callers dedupe too. A
     * failed fetch resolves to `null` (never rejects) and is also memoized, so a
     * transient failure fails fast for the whole run rather than re-downloading.
     */
    attachmentBodyCache = new Map();
    constructor(rootDir, options) {
        this.rootDir = rootDir;
        this.features = options?.features ?? null;
    }
    async fetch(tasks, source) {
        // Narrow to literacy tasks — only they have context.docs for doc resolution.
        const literacyTasks = tasks.filter((t) => t.mode === "literacy");
        return this.fetchInternal(literacyTasks, source);
    }
    async fetchInternal(tasks, source) {
        // 1. Collect all unique slugs and IDs across every task.
        // ID refs are resolved to slugs via a batch query so the rest of the
        // slug-based pipeline works unchanged.
        const allSlugs = new Set();
        const idRefs = [];
        const pathRefs = [];
        const perspectiveRefs = [];
        for (const task of tasks) {
            for (const ref of task.context?.docs ?? []) {
                if (isSlugRef(ref)) {
                    allSlugs.add(ref.slug);
                }
                else if (isIdRef(ref)) {
                    idRefs.push({ id: ref.id, taskId: task.id });
                }
                else if (isPathRef(ref)) {
                    pathRefs.push({ path: ref.path, taskId: task.id });
                }
                else if (isPerspectiveRef(ref)) {
                    perspectiveRefs.push({
                        ...(task.area ? { area: task.area } : {}),
                        perspective: ref.perspective,
                        taskId: task.id,
                    });
                }
                else {
                    console.warn(`    [warn] Skipping unsupported canonical doc ref: ${canonicalDocRefLabel(ref)}`);
                }
            }
        }
        // Resolve ID refs. Articles get added to allSlugs (legacy slug-flow takes
        // over for fetch + render). Non-articles render directly via the renderer
        // registry and bypass the slug-keyed content map.
        const idResolution = await this.resolveIdRefs(idRefs, source);
        for (const slug of idResolution.idToSlug.values()) {
            allSlugs.add(slug);
        }
        // Resolve path refs → slugs
        const pathToSlug = await this.resolvePathRefsToSlugs(pathRefs, source);
        for (const slug of pathToSlug.values()) {
            allSlugs.add(slug);
        }
        // Resolve perspective refs → slugs (one-to-many: each release expands
        // to all articles versioned within it)
        const perspectiveToSlugs = await this.resolvePerspectiveRefsToSlugs(perspectiveRefs, source);
        for (const slugs of perspectiveToSlugs.values()) {
            for (const slug of slugs) {
                allSlugs.add(slug);
            }
        }
        const metadata = {};
        // Task-level perspective refs expand one-to-many only at fetch time,
        // so return the expansion + per-task attribution to the step — release
        // auto-scope uses it to attribute expanded slugs back to their tasks.
        if (perspectiveRefs.length > 0) {
            metadata.perspectiveExpansion = buildPerspectiveExpansion(perspectiveRefs, perspectiveToSlugs);
        }
        // 2. Fetch document manifest (traceability metadata). Articles flow
        // through fetchManifest; non-article id-refs contribute their own
        // entries from the resolution we already did above.
        const manifest = await this.fetchManifest(allSlugs, source);
        for (const extra of idResolution.extraManifest) {
            manifest.push(extra);
        }
        manifest.sort((a, b) => a.slug.localeCompare(b.slug));
        if (manifest.length > 0) {
            metadata.manifest = manifest;
        }
        // 3. Perspective diffing — identify which docs changed in the release
        let releaseImpact;
        if (source?.perspective) {
            releaseImpact = await this.identifyAffectedDocs([...allSlugs], source);
            metadata.releaseImpact = releaseImpact;
            const affected = releaseImpact.added.length +
                releaseImpact.modified.length +
                releaseImpact.removed.length;
            console.log(`  Perspective diff: ${affected} of ${allSlugs.size} canonical docs affected`);
            if (releaseImpact.added.length > 0)
                console.log(`    Added:    ${releaseImpact.added.join(", ")}`);
            if (releaseImpact.modified.length > 0)
                console.log(`    Modified: ${releaseImpact.modified.join(", ")}`);
            if (releaseImpact.removed.length > 0)
                console.log(`    Removed:  ${releaseImpact.removed.join(", ")}`);
            // Visibility net: surface release edits that fall outside the evaluated
            // canonical set, so an applied fix to a doc no in-scope task references
            // is reported rather than silently unmeasured (the diff line above only
            // counts canonical docs).
            const uncovered = releaseImpact.uncovered ?? [];
            if (uncovered.length > 0) {
                console.warn(`  ⚠️  ${uncovered.length} doc(s) modified by the release are NOT covered by any in-scope task and will go unmeasured: ${uncovered.join(", ")}`);
            }
        }
        // 4. Document overlay — resolve document IDs against canonical set
        let documentOverlay;
        if (source?.documentIds && source.documentIds.length > 0) {
            console.log(`  Resolving ${source.documentIds.length} document ID(s) against canonical set...`);
            documentOverlay = await this.resolveDocumentOverlay(source.documentIds, allSlugs, source);
            const summary = {
                appendedCount: documentOverlay.appendedDocs.length,
                documentIds: source.documentIds,
                replacedSlugs: [...documentOverlay.replacements.keys()],
            };
            metadata.documentOverlay = summary;
            console.log(`  Document overlay: ${documentOverlay.replacements.size} replacement(s), ${documentOverlay.appendedDocs.length} appended`);
        }
        // 5. URL content fetch — fetch direct URLs
        const urlContent = [];
        if (source?.urls && source.urls.length > 0) {
            const urlFetchMeta = [];
            console.log(`  Fetching ${source.urls.length} direct URL(s)...`);
            for (const url of source.urls) {
                const result = await fetchUrlContent(url, source.headers);
                urlFetchMeta.push({
                    contentLength: result.content?.length,
                    error: result.error,
                    method: result.method,
                    status: result.status,
                    url: result.url,
                });
                if (result.content) {
                    urlContent.push(result.content);
                    const tokens = estimateTokens(result.content);
                    console.log(`    ✅ ${url} (via ${result.method}, ~${tokens} tokens)`);
                }
                else {
                    console.warn(`    ⚠️  ${url}: ${result.error}`);
                }
            }
            const fetched = urlFetchMeta.filter((m) => m.method !== "failed");
            const failures = urlFetchMeta.filter((m) => m.method === "failed");
            metadata.urlFetch = {
                failures: failures.map((f) => ({ error: f.error, url: f.url })),
                fetchedUrls: fetched,
                totalFailed: failures.length,
                totalFetched: fetched.length,
            };
        }
        // 6. Fetch canonical docs and assemble per-task contexts
        //
        // Build a per-slug perspective map so each slug is fetched with the
        // right perspective client. Sources:
        //   a) release impact diff (added/modified slugs → source perspective)
        //   b) perspective ref expansion (expanded slugs → ref's perspective)
        // When both set a perspective for the same slug, the ref-level
        // perspective wins (it's more specific).
        const slugPerspective = new Map();
        // Source-level perspective affects release-impacted slugs
        if (source?.perspective) {
            for (const slug of [
                ...(releaseImpact?.added ?? []),
                ...(releaseImpact?.modified ?? []),
            ]) {
                slugPerspective.set(slug, source.perspective);
            }
        }
        // Perspective ref expansion — each slug gets its ref's perspective
        for (const [perspectiveId, slugs] of perspectiveToSlugs) {
            for (const slug of slugs) {
                slugPerspective.set(slug, perspectiveId);
            }
        }
        const affectedSlugs = new Set(slugPerspective.keys());
        const removedSlugs = new Set(releaseImpact?.removed ?? []);
        const docsBySlug = await this.fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, documentOverlay, source, slugPerspective);
        // 7. Assemble per-task context + symbol index. Both surfaces dispatch
        // through the renderer registry uniformly — the slug-flow, id-ref flow,
        // and overlay flow all produce `DocumentForRender` objects here, and the
        // registry decides how to render Markdown and extract symbols per
        // `_type`. Per-task assembly aggregates symbols across all references
        // attached to the task, writes:
        //   - `contexts/canonical/<task.id>.md` — existing rendered-doc
        //     artifact, consumed by Promptfoo `vars.docs`.
        //   - `contexts/canonical-symbols/<task.id>.md` — W0197 symbol-index
        //     artifact, written only when the task produced ≥1 symbol.
        //   - `contexts/canonical-symbols/manifest.json` — per-task manifest
        //     listing which tasks produced indexes (with counts + tier
        //     breakdowns). The grader-context consumer reads this manifest
        //     to decide between symbol-index and full-doc injection.
        const canonicalDir = join(this.rootDir, "contexts", "canonical");
        const symbolsDir = join(this.rootDir, "contexts", "canonical-symbols");
        mkdirSync(canonicalDir, { recursive: true });
        mkdirSync(symbolsDir, { recursive: true });
        const renderCtx = { fetchUrl: this.fetchAttachmentBody };
        const renderAndExtract = async (doc) => {
            const rendered = await renderDocument(doc, renderCtx);
            const symbols = await extractSymbolsForDoc(doc, renderCtx);
            return { markdown: rendered.content, symbols, slug: rendered.slug };
        };
        const consumeDoc = async (doc, fallbackSlug) => {
            const { markdown, symbols, slug } = await renderAndExtract(doc);
            if (!markdown)
                return null;
            return { markdown, slug: fallbackSlug ?? slug, symbols };
        };
        const symbolIndexManifest = [];
        const contexts = await Promise.all(tasks.map(async (task) => {
            // Phase 1 — resolve each ref to a slot. Most refs render+extract now
            // via the registry (slug-flow, perspective expansion, or the
            // pre-rendered non-article id-refs in contentById). typesReference
            // id-refs are deferred: their candidate markdown is relevance-scoped
            // to this task, rendered in phase 3 once the hints are known (D0069 §5).
            const slots = await Promise.all((task.context?.docs ?? []).map(async (ref) => {
                if (isPerspectiveRef(ref)) {
                    const expanded = perspectiveToSlugs.get(ref.perspective) ?? [];
                    const docs = expanded
                        .filter((slug) => !removedSlugs.has(slug))
                        .map((slug) => ({ slug, doc: docsBySlug.get(slug) }))
                        .filter((e) => e.doc !== undefined);
                    const consumed = await Promise.all(docs.map((e) => consumeDoc(e.doc, e.slug)));
                    return {
                        kind: "consumed",
                        refs: consumed.filter((c) => c !== null),
                    };
                }
                if (isIdRef(ref) && idResolution.typesReferenceById.has(ref.id)) {
                    return {
                        kind: "deferred",
                        deferred: idResolution.typesReferenceById.get(ref.id),
                    };
                }
                if (isIdRef(ref) && idResolution.contentById.has(ref.id)) {
                    const entry = idResolution.contentById.get(ref.id);
                    return {
                        kind: "consumed",
                        refs: [
                            {
                                markdown: entry.content,
                                slug: entry.slug,
                                symbols: entry.symbolIndex,
                            },
                        ],
                    };
                }
                let slug;
                if (isSlugRef(ref)) {
                    slug = ref.slug;
                }
                else if (isIdRef(ref)) {
                    slug = idResolution.idToSlug.get(ref.id);
                }
                else if (isPathRef(ref)) {
                    slug = pathToSlug.get(ref.path);
                }
                if (!slug || removedSlugs.has(slug))
                    return { kind: "consumed", refs: [] };
                const doc = docsBySlug.get(slug);
                if (!doc)
                    return { kind: "consumed", refs: [] };
                const consumed = await consumeDoc(doc, slug);
                return { kind: "consumed", refs: consumed ? [consumed] : [] };
            }));
            // Overlay docs (appended) are always articles — `resolveDocumentOverlay`
            // fetches them via `fetchArticleById` — so they never carry a typedoc
            // attachment and render now with no relevance hints.
            const overlayConsumed = [];
            if (documentOverlay && documentOverlay.appendedDocs.length > 0) {
                const consumed = await Promise.all(documentOverlay.appendedDocs.map((doc) => consumeDoc(doc)));
                for (const c of consumed) {
                    if (c)
                        overlayConsumed.push(c);
                }
            }
            // Phase 2 — relevance hints (D0069 §2): the union of the symbols this
            // task's co-pinned articles/other refs legitimize and the identifier-
            // shaped tokens in its prompt + title. A deferred typesReference's own
            // symbols are intentionally excluded — including them would make every
            // export self-relevant and defeat the scoping.
            const relevanceSymbols = new Set();
            for (const slot of slots) {
                if (slot.kind !== "consumed")
                    continue;
                for (const ref of slot.refs)
                    for (const entry of ref.symbols.symbols)
                        relevanceSymbols.add(entry.symbol);
            }
            for (const ref of overlayConsumed)
                for (const entry of ref.symbols.symbols)
                    relevanceSymbols.add(entry.symbol);
            for (const token of extractRelevanceIdentifiers(task.prompt?.text ?? ""))
                relevanceSymbols.add(token);
            for (const token of extractRelevanceIdentifiers(task.title))
                relevanceSymbols.add(token);
            const relevanceHints = [...relevanceSymbols];
            // Phase 3 — render deferred typesReference docs with the hints, then
            // stitch every slot back into original ref order.
            const refResolutions = await Promise.all(slots.map(async (slot) => {
                if (slot.kind === "consumed")
                    return slot.refs;
                const rendered = await renderDocument(slot.deferred.doc, {
                    fetchUrl: this.fetchAttachmentBody,
                    relevanceSymbols: relevanceHints,
                });
                if (!rendered.content)
                    return [];
                return [
                    {
                        markdown: rendered.content,
                        slug: slot.deferred.slug,
                        symbols: slot.deferred.symbolIndex,
                    },
                ];
            }));
            const parts = [];
            const slugs = [];
            const taskIndexes = [];
            const consumed = [
                ...refResolutions.flat(),
                ...overlayConsumed,
            ];
            for (const c of consumed) {
                parts.push(c.markdown);
                slugs.push(c.slug);
                if (c.symbols.symbols.length > 0)
                    taskIndexes.push(c.symbols);
            }
            // Append URL-fetched content. URLs are raw markdown blobs from
            // external sources (no Sanity doc shape), so they don't contribute
            // to the per-task symbol index.
            if (urlContent.length > 0) {
                parts.push(...urlContent);
            }
            const combined = escapeNunjucks(parts.join("\n\n---\n\n"));
            const contextPath = join(canonicalDir, `${task.id}.md`);
            writeFileSync(contextPath, combined, "utf-8");
            // Aggregate per-task symbol index. Always emit a manifest entry —
            // entry presence is the consumer's signal that extraction ran.
            // The per-task .md file is only written when the index is
            // non-empty; a missing file under an entry-present manifest is
            // permitted (consumer treats it the same as count zero).
            const merged = mergeSymbolIndexes(taskIndexes);
            const tierBreakdown = symbolIndexTierBreakdown(merged);
            const symbolsRelativePath = `contexts/canonical-symbols/${task.id}.md`;
            if (merged.symbols.length > 0) {
                const indexMarkdown = renderSymbolIndex(merged, task.title);
                writeFileSync(join(symbolsDir, `${task.id}.md`), indexMarkdown, "utf-8");
            }
            symbolIndexManifest.push({
                taskId: task.id,
                path: symbolsRelativePath,
                symbolCount: merged.symbols.length,
                tierBreakdown,
            });
            return {
                taskId: task.id,
                content: combined,
                slugs,
                tokenCount: estimateTokens(combined),
            };
        }));
        // Persist the symbol-index manifest so the literacy compiler can read
        // it to decide between symbol-index and full-doc grader-context
        // injection per task. Sorted for stable diffs across runs.
        symbolIndexManifest.sort((a, b) => a.taskId.localeCompare(b.taskId));
        writeFileSync(join(symbolsDir, "manifest.json"), JSON.stringify({ entries: symbolIndexManifest }, null, 2), "utf-8");
        metadata.symbolIndexes = symbolIndexManifest;
        const hasMetadata = Object.keys(metadata).length > 0;
        return {
            contexts,
            ...(hasMetadata ? { metadata } : {}),
        };
    }
    // -----------------------------------------------------------------------
    // Private: Fetch document manifest
    // -----------------------------------------------------------------------
    async fetchManifest(allSlugs, source) {
        const client = source?.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        const allMetadata = await client.fetch(ARTICLES_METADATA_BY_SLUGS_QUERY, { slugs: [...allSlugs] });
        return allMetadata
            .map((m) => {
            const path = buildContextDocPath({
                sectionSlug: m.sectionSlug,
                slug: m.slug,
            });
            return {
                locator: buildSanityLocator(source, m._id, m._rev),
                _id: m._id,
                _rev: m._rev,
                slug: m.slug,
                ...(m.sectionSlug ? { sectionSlug: m.sectionSlug } : {}),
                ...(path ? { path } : {}),
                title: m.title,
            };
        })
            .sort((a, b) => a.slug.localeCompare(b.slug));
    }
    // -----------------------------------------------------------------------
    // Private: Resolve ID refs (type-agnostic)
    // -----------------------------------------------------------------------
    /**
     * Batch-resolve document ID refs without filtering on `_type`.
     *
     * Articles are routed back into the slug-flow (so manifest, perspective
     * diffing, and overlay handling continue to work unchanged). Non-articles
     * are rendered eagerly via the renderer registry — `typesReference` gets
     * a high-fidelity formatter; anything else falls through to the default
     * walker so authors can pin marketing pages, glossary entries, etc.
     * without an AILF code change.
     *
     * Three log shapes:
     *   - `info` — doc rendered with the default formatter (suggests a
     *     dedicated renderer would improve fidelity)
     *   - `warn` — registered renderer produced empty content (W0195 AC#3)
     *   - `warn` — id had no matching document (existed/wrong tenant/typo)
     */
    async resolveIdRefs(idRefs, source) {
        const idToSlug = new Map();
        const contentById = new Map();
        // typesReference docs are NOT rendered here. Their candidate markdown is
        // relevance-scoped per task (D0069 §5), so rendering is deferred to the
        // per-task assembly loop. The task-independent work — the grader symbol
        // index (unchanged grader path) and the raw doc for later rendering — is
        // captured now so the attachment is parsed once, not per task.
        const typesReferenceById = new Map();
        const extraManifest = [];
        if (idRefs.length === 0)
            return { idToSlug, contentById, typesReferenceById, extraManifest };
        const uniqueIds = [...new Set(idRefs.map((r) => r.id))];
        const taskByRef = new Map();
        for (const { id, taskId } of idRefs) {
            const existing = taskByRef.get(id) ?? [];
            existing.push(taskId);
            taskByRef.set(id, existing);
        }
        const client = source?.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        const docs = await client.fetch(DOCS_BY_IDS_QUERY, {
            ids: uniqueIds,
        });
        const docsById = new Map(docs.map((d) => [d._id, d]));
        let articleCount = 0;
        let highFidelity = 0;
        let defaultFidelity = 0;
        for (const id of uniqueIds) {
            const taskIds = taskByRef.get(id) ?? [];
            const doc = docsById.get(id);
            if (!doc) {
                console.warn(`    [warn] doc "${id}" not found (referenced by task(s): ${taskIds.join(", ")})`);
                continue;
            }
            // Article id-refs flow back into the slug-keyed pipeline so manifest,
            // perspective diff, and document overlay continue to work. The slug
            // projection in DOCS_BY_IDS_QUERY mirrors ARTICLE_PROJECTION.
            if (doc._type === "article") {
                const slug = doc.slug;
                if (typeof slug === "string" && slug.length > 0) {
                    idToSlug.set(id, slug);
                    articleCount += 1;
                }
                else {
                    console.warn(`    [warn] article "${id}" has no slug (referenced by task(s): ${taskIds.join(", ")})`);
                }
                continue;
            }
            const renderCtx = { fetchUrl: this.fetchAttachmentBody };
            // typesReference: defer candidate markdown to the per-task loop so it can
            // be relevance-scoped (D0069 §5). Do the task-independent work now — the
            // grader symbol index (uncapped, unchanged) and traceability — and stash
            // the raw doc keyed by id. Always high-fidelity (a registered renderer),
            // and the metadata header means it is never empty, so the empty-content
            // and default-fidelity branches below don't apply.
            if (doc._type === "typesReference") {
                const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
                const slug = slugForDoc(doc);
                typesReferenceById.set(id, { doc, slug, symbolIndex });
                const _rev = doc._rev;
                const revision = typeof _rev === "string" && _rev ? _rev : undefined;
                const title = doc.title;
                extraManifest.push({
                    locator: buildSanityLocator(source, doc._id, revision),
                    _id: doc._id,
                    _rev: revision ?? "",
                    slug,
                    title: typeof title === "string" ? title : `(${doc._type})`,
                });
                highFidelity += 1;
                continue;
            }
            const rendered = await renderDocument(doc, renderCtx);
            if (!rendered.content) {
                console.warn(`    [warn] doc "${id}" resolved as "${doc._type}" but produced empty context (referenced by task(s): ${taskIds.join(", ")})`);
                continue;
            }
            const symbolIndex = await extractSymbolsForDoc(doc, renderCtx);
            contentById.set(id, {
                content: rendered.content,
                slug: rendered.slug,
                type: doc._type,
                symbolIndex,
            });
            const _rev = doc._rev;
            const revision = typeof _rev === "string" && _rev ? _rev : undefined;
            const title = doc.title;
            extraManifest.push({
                locator: buildSanityLocator(source, doc._id, revision),
                _id: doc._id,
                _rev: revision ?? "",
                slug: rendered.slug,
                title: typeof title === "string" ? title : `(${doc._type})`,
            });
            if (rendered.fidelity === "default") {
                console.log(`    [info] doc "${id}" rendered with default formatter — a dedicated renderer for "${doc._type}" would likely improve grader fidelity`);
                defaultFidelity += 1;
            }
            else {
                highFidelity += 1;
            }
        }
        if (articleCount > 0) {
            console.log(`  Resolved ${articleCount} article id ref(s) to slugs`);
        }
        if (highFidelity + defaultFidelity > 0) {
            console.log(`  Resolved ${highFidelity + defaultFidelity} non-article id ref(s) (${highFidelity} high-fidelity, ${defaultFidelity} default)`);
        }
        return { idToSlug, contentById, typesReferenceById, extraManifest };
    }
    /**
     * Fetch the raw body of a Sanity file asset URL, used by the
     * `typesReference` renderer to inline typedoc JSON. Returns `null`
     * on any HTTP/network failure rather than throwing — the renderer
     * surfaces a placeholder so the rest of the context still renders.
     *
     * Wrapped in a 30s `AbortSignal.timeout` so a slow CDN can't hang the
     * eval pipeline indefinitely. Timeouts surface as a single `null`
     * return like any other fetch failure. Results are memoized per URL (see
     * `attachmentBodyCache`).
     */
    fetchAttachmentBody = (url) => {
        const cached = this.attachmentBodyCache.get(url);
        if (cached)
            return cached;
        const pending = this.fetchAttachmentBodyUncached(url);
        this.attachmentBodyCache.set(url, pending);
        return pending;
    };
    fetchAttachmentBodyUncached = async (url) => {
        try {
            const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
            if (!response.ok) {
                console.warn(`    [warn] attachment fetch failed for ${url}: HTTP ${response.status}`);
                return null;
            }
            return await response.text();
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`    [warn] attachment fetch failed for ${url}: ${msg}`);
            return null;
        }
    };
    // -----------------------------------------------------------------------
    // Private: Resolve path refs to slugs
    // -----------------------------------------------------------------------
    /**
     * Resolve path-based canonical doc references to their article slugs.
     *
     * Handles two path formats:
     * - Simple path: "webhooks" → resolves as slug lookup
     * - Section-qualified path: "content-lake/webhooks" → matches section + slug
     *
     * @see docs/design-docs/canonical-doc-resolution.md
     */
    async resolvePathRefsToSlugs(pathRefs, source) {
        const result = new Map();
        if (pathRefs.length === 0)
            return result;
        const client = source?.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        for (const { path, taskId } of pathRefs) {
            const segments = path.split("/").filter(Boolean);
            const articleSlug = segments[segments.length - 1];
            const sectionSlug = segments.length > 1 ? segments.slice(0, -1).join("/") : undefined;
            let article;
            if (sectionSlug) {
                // Section-qualified path: disambiguate by section
                article = await client.fetch(ARTICLE_SLUG_BY_SECTION_PATH_QUERY, {
                    articleSlug,
                    sectionSlug,
                });
                if (!article) {
                    // Fallback: try without section qualifier (section slug might differ)
                    article = await client.fetch(ARTICLE_SLUG_BY_PATH_QUERY, { slug: articleSlug });
                    if (article) {
                        console.warn(`    [warn] Path "${path}" section "${sectionSlug}" didn't match (article found in section "${article.sectionSlug ?? "unknown"}")`);
                    }
                }
            }
            else {
                // Simple path: resolve by slug directly
                article = await client.fetch(ARTICLE_SLUG_BY_PATH_QUERY, { slug: articleSlug });
            }
            if (article) {
                result.set(path, article.slug);
            }
            else {
                console.warn(`    [warn] No article found for path "${path}" (referenced by task "${taskId}")`);
            }
        }
        if (result.size > 0) {
            console.log(`  Resolved ${result.size} path ref(s) to slugs`);
        }
        return result;
    }
    // -----------------------------------------------------------------------
    // Private: Resolve perspective refs to slugs (one-to-many)
    // -----------------------------------------------------------------------
    /**
     * Expand perspective canonical doc refs to the article slugs within
     * each content release.
     *
     * Each perspective ref resolves to ALL article documents versioned in
     * that release. This is the one-to-many expansion described in the
     * canonical-doc-resolution design doc.
     *
     * The perspective value from the ref is used as both the release ID
     * for the versioned document query AND the client perspective for
     * fetching content. If a source-level perspective is also set, the
     * ref-level perspective takes precedence for its own resolution.
     *
     * Expansion is intentionally unconditional; a stale or unrelated pin is
     * surfaced through the non-fatal {@link warnOnSuspectPerspectivePin}
     * guard rather than gating the fetch.
     *
     * @see docs/design-docs/canonical-doc-resolution.md
     */
    async resolvePerspectiveRefsToSlugs(perspectiveRefs, source) {
        const result = new Map();
        if (perspectiveRefs.length === 0)
            return result;
        // Deduplicate by perspective ID (multiple tasks may reference the same release)
        const uniquePerspectives = [
            ...new Set(perspectiveRefs.map((r) => r.perspective)),
        ];
        for (const perspectiveId of uniquePerspectives) {
            // Create a client with this specific perspective so versioned docs are visible
            const client = createPerspectiveClient(perspectiveId, source);
            const articles = await client.fetch(ARTICLES_IN_RELEASE_QUERY, { releaseId: perspectiveId });
            this.warnOnSuspectPerspectivePin(perspectiveId, perspectiveRefs, articles);
            if (articles.length === 0) {
                result.set(perspectiveId, []);
                continue;
            }
            const slugs = articles
                .map((a) => a.slug)
                .filter((s) => Boolean(s));
            result.set(perspectiveId, slugs);
            console.log(`  Expanded perspective "${perspectiveId}" → ${slugs.length} article(s): ${slugs.join(", ")}`);
        }
        return result;
    }
    /**
     * Non-fatal stale-pin guard for one perspective ref expansion.
     *
     * Warns when the pinned release expanded to no articles (release gone,
     * published, or empty) or when the expanded articles' sections are
     * unrelated to the pinning task's area per the feature-registry
     * section→area mapping (see {@link buildPerspectivePinWarnings}).
     * Warning only — expansion remains unconditional and the fetch never
     * fails because of a suspect pin.
     */
    warnOnSuspectPerspectivePin(perspectiveId, perspectiveRefs, articles) {
        const taskRefs = perspectiveRefs
            .filter((r) => r.perspective === perspectiveId)
            .map((r) => ({ taskId: r.taskId, ...(r.area ? { area: r.area } : {}) }));
        const warnings = buildPerspectivePinWarnings(perspectiveId, taskRefs, articles, this.features);
        for (const warning of warnings) {
            console.warn(`    ⚠️  ${warning}`);
        }
    }
    // -----------------------------------------------------------------------
    // Private: Perspective diffing
    // -----------------------------------------------------------------------
    async identifyAffectedDocs(canonicalSlugs, source) {
        if (!source.perspective) {
            return {
                added: [],
                modified: [],
                removed: [],
                unchanged: [...canonicalSlugs],
            };
        }
        const publishedClient = createPublishedClient(source);
        const perspectiveClient = createPerspectiveClient(source.perspective, source);
        const [publishedMeta, perspectiveMeta] = await Promise.all([
            publishedClient.fetch(ARTICLES_METADATA_BY_SLUGS_QUERY, { slugs: canonicalSlugs }),
            perspectiveClient.fetch(ARTICLES_METADATA_BY_SLUGS_QUERY, { slugs: canonicalSlugs }),
        ]);
        const publishedMap = new Map(publishedMeta.map((d) => [d.slug, d]));
        const perspectiveMap = new Map(perspectiveMeta.map((d) => [d.slug, d]));
        const added = [];
        const modified = [];
        const removed = [];
        const unchanged = [];
        for (const slug of canonicalSlugs) {
            const pub = publishedMap.get(slug);
            const persp = perspectiveMap.get(slug);
            if (!pub && persp) {
                added.push(slug);
            }
            else if (pub && !persp) {
                removed.push(slug);
            }
            else if (pub && persp && pub._rev !== persp._rev) {
                modified.push(slug);
            }
            else {
                unchanged.push(slug);
            }
        }
        // Visibility net: the diff above only sees canonical docs of in-scope
        // tasks, so a release edit to any other doc is invisible to it. Compute
        // the full release footprint (every article versioned in the release) and
        // report the slugs that fall outside the canonical set, so applied fixes
        // to docs no task references are surfaced rather than silently unmeasured.
        // Best-effort: a footprint-query failure must never fail the run.
        const uncovered = await this.identifyUncoveredReleaseDocs(source.perspective, perspectiveClient, canonicalSlugs);
        return {
            added,
            modified,
            removed,
            unchanged,
            ...(uncovered ? { uncovered } : {}),
        };
    }
    /**
     * Compute the slugs versioned in the release that are NOT canonical docs of
     * any in-scope task, via the full `sanity::partOfRelease` footprint.
     *
     * Returns `undefined` (not `[]`) when the footprint query fails, preserving
     * the three-valued `ReleaseImpact.uncovered` contract: absent = not computed,
     * `[]` = checked and fully covered, non-empty = unmeasured docs named.
     */
    async identifyUncoveredReleaseDocs(releaseId, perspectiveClient, canonicalSlugs) {
        try {
            const releaseArticles = await perspectiveClient.fetch(ARTICLES_IN_RELEASE_QUERY, { releaseId });
            const footprintSlugs = releaseArticles
                .map((a) => a.slug)
                .filter((s) => Boolean(s));
            return computeUncoveredDocs(footprintSlugs, canonicalSlugs);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`    [warn] Could not compute the full release footprint (uncovered-edits check skipped): ${msg}`);
            return undefined;
        }
    }
    // -----------------------------------------------------------------------
    // Private: Document overlay
    // -----------------------------------------------------------------------
    async resolveDocumentOverlay(documentIds, canonicalSlugs, source) {
        const overlay = {
            appendedDocs: [],
            replacements: new Map(),
        };
        if (documentIds.length === 0)
            return overlay;
        const results = await Promise.all(documentIds.map(async (id) => {
            const doc = await this.fetchArticleById(id, source);
            return { doc, id };
        }));
        for (const { doc, id } of results) {
            if (!doc) {
                console.warn(`    [warn] No article found for document ID "${id}"`);
                continue;
            }
            const slug = readString(doc, "slug");
            if (slug && canonicalSlugs.has(slug)) {
                overlay.replacements.set(slug, doc);
                console.log(`    📄 Document ${id} → replaces canonical doc "${slug}"`);
            }
            else {
                overlay.appendedDocs.push(doc);
                const slugInfo = slug ? ` (slug: "${slug}")` : "";
                console.log(`    📄 Document ${id} → appended as additional context${slugInfo}`);
            }
        }
        return overlay;
    }
    // -----------------------------------------------------------------------
    // Private: Fetch individual articles
    // -----------------------------------------------------------------------
    async fetchArticleById(id, source) {
        const client = source.perspective
            ? createPerspectiveClient(source.perspective, source)
            : getSanityClient(toSanityOverrides(source));
        return client.fetch(ARTICLE_BY_ID_QUERY, { id });
    }
    async fetchArticleBySlug(slug) {
        const client = getSanityClient();
        const doc = await client.fetch(ARTICLE_BY_SLUG_QUERY, { slug });
        if (!doc) {
            console.warn(`    [warn] No article found for slug "${slug}"`);
            return null;
        }
        return doc;
    }
    async fetchArticleBySlugWithPerspective(slug, source) {
        if (!source.perspective) {
            return this.fetchArticleBySlug(slug);
        }
        const client = createPerspectiveClient(source.perspective, source);
        const doc = await client.fetch(ARTICLE_BY_SLUG_WITH_PERSPECTIVE_QUERY, { slug });
        if (!doc) {
            console.warn(`    [warn] No article found for slug "${slug}" in perspective "${source.perspective}"`);
            return null;
        }
        return doc;
    }
    // -----------------------------------------------------------------------
    // Private: Fetch all canonical docs with perspective/overlay awareness
    // -----------------------------------------------------------------------
    async fetchCanonicalDocs(allSlugs, affectedSlugs, removedSlugs, overlay, source, slugPerspective) {
        const docsBySlug = new Map();
        for (const slug of allSlugs) {
            if (removedSlugs.has(slug))
                continue;
            // Check if this slug has a document overlay replacement
            if (overlay?.replacements.has(slug)) {
                console.log(`      Fetching: ${slug} (from document overlay)`);
                docsBySlug.set(slug, overlay.replacements.get(slug));
                continue;
            }
            let doc;
            if (affectedSlugs.has(slug)) {
                // Use the per-slug perspective when available; fall back to
                // source-level perspective. This ensures perspective-ref-expanded
                // slugs are fetched from their specific release even when no
                // source-level --sanity-perspective is set.
                const perspective = slugPerspective?.get(slug) ?? source?.perspective;
                if (perspective && source) {
                    const perspectiveSource = { ...source, perspective };
                    console.log(`      Fetching: ${slug} (from perspective: ${perspective})`);
                    doc = await this.fetchArticleBySlugWithPerspective(slug, perspectiveSource);
                }
                else {
                    // No perspective available — fetch from published
                    console.log(`      Fetching: ${slug}`);
                    doc = await this.fetchArticleBySlug(slug);
                }
            }
            else {
                console.log(`      Fetching: ${slug}`);
                doc = await this.fetchArticleBySlug(slug);
            }
            if (doc)
                docsBySlug.set(slug, doc);
        }
        return docsBySlug;
    }
    // -----------------------------------------------------------------------
    // Public non-port methods — opt-in CLI features
    // -----------------------------------------------------------------------
    /**
     * Generate feature-area context files (one per GROQ feature area query).
     *
     * This is NOT part of the DocFetcher port — it's an opt-in CLI feature
     * used by `ailf fetch-docs --include-feature-areas`.
     */
    async fetchFeatureAreaContexts(source) {
        const client = getSanityClient(toSanityOverrides(source), source);
        const contextsDir = join(this.rootDir, "contexts");
        mkdirSync(contextsDir, { recursive: true });
        console.log("Generating feature-area contexts...\n");
        for (const feature of ALL_FEATURE_AREAS) {
            const query = FEATURE_AREA_QUERIES[feature];
            const docs = await client.fetch(query);
            if (docs.length === 0) {
                console.warn(`  [warn] No articles found for "${feature}"`);
                continue;
            }
            const rendered = await Promise.all(docs.map((d) => renderDocument(d, { fetchUrl: this.fetchAttachmentBody })));
            const combined = rendered.map((r) => r.content).join("\n\n---\n\n");
            const outPath = join(contextsDir, `${feature}.md`);
            writeFileSync(outPath, escapeNunjucks(combined), "utf-8");
            console.log(`  ${feature}: ${docs.length} articles, ~${estimateTokens(combined)} tokens`);
        }
    }
    /**
     * Generate a full corpus context file (all articles in one markdown file).
     *
     * This is NOT part of the DocFetcher port — it's an opt-in CLI feature
     * used by `ailf fetch-docs --include-corpus`.
     */
    async fetchFullCorpus(source) {
        const client = getSanityClient(toSanityOverrides(source), source);
        const baseUrl = source?.baseUrl ?? "https://www.sanity.io/docs";
        console.log("\nGenerating full corpus...");
        const docs = await client.fetch(ALL_ARTICLES_QUERY);
        const rendered = await Promise.all(docs.map(async (d) => {
            const result = await renderDocument(d, {
                fetchUrl: this.fetchAttachmentBody,
            });
            // Corpus output prepends the article URL line so the dataset can
            // be consumed by RAG retrievers that key on canonical URL.
            const slug = readString(d, "slug") ?? result.slug;
            return `URL: ${baseUrl}/${slug}\n\n${result.content}`;
        }));
        const corpus = rendered.join("\n\n---\n\n");
        const outDir = join(this.rootDir, "contexts");
        mkdirSync(outDir, { recursive: true });
        writeFileSync(join(outDir, "full-corpus.md"), escapeNunjucks(corpus), "utf-8");
        console.log(`  full-corpus.md: ${docs.length} articles, ~${estimateTokens(corpus)} tokens`);
    }
}
