/**
 * Pipeline step: Fetch documentation from Sanity CMS.
 *
 * Uses ctx.docFetcher (the DocFetcher port) for all fetching. The adapter
 * handles GROQ queries, perspective diffing, document overlays, and URL
 * fetching. This step orchestrates the call and writes metadata files.
 *
 * When a source-level perspective is active and release impact data is
 * available, this step computes which tasks are affected by the release
 * and stores a `releaseAutoScope` entry in PipelineState. Downstream
 * steps (GenerateConfigsStep, RunEvalStep) use this to narrow scope.
 */
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { join } from "path";
import { assoc, isIdRef, isPathRef, isSlugRef, } from "../../_vendor/ailf-core/index.js";
import { emitFileContents } from "../../artifact-capture/emit-file.js";
import { getStepInputPaths } from "../../pipeline/cache.js";
import { buildCacheContext } from "../cache-context.js";
import { checkCanonicalContextsExist } from "../../pipeline/checks.js";
import { loadSource } from "../../sources.js";
import { configToSourceOverrides } from "../config-to-source-overrides.js";
export class FetchDocsStep {
    name = "fetch-docs";
    check() {
        return [];
    }
    async execute(ctx, state) {
        if (ctx.config.skipFetch) {
            return { status: "skipped", reason: "--no-fetch" };
        }
        const start = Date.now();
        // Load tasks via ctx.taskSource — the composition root wires the
        // right adapter for every taskSourceType (W0146). FetchDocsStep and
        // GenerateConfigsStep MUST go through the same adapter so configs
        // reference context files that were actually fetched.
        const filter = {
            ...(ctx.config.areas?.length ? { areas: ctx.config.areas } : {}),
            ...(ctx.config.tasks?.length ? { taskIds: ctx.config.tasks } : {}),
            ...(ctx.config.tags?.length ? { tags: ctx.config.tags } : {}),
            ...(ctx.config.changedDocs?.length
                ? { changedDocs: ctx.config.changedDocs }
                : {}),
            ...(ctx.config.injectContextDocs?.length
                ? { injectContextDocs: ctx.config.injectContextDocs }
                : {}),
        };
        const allTasks = await ctx.taskSource.loadTasks(Object.keys(filter).length > 0 ? filter : undefined);
        // Bridge: narrow to literacy tasks for canonical doc access
        const literacyTasks = allTasks.filter((t) => t.mode === "literacy");
        // Pass every literacy task to the active fetcher and let it decide what each
        // one gets. The Sanity fetcher contributes nothing for a task with no
        // canonical_docs; a source-level fetcher (e.g. Google Drive) supplies the
        // whole corpus even when a task declares no per-task refs. Keying off the
        // fetcher's output rather than declared `context.docs` keeps this step
        // source-agnostic.
        if (literacyTasks.length === 0) {
            return {
                status: "skipped",
                reason: "No literacy tasks — nothing to fetch",
            };
        }
        // Resolve source once with typed overrides
        const overrides = configToSourceOverrides(ctx.config);
        const resolvedSource = loadSource(ctx.config.source, overrides);
        // Log source info
        console.log(`  Source: ${resolvedSource.name}`);
        console.log(`  Base URL: ${resolvedSource.baseUrl}`);
        if (resolvedSource.perspective) {
            console.log(`  Perspective: ${resolvedSource.perspective}`);
        }
        if (resolvedSource.documentIds && resolvedSource.documentIds.length > 0) {
            console.log(`  Documents: ${resolvedSource.documentIds.length} document ID(s)`);
        }
        if (resolvedSource.urls.length > 0) {
            console.log(`  URLs:      ${resolvedSource.urls.length} direct URL(s)`);
        }
        // Precondition: docFetcher must be available
        if (!ctx.docFetcher) {
            return {
                durationMs: Date.now() - start,
                error: "DocFetcher port not available. Ensure composition root wires ctx.docFetcher.",
                status: "failed",
            };
        }
        // Execute the fetch via the DocFetcher port
        let releaseImpact;
        let perspectiveExpansion;
        // Task ids the fetcher produced actual content for. The Sanity fetcher
        // writes an empty placeholder file for every task (ref-less included), so
        // file-presence alone can't tell which tasks really got context — the
        // postcondition reads this instead.
        const tasksWithFetchedContent = new Set();
        try {
            const result = await ctx.docFetcher.fetch(literacyTasks, resolvedSource);
            for (const c of result.contexts) {
                if (c.content.trim().length > 0) {
                    tasksWithFetchedContent.add(c.taskId);
                }
            }
            // Capture release impact + perspective-ref expansion for auto-scoping
            releaseImpact = result.metadata?.releaseImpact;
            perspectiveExpansion = result.metadata?.perspectiveExpansion;
            // Write metadata files for downstream pipeline consumption
            if (result.metadata) {
                writeMetadataFiles(ctx.config.rootDir, result.metadata);
            }
            // W0050 — documentManifest is run-scoped bulk JSON. The
            // release-impact/document-overlay/url-fetch captures had no
            // registered descriptors (they were extras-only); dropped per Q3
            // ("producers always call emit; registered types only").
            const documentManifestPath = join(ctx.config.rootDir, "contexts", "document-manifest.json");
            if (existsSync(documentManifestPath)) {
                await emitFileContents(ctx.artifactWriter, "documentManifest", assoc(ctx), documentManifestPath);
            }
        }
        catch (err) {
            return {
                durationMs: Date.now() - start,
                error: `fetch-docs failed: ${err instanceof Error ? err.message : String(err)}`,
                status: "failed",
            };
        }
        // Postcondition: every task that should carry grader context has a
        // non-empty canonical file. A task qualifies if it declared doc refs
        // (catches a ref pointing at a removed/typo'd doc that resolved to
        // nothing) or the fetcher produced content for it (a whole-folder source
        // supplies context to ref-less tasks). Tasks with neither are legitimately
        // context-free, and asserting them would trip on the empty placeholder.
        const taskIds = literacyTasks
            .filter((t) => (t.context?.docs?.length ?? 0) > 0 ||
            tasksWithFetchedContent.has(t.id))
            .map((t) => t.id);
        const contextIssues = checkCanonicalContextsExist(ctx.config.rootDir, taskIds);
        const contextErrors = contextIssues.filter((i) => i.severity === "error");
        if (contextErrors.length > 0) {
            return {
                durationMs: Date.now() - start,
                error: `Postcondition failed: ${contextErrors.map((e) => e.message).join("; ")}`,
                status: "failed",
            };
        }
        // -----------------------------------------------------------------
        // Thread the perspective diff onto pipeline state for report
        // provenance. Deliberately independent of the auto-scope block below:
        // the uncovered visibility net (release edits outside the evaluated
        // canonical set) must reach the published report even when
        // computeAutoScope returns null (every task affected → no
        // `releaseAutoScope`) or auto-scoping is disabled — an applied fix
        // can never be silently unmeasured (D0066).
        // -----------------------------------------------------------------
        if (resolvedSource.perspective && releaseImpact) {
            state.releaseImpact = {
                perspective: resolvedSource.perspective,
                impact: releaseImpact,
            };
        }
        // -----------------------------------------------------------------
        // Release auto-scope: compute which tasks are affected by the
        // content release. This only activates when:
        //   1. A source-level perspective is active
        //   2. Release impact data was computed (docs were fetched)
        //   3. Auto-scoping was not explicitly disabled (--no-auto-scope)
        //   4. No explicit area/task filters are set (those take precedence)
        // -----------------------------------------------------------------
        if (resolvedSource.perspective &&
            releaseImpact &&
            !ctx.config.noAutoScope) {
            const autoScope = computeAutoScope(literacyTasks, releaseImpact, resolvedSource.perspective, perspectiveExpansion);
            if (autoScope) {
                state.releaseAutoScope = autoScope;
                logAutoScope(autoScope, releaseImpact.uncovered?.length ?? 0);
            }
        }
        const durationMs = Date.now() - start;
        return {
            durationMs,
            status: "success",
            summary: `Fetched canonical contexts for ${taskIds.length} tasks`,
        };
    }
    cacheInputs(ctx) {
        return getStepInputPaths(ctx.config.rootDir, "fetch-docs");
    }
    cacheContext(ctx) {
        return buildCacheContext(ctx.config);
    }
}
/**
 * Write metadata files returned by DocFetcher to the contexts/ directory.
 *
 * These files are consumed by downstream pipeline steps (scoring,
 * comparison, reporting) for traceability and impact analysis.
 */
