/**
 * GoogleDriveDocFetcher — DocFetcher adapter for Google Drive (W0208).
 *
 * Accepts only `kind: "drive-*"` refs; rejects everything else via
 * `RefKindMismatchError`. Dispatches per file by MIME type:
 *   - Google Docs (`application/vnd.google-apps.document`) → export as markdown
 *   - `text/markdown`, `text/x-markdown`, `text/plain` → fetch raw bytes
 *   - `application/vnd.google-apps.folder` → recurse via `files.list`
 *   - anything else (including `text/html`) → skip with a fetch-failure entry
 *
 * Per-file failures are accumulated rather than aborting the run; the
 * catastrophic failure modes are: auth missing (preset-construction time),
 * `maxFiles` exceeded (pre/in-flight), or every file failed (post-fetch).
 *
 * Per-file caching by `(fileId, file.version)` is intentionally deferred —
 * the eval step-runner's cache covers re-runs at the step granularity, and
 * the finer-grained cache would expand the v1 surface without enabling
 * Drive functionality. Worth revisiting if/when Drive runs become hot.
 */
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { RefKindMismatchError, } from "../../../_vendor/ailf-core/index.js";
import { escapeNunjucks } from "../escape-nunjucks.js";
import { assertDriveId } from "./ids.js";
// ---------------------------------------------------------------------------
// MIME-type dispatch table
// ---------------------------------------------------------------------------
const GOOGLE_DOC_MIME = "application/vnd.google-apps.document";
const FOLDER_MIME = "application/vnd.google-apps.folder";
const PASSTHROUGH_MIMES = new Set([
    "text/markdown",
    "text/x-markdown",
    "text/plain",
]);
const EMPTY_RESULT = { files: [], failures: [] };
function singleFile(file) {
    return { files: [file], failures: [] };
}
function singleFailure(failure) {
    return { files: [], failures: [failure] };
}
function mergeResults(...parts) {
    const files = [];
    const failures = [];
    for (const p of parts) {
        files.push(...p.files);
        failures.push(...p.failures);
    }
    return { files, failures };
}
export class GoogleDriveDocFetcher {
    source;
    client;
    maxFiles;
    recurseDepth;
    mimeFilters;
    rootDir;
    constructor(source, client, options = {}) {
        this.source = source;
        this.client = client;
        this.maxFiles = source.maxFiles ?? 2000;
        this.recurseDepth = source.recurseDepth ?? 3;
        this.mimeFilters =
            source.mimeFilters && source.mimeFilters.length > 0
                ? new Set(source.mimeFilters)
                : null;
        this.rootDir = options.rootDir ?? null;
    }
    /**
     * Implements the `DocFetcher` port. The optional `source` parameter
     * lets a caller override the closure-captured `GoogleDriveSource` on a
     * per-call basis — same per-call-override semantics the Sanity adapter
     * honors. Wrong-kind sources (e.g. a Sanity source passed in) are
     * rejected loudly via `RefKindMismatchError` so a future per-call
     * dispatch can't silently misroute work.
     */
    async fetch(tasks, source) {
        const effectiveSource = this.resolveSource(source);
        const limits = this.limitsFor(effectiveSource);
        const contexts = [];
        const symbolIndexes = [];
        const fetchFailures = [];
        // Dedup the manifest and bound maxFiles by unique file id: in whole-folder
        // mode the same corpus is every ref-less task's context, so the folder must
        // count once toward the ceiling, not once per task.
        const filesById = new Map();
        // Whole-folder mode: a literacy task that declares no drive refs gets the
        // entire source folder as its context, so the agent has the full corpus to
        // navigate. The folder is enumerated once and reused across every such task.
        let wholeFolder;
        let wholeFolderCounted = false;
        for (const task of tasks) {
            const taskFiles = [];
            const refs = extractDocs(task);
            if (refs.length === 0) {
                if (!wholeFolder) {
                    wholeFolder = await this.expandFolder(effectiveSource.rootFolderId, undefined, 0, limits);
                }
                taskFiles.push(...wholeFolder.files);
                // The folder's files are shared across all ref-less tasks, so record
                // its failures once (attributed to the first task that triggered it).
                if (!wholeFolderCounted) {
                    for (const failure of wholeFolder.failures) {
                        fetchFailures.push({ ...failure, taskId: task.id });
                    }
                    wholeFolderCounted = true;
                }
            }
            else {
                for (const ref of refs) {
                    if (!isDriveRef(ref)) {
                        throw new RefKindMismatchError(`GoogleDriveDocFetcher accepts drive-* refs; got ${"kind" in ref && ref.kind ? String(ref.kind) : "<untagged>"} on task ${task.id}`);
                    }
                    const result = await this.expandRef(ref, limits);
                    taskFiles.push(...result.files);
                    // I5 — attribute every per-ref failure to the task whose ref it came
                    // from, so grading can distinguish "the model ignored its docs" from
                    // "the fetcher couldn't read this task's docs."
                    for (const failure of result.failures) {
                        fetchFailures.push({ ...failure, taskId: task.id });
                    }
                }
            }
            for (const file of taskFiles)
                filesById.set(file.id, file);
            if (filesById.size > limits.maxFiles) {
                throw new Error(`Google Drive adapter aborted: file count ${filesById.size} exceeds maxFiles=${limits.maxFiles}; raise maxFiles, lower recurseDepth, or refine rootFolderId`);
            }
            contexts.push(buildContext(task.id, taskFiles));
            symbolIndexes.push(zeroSymbolIndex(task.id));
        }
        const manifest = [...filesById.values()].map(toManifestEntry);
        // Catastrophic abort: hard fetch errors occurred AND nothing succeeded.
        // Pure unsupported-MIME skips (status undefined) don't trigger this —
        // a Drive folder of only-HTML produces an empty context, not an error.
        const hardFailures = fetchFailures.filter((f) => f.status !== undefined);
        if (hardFailures.length > 0 && manifest.length === 0) {
            throw new Error(`Google Drive adapter: all ${hardFailures.length} fetch(es) failed; first error: ${hardFailures[0].error}`);
        }
        const metadata = {
            ...(manifest.length > 0 ? { manifest } : {}),
            ...(symbolIndexes.length > 0 ? { symbolIndexes } : {}),
            ...(fetchFailures.length > 0 ? { fetchFailures } : {}),
        };
        if (this.rootDir) {
            await this.writeArtifacts(this.rootDir, contexts, symbolIndexes);
        }
        return Object.keys(metadata).length > 0
            ? { contexts, metadata }
            : { contexts };
    }
    /**
     * Validate + narrow a per-call source override. The composition root wires
     * the kind-matched source into the constructor, and the pipeline's
     * fetch-docs step passes the resolved source (kind-erased by
     * `resolveGoogleDriveSource`, which routes the fetch through this preset
     * rather than the resolved fields) on every call. So an untagged override
     * is the normal case — fall back to the constructor source. Only a *tagged*
     * foreign source (e.g. a Sanity source) signals a caller bypassing the
     * registry, which we still reject.
     */
    resolveSource(source) {
        if (!source)
            return this.source;
        const kind = source.kind;
        if (kind === undefined)
            return this.source;
        if (kind !== "google-drive") {
            throw new RefKindMismatchError(`GoogleDriveDocFetcher.fetch() received a non-Drive source override (kind=${String(kind)}); pass a GoogleDriveSource or omit the override`);
        }
        return source;
    }
    limitsFor(source) {
        // When fetch() is called without a per-call override, reuse the
        // limits this adapter was constructed with so the hot path stays
        // allocation-free.
        if (source === this.source) {
            return {
                maxFiles: this.maxFiles,
                recurseDepth: this.recurseDepth,
                mimeFilters: this.mimeFilters,
            };
        }
        return {
            maxFiles: source.maxFiles ?? 2000,
            recurseDepth: source.recurseDepth ?? 3,
            mimeFilters: source.mimeFilters && source.mimeFilters.length > 0
                ? new Set(source.mimeFilters)
                : null,
        };
    }
    /**
     * Emit the per-task canonical context files and the symbol-index
     * manifest under `<rootDir>/contexts/`. `document-manifest.json` and
     * `fetch-failures.json` are written by `fetch-docs-step.ts` from the
     * returned `FetchMetadata` (same path as the Sanity adapter).
     *
     * `escapeNunjucks` runs once at the write seam (C3) so the
     * `DocContext.content` returned from `fetch()` stays as clean
     * markdown — only the on-disk artifact Promptfoo consumes carries
     * the `{{` / `}}` escapes.
     */
    async writeArtifacts(rootDir, contexts, symbolIndexes) {
        const canonicalDir = join(rootDir, "contexts", "canonical");
        const symbolsDir = join(rootDir, "contexts", "canonical-symbols");
        await mkdir(canonicalDir, { recursive: true });
        await mkdir(symbolsDir, { recursive: true });
        await Promise.all(contexts.map((ctx) => writeFile(join(canonicalDir, `${ctx.taskId}.md`), escapeNunjucks(ctx.content), "utf-8")));
        // W0197 observable-fallback manifest: one entry per task with
        // symbolCount: 0. Per-task .md files are deliberately not written —
        // the manifest entry alone signals "extraction ran, produced no
        // symbols," which is what the grader-context consumer reads.
        const sorted = [...symbolIndexes].sort((a, b) => a.taskId.localeCompare(b.taskId));
        await writeFile(join(symbolsDir, "manifest.json"), JSON.stringify({ entries: sorted }, null, 2), "utf-8");
    }
    // ---------------------------------------------------------------------------
    // Ref expansion
    //
    // Per-call limits (`maxFiles`, `recurseDepth`, `mimeFilters`) thread
    // through this layer so a per-call source override (I2) actually
    // changes the recursion bounds — closure-only limits would silently
    // ignore the override.
    // ---------------------------------------------------------------------------
    async expandRef(ref, limits) {
        // Runtime trust-boundary check (I1): ref values arrive untyped from
        // YAML task files and (future) Content Lake `ailf.task` documents
        // until ref schemas land in W0218 / W0220.
        if (ref.kind === "drive-file") {
            assertDriveId("fileId", ref.fileId);
            return this.fetchFileById(ref.fileId);
        }
        assertDriveId("folderId", ref.folderId);
        return this.expandFolder(ref.folderId, ref.mimeTypes, 0, limits);
    }
    async fetchFileById(fileId) {
        let meta;
        try {
            const res = await this.client.files.get({
                fileId,
                fields: "id,name,mimeType,version",
                supportsAllDrives: true,
            });
            meta = res.data;
        }
        catch (err) {
            return singleFailure(buildFailure(fileId, err));
        }
        return this.materializeFile(meta, undefined, this.mimeFilters);
    }
    async expandFolder(folderId, refMimeTypes, depth, limits) {
        if (depth > limits.recurseDepth)
            return EMPTY_RESULT;
        const listing = await this.listChildren(folderId);
        const parts = [{ files: [], failures: listing.failures }];
        for (const child of listing.children) {
            if (!child.mimeType || !child.id)
                continue;
            if (child.mimeType === FOLDER_MIME) {
                parts.push(await this.expandFolder(child.id, refMimeTypes, depth + 1, limits));
                continue;
            }
            if (!allowedByFilters(child.mimeType, refMimeTypes, limits.mimeFilters))
                continue;
            parts.push(await this.materializeFile(child, refMimeTypes, limits.mimeFilters));
        }
        return mergeResults(...parts);
    }
    async listChildren(folderId) {
        const children = [];
        let pageToken;
        do {
            try {
                const res = await this.client.files.list({
                    q: `'${folderId}' in parents and trashed = false`,
                    fields: "nextPageToken,files(id,name,mimeType,version)",
                    pageSize: 1000,
                    supportsAllDrives: true,
                    includeItemsFromAllDrives: true,
                    ...(pageToken ? { pageToken } : {}),
                });
                if (res.data.files)
                    children.push(...res.data.files);
                pageToken = res.data.nextPageToken ?? undefined;
            }
            catch (err) {
                return {
                    children,
                    failures: [
                        buildFailure(`folder:${folderId}`, err, "folder-list failed"),
                    ],
                };
            }
        } while (pageToken);
        return { children, failures: [] };
    }
    /**
     * Fetch the content of a single file and convert it to markdown when the
     * MIME type is supported. Unsupported types yield a soft-skip failure
     * (status undefined) so the run continues with the rest of the batch.
     */
    async materializeFile(meta, refFilters, sourceFilters) {
        if (!meta.id || !meta.mimeType || !meta.name) {
            return singleFailure({
                resource: meta.id
                    ? `https://drive.google.com/file/d/${meta.id}/view`
                    : "drive:<unknown>",
                error: "Drive returned file metadata without id, name, or mimeType",
            });
        }
        if (!allowedByFilters(meta.mimeType, refFilters, sourceFilters))
            return EMPTY_RESULT;
        try {
            const content = await this.fetchContentByMime(meta.id, meta.mimeType);
            if (content === null) {
                return singleFailure({
                    resource: `https://drive.google.com/file/d/${meta.id}/view`,
                    error: `Unsupported MIME type: ${meta.mimeType}`,
                });
            }
            return singleFile({
                id: meta.id,
                name: meta.name,
                version: meta.version ?? "unknown",
                content,
            });
        }
        catch (err) {
            return singleFailure(buildFailure(meta.id, err));
        }
    }
    /**
     * Resolve a file's content per its MIME type. Returns `null` for
     * unsupported types (caller records the skip; not an error).
     */
    async fetchContentByMime(fileId, mimeType) {
        if (mimeType === GOOGLE_DOC_MIME) {
            const res = await this.client.files.export({
                fileId,
                mimeType: "text/markdown",
                supportsAllDrives: true,
            });
            return res.data;
        }
        if (PASSTHROUGH_MIMES.has(mimeType)) {
            const res = await this.client.files.get({
                fileId,
                alt: "media",
                supportsAllDrives: true,
            });
            // The `files.get` body for `alt: media` arrives as a string from the
            // Drive client; downstream consumers expect markdown text.
            return typeof res.data === "string"
                ? res.data
                : String(res.data ?? "");
        }
        return null;
    }
}
/**
 * Whether `mimeType` passes both the source-level `mimeFilters` and the
 * optional per-ref `mimeTypes` overlay. Folders are always allowed
 * (the dispatch in `expandFolder` handles them before this check).
 */