function writeMetadataFiles(rootDir, metadata) {
    const contextsDir = join(rootDir, "contexts");
    mkdirSync(contextsDir, { recursive: true });
    if (metadata.manifest) {
        const path = join(contextsDir, "document-manifest.json");
        writeFileSync(path, JSON.stringify(metadata.manifest, null, 2));
        console.log(`  📋 Document manifest: ${metadata.manifest.length} docs → contexts/document-manifest.json`);
    }
    if (metadata.releaseImpact) {
        const path = join(contextsDir, "release-impact.json");
        writeFileSync(path, JSON.stringify(metadata.releaseImpact, null, 2));
        console.log("  📄 Release impact written to contexts/release-impact.json");
    }
    if (metadata.documentOverlay) {
        const path = join(contextsDir, "document-overlay.json");
        writeFileSync(path, JSON.stringify(metadata.documentOverlay, null, 2));
        console.log("  📄 Document overlay written to contexts/document-overlay.json");
    }
    if (metadata.urlFetch) {
        const path = join(contextsDir, "url-fetch.json");
        writeFileSync(path, JSON.stringify(metadata.urlFetch, null, 2));
        console.log("  📄 URL fetch metadata written to contexts/url-fetch.json");
    }
    if (metadata.fetchFailures && metadata.fetchFailures.length > 0) {
        const path = join(contextsDir, "fetch-failures.json");
        writeFileSync(path, JSON.stringify(metadata.fetchFailures, null, 2));
        console.log(`  📄 Fetch failures: ${metadata.fetchFailures.length} entries → contexts/fetch-failures.json`);
    }
}
// ---------------------------------------------------------------------------
// Release auto-scope computation
// ---------------------------------------------------------------------------
/**
 * Extract display slugs from a task's canonical doc refs.
 *
 * Uses slug, path (last segment), or id-based slug annotation.
 *
 * PerspectiveDocRefs contribute nothing here — they expand one-to-many to
 * "all articles versioned in release X" only during fetch (`SanityDocFetcher`
 * .resolvePerspectiveRefsToSlugs), so their slugs are genuinely unknown at
 * this point in the step. Their expanded slugs are attributed separately in
 * `computeAutoScope` via the fetcher-returned
 * `FetchMetadata.perspectiveExpansion`, so a task whose canonical docs come
 * only from a perspective ref is still scoped when a source-level release
 * modifies one of its expanded docs.
 */
function extractSlugsFromTask(task) {
    const slugs = [];
    for (const ref of task.context?.docs ?? []) {
        if (isSlugRef(ref)) {
            slugs.push(ref.slug);
        }
        else if (isPathRef(ref)) {
            // Path refs use the last segment as slug (e.g., "groq/groq-introduction" → "groq-introduction")
            const segments = ref.path.split("/");
            slugs.push(segments[segments.length - 1]);
        }
        else if (isIdRef(ref) && ref.slug) {
            // IdDocRef may carry an optional slug annotation for display
            slugs.push(ref.slug);
        }
        // PerspectiveDocRef — fetch-time expansion, attributed via
        // FetchMetadata.perspectiveExpansion (see the function doc).
    }
    return slugs;
}
/**
 * Look up the fetch-time perspective-ref expansion for one task.
 *
 * Returns the slugs that the task's `PerspectiveDocRef`s expanded to
 * during fetch (empty when the task has no perspective refs or no
 * expansion metadata was returned).
 */
function perspectiveExpandedSlugs(taskId, expansion) {
    if (!expansion)
        return [];
    const perspectives = expansion.perspectivesByTask[taskId] ?? [];
    return perspectives.flatMap((perspective) => expansion.slugsByPerspective[perspective] ?? []);
}
/**
 * Compute which tasks are affected by a content release.
 *
 * Builds a reverse mapping (slug → task IDs) from all tasks' canonical
 * doc refs — including slugs that task-level `PerspectiveDocRef`s expanded
 * to during fetch (`perspectiveExpansion`) — then intersects with the
 * release impact's added/modified slugs.
 * It reads only `releaseImpact.added`/`modified` (the canonical-scoped diff),
 * so `releaseImpact.uncovered` — release edits outside the evaluated set — by
 * construction never changes which tasks are scoped; those docs are surfaced
 * for visibility, not evaluated.
 *
 * Returns null if auto-scoping should not apply (e.g., all tasks affected).
 *
 * Exported for unit testing.
 */