function allowedByFilters(mimeType, refFilters, sourceFilters) {
    if (sourceFilters && !sourceFilters.has(mimeType))
        return false;
    if (refFilters && refFilters.length > 0 && !refFilters.includes(mimeType)) {
        return false;
    }
    return true;
}
function buildContext(taskId, files) {
    const sections = files.map((f) => `# ${f.name}\n\n${f.content.trim()}`);
    const content = sections.join("\n\n---\n\n");
    return {
        taskId,
        content,
        slugs: files.map((f) => slugify(f.name)),
        tokenCount: Math.ceil(content.length / 4),
    };
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isDriveRef(ref) {
    return ("kind" in ref &&
        typeof ref.kind === "string" &&
        ref.kind.startsWith("drive-"));
}
function extractDocs(task) {
    // All task modes expose context.docs; the field is optional.
    const docs = task.context?.docs;
    return docs ?? [];
}
function buildFailure(resourceId, err, fallbackMsg = "Drive request failed") {
    const errObj = err;
    const message = typeof errObj?.message === "string" ? errObj.message : fallbackMsg;
    const statusCandidate = errObj?.code ?? errObj?.status;
    const status = typeof statusCandidate === "number" ? statusCandidate : undefined;
    const resource = resourceId.startsWith("folder:")
        ? `https://drive.google.com/drive/folders/${resourceId.slice("folder:".length)}`
        : `https://drive.google.com/file/d/${resourceId}/view`;
    return status !== undefined
        ? { resource, error: message, status }
        : { resource, error: message };
}
function toManifestEntry(file) {
    return {
        // Stamp the real Drive identity; without it resolveLocator() synthesizes a
        // Sanity locator from file.id, pointing consumers at Content Lake.
        locator: {
            kind: "google-drive",
            fileId: file.id,
            ...(file.version && file.version !== "unknown"
                ? { revision: file.version }
                : {}),
        },
        _id: file.id,
        _rev: file.version,
        slug: slugify(file.name),
        title: file.name,
    };
}
function zeroSymbolIndex(taskId) {
    return {
        taskId,
        path: `contexts/canonical-symbols/${taskId}.json`,
        symbolCount: 0,
        tierBreakdown: { typeDef: 0, heading: 0, inlineCode: 0, codeBlock: 0 },
    };
}
function slugify(name) {
    return name
        .toLowerCase()
        .replace(/\.[^.]+$/, "")
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-+|-+$/g, "");
}