export function computeAutoScope(tasks, releaseImpact, perspective, perspectiveExpansion) {
    // Build reverse mapping: slug → task IDs. Step-extractable slugs and
    // fetch-time perspective-ref expansions both attribute to the task, so
    // a task whose canonical docs come only from a PerspectiveDocRef is
    // scoped (not silently skipped) when a release modifies an expanded doc.
    const slugToTaskIds = new Map();
    for (const task of tasks) {
        const taskSlugs = [
            ...extractSlugsFromTask(task),
            ...perspectiveExpandedSlugs(task.id, perspectiveExpansion),
        ];
        for (const slug of taskSlugs) {
            let taskIds = slugToTaskIds.get(slug);
            if (!taskIds) {
                taskIds = new Set();
                slugToTaskIds.set(slug, taskIds);
            }
            taskIds.add(task.id);
        }
    }
    // Compute affected slugs (added + modified — these have changed content)
    const affectedSlugs = new Set([
        ...releaseImpact.added,
        ...releaseImpact.modified,
    ]);
    // Find tasks affected by the release
    const affectedTaskIds = new Set();
    for (const slug of affectedSlugs) {
        const taskIds = slugToTaskIds.get(slug);
        if (taskIds) {
            for (const id of taskIds) {
                affectedTaskIds.add(id);
            }
        }
    }
    // If all tasks are affected, auto-scoping adds no value
    if (affectedTaskIds.size >= tasks.length)
        return null;
    // If no tasks are affected, still return the scope info so downstream
    // steps know nothing needs evaluation (avoids wasting eval budget)
    const allTaskIds = new Set(tasks.map((t) => t.id));
    const skippedTaskIds = [...allTaskIds].filter((id) => !affectedTaskIds.has(id));
    return {
        affectedTaskIds: [...affectedTaskIds],
        skippedTaskIds,
        perspective,
        impact: {
            added: releaseImpact.added,
            modified: releaseImpact.modified,
            removed: releaseImpact.removed,
            unchanged: releaseImpact.unchanged,
        },
    };
}
/**
 * Log auto-scope results to the console.
 *
 * `uncoveredCount` is the number of release-modified docs that fall outside the
 * evaluated canonical set (named in full by the fetcher's perspective-diff
 * log). Reported here so the scope summary makes the relationship between
 * narrowing and unmeasured edits explicit at the point scope is narrowed.
 */
function logAutoScope(autoScope, uncoveredCount = 0) {
    const { affectedTaskIds, skippedTaskIds, impact } = autoScope;
    const totalTasks = affectedTaskIds.length + skippedTaskIds.length;
    const affectedDocs = impact.added.length + impact.modified.length;
    console.log(`  🎯 Release auto-scope: ${affectedTaskIds.length} of ${totalTasks} tasks affected`);
    if (affectedDocs > 0) {
        console.log(`     ${affectedDocs} doc(s) changed → ${affectedTaskIds.length} task(s) to evaluate`);
    }
    if (affectedTaskIds.length > 0) {
        for (const id of affectedTaskIds) {
            console.log(`     ✓ ${id}`);
        }
    }
    if (skippedTaskIds.length > 0) {
        console.log(`     ⏭ ${skippedTaskIds.length} task(s) skipped (no docs affected by release)`);
    }
    if (uncoveredCount > 0) {
        console.log(`     ⚠ ${uncoveredCount} release-modified doc(s) outside the evaluated set — not attributed to any task (see perspective-diff log)`);
    }
}
