/**
 * Sources.ts
 *
 * Loads and validates documentation source configurations from config/sources.
 * Each source defines where to find documentation for evaluation — a Sanity
 * dataset, a branch deploy URL, a local dev server, etc.
 *
 * Every source carries all fields needed by both evaluation modes:
 *   - Baseline/Observed: uses projectId + dataset (GROQ queries to Sanity CMS)
 *   - Agentic: uses baseUrl (agent browses the live site, llms.txt derived automatically)
 *
 * Environment variables in config/sources are resolved via ${{ VAR | default }}
 * interpolation at load time.
 */
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
import { DEFAULT_DOCS_DATASET, DEFAULT_DOCS_PROJECT_ID, } from "./_vendor/ailf-shared/index.js";
import { resolveGoogleDriveSource } from "./adapters/doc-fetchers/google-drive/config.js";
import { ConsoleLogger } from "./adapters/loggers/index.js";
import { interpolate } from "./interpolate.js";
import { tryLoadConfigFile } from "./pipeline/compiler/config-loader.js";
import { sanityLiteracyStaticSourceDefs } from "./pipeline/compiler/presets/sanity-literacy-sources.js";
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = resolve(__dirname, "..");
// ---------------------------------------------------------------------------
// Default source — used when no config/sources exists or no --source flag
// ---------------------------------------------------------------------------
const DEFAULT_SOURCE = {
    baseUrl: "https://www.sanity.io/docs",
    dataset: DEFAULT_DOCS_DATASET,
    documentIds: [],
    llmsTxt: "https://www.sanity.io/docs/llms.txt",
    name: "production",
    perspective: undefined,
    priorityDomain: "sanity.io",
    projectId: DEFAULT_DOCS_PROJECT_ID,
    studioOrigin: "https://admin.sanity.io",
    urls: [],
};
/**
 * Apply `SourceOverrides` + env-var fallbacks to `DEFAULT_SOURCE`.
 *
 * The DEFAULT_SOURCE early-return branches are taken when `config/sources`
 * is missing or empty — the production state, since the named source
 * definitions actually live in the `sanity-literacy` preset's `sourceDefs`
 * (which `loadSource` consults only for the env-override value-equality
 * check, not for named lookup). Returning `DEFAULT_SOURCE`
 * verbatim drops every override the caller passed in, including
 * `perspective` — observed live as production-source release evals
 * fetching the published doc revision (W0295).
 *
 * The merge order mirrors the priority-1 (env-baseUrl) branch. The two
 * paths diverge in three ways, all intentional: this branch (a) pins
 * `baseUrl` / `llmsTxt` / `name` / `priorityDomain` to `DEFAULT_SOURCE`,
 * (b) returns `documentIds: []` (the prior `DEFAULT_SOURCE` shape) where
 * priority-1 would return `undefined` — both fall through the same
 * `length > 0` consumer check, so behaviorally equivalent.
 */
function applyOverridesToDefault(overrides) {
    const allowedOrigins = overrides?.allowedOrigins ?? parseAllowedOriginsEnv();
    const headers = overrides?.headers ?? parseHeadersEnv();
    return {
        ...DEFAULT_SOURCE,
        ...(allowedOrigins ? { allowedOrigins } : {}),
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back
        dataset: overrides?.dataset ??
            (process.env.SANITY_DATASET || DEFAULT_DOCS_DATASET),
        documentIds: overrides?.documentIds ?? parseDocumentIdsEnv() ?? [],
        ...(headers ? { headers } : {}),
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back
        perspective: overrides?.perspective ?? (process.env.SANITY_PERSPECTIVE || undefined),
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back
        projectId: overrides?.projectId ??
            (process.env.SANITY_PROJECT_ID || DEFAULT_DOCS_PROJECT_ID),
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back
        studioOrigin: overrides?.studioOrigin ??
            (process.env.SANITY_STUDIO_ORIGIN || "https://admin.sanity.io"),
        urls: overrides?.directUrls ?? parseDirectUrlsEnv(),
    };
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/**
 * Check whether a URL is allowed by the given origin patterns.
 *
 * Returns `true` if:
 * - `origins` is empty (no sandboxing configured), or
 * - the URL's hostname matches at least one origin pattern.
 *
 * Returns `false` if the URL is invalid or doesn't match any pattern.
 */
export function isAllowedOrigin(url, origins) {
    if (origins.length === 0) {
        return true;
    }
    try {
        const hostname = new URL(url).hostname.replace(/^www\./, "");
        return origins.some((origin) => matchesOrigin(hostname, origin));
    }
    catch {
        return false;
    }
}
/**
 * Load a documentation source by name.
 *
 * Resolution order:
 *   1. If DOC_BASE_URL env var is set, build an ad-hoc source from env vars.
 *      When the resolved tuple (baseUrl, projectId, dataset) is value-equal
 *      to a registered named source and no override-only knob is set, the
 *      source resolves under its registered name instead of "env-override".
 *   2. If config/sources exists, load and interpolate the named source
 *   3. Otherwise the built-in default (sanity.io production). A requested name
 *      that can't be resolved throws — it does not fall back.
 *
 * @param name - Source name from config/sources. If omitted, uses DOC_SOURCE
 *               env var or the first source defined in config/sources.
 * @throws if an explicitly requested source (name / DOC_SOURCE) can't be
 *         resolved — a missing registry or unknown name fails loudly rather
 *         than silently defaulting to the wrong docs.
 */
export function loadSource(name, overrides, logger) {
    const log = logger ?? new ConsoleLogger();
    // Priority 1: DOC_BASE_URL env var creates an ad-hoc source
    const envBaseUrl = overrides?.baseUrl ?? process.env.DOC_BASE_URL;
    if (envBaseUrl) {
        const envSource = {
            allowedOrigins: overrides?.allowedOrigins ?? parseAllowedOriginsEnv(),
            baseUrl: envBaseUrl,
            // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to default
            dataset: overrides?.dataset ??
                (process.env.SANITY_DATASET || DEFAULT_DOCS_DATASET),
            documentIds: overrides?.documentIds ?? parseDocumentIdsEnv(),
            headers: overrides?.headers ?? parseHeadersEnv(),
            llmsTxt: envBaseUrl + "/llms.txt",
            name: "env-override",
            // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to undefined
            perspective: overrides?.perspective ?? (process.env.SANITY_PERSPECTIVE || undefined),
            priorityDomain: extractHostname(envBaseUrl),
            // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to default
            projectId: overrides?.projectId ??
                (process.env.SANITY_PROJECT_ID || DEFAULT_DOCS_PROJECT_ID),
            studioOrigin: 
            // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to default
            overrides?.studioOrigin ??
                (process.env.SANITY_STUDIO_ORIGIN || "https://admin.sanity.io"),
            urls: overrides?.directUrls ?? parseDirectUrlsEnv(),
        };
        // An override tuple that is value-identical to a registered named source
        // isn't really an override — resolve it under its registered name so
        // report provenance reflects what the configuration *resolves to*, not
        // how it was spelled. Spelled-as-override but value-equal configs would
        // otherwise trip the D0059 Tier-1 `env-override → experimental` rule and
        // silently override the caller's explicit classification. Any
        // override-only knob (perspective, documentIds, headers, direct URLs)
        // means the registry genuinely cannot account for the configuration, so
        // the equality check is skipped and `env-override` stands.
        if (!hasOverrideOnlyKnobs(envSource)) {
            const named = findValueEqualNamedSource(envSource);
            if (named) {
                const namedBaseUrl = stripTrailingSlashes(named.baseUrl);
                log.debug("Override values match a registered source; resolving to named source", {
                    name: named.name,
                    baseUrl: namedBaseUrl,
                    hasOverrides: Boolean(overrides),
                });
                return {
                    ...envSource,
                    baseUrl: namedBaseUrl,
                    llmsTxt: namedBaseUrl + "/llms.txt",
                    name: named.name,
                    priorityDomain: extractHostname(namedBaseUrl),
                };
            }
        }
        log.debug("Source resolved from DOC_BASE_URL env override", {
            baseUrl: envBaseUrl,
            domain: envSource.priorityDomain,
            hasOverrides: Boolean(overrides),
        });
        return envSource;
    }
    // Priority 2: Load from config/sources (TS, JS, YAML, or JSON)
    //
    // An explicitly requested source must resolve to a registered entry, or the
    // run would score against the wrong docs — so the fallbacks below throw for
    // it instead of silently defaulting. Exception: the built-in default is
    // itself named "production", so requesting that name still resolves to it.
    const requestedName = (name || process.env.DOC_SOURCE)?.trim();
    const mustResolveRequest = !!requestedName && requestedName !== DEFAULT_SOURCE.name;
    const loaded = tryLoadConfigFile("sources", PACKAGE_ROOT);
    const entries = loaded && Array.isArray(loaded.data)
        ? loaded.data
        : null;
    if (!entries || entries.length === 0) {
        if (mustResolveRequest) {
            throw new Error('Source "' +
                requestedName +
                '" was requested but config/sources has no entries.');
        }
        log.debug("config/sources empty or absent, using built-in default");
        console.log("  config/sources empty or absent, using built-in default");
        return applyOverridesToDefault(overrides);
    }
    // Match by name; with no --source, only an entry explicitly tagged
    // `default: true` is selected. A lone non-default entry must NOT become the
    // implicit default — that would route every default run to the wrong source
    // (the W-source-default-resolution fix). No tagged default ⇒ the built-in
    // Sanity production source. Mirrors composition-root.ts:resolveSourceEntry.
    const match = requestedName
        ? entries.find((e) => e.name === requestedName)
        : entries.find((e) => e.default);
    if (!match) {
        if (mustResolveRequest) {
            const available = entries
                .map((e) => e.name)
                .filter(Boolean)
                .join(", ");
            throw new Error('Source "' +
                requestedName +
                '" not found in config/sources. Available: ' +
                available);
        }
        return applyOverridesToDefault(overrides);
    }
    // Drive specifics stay inside the adapter: it hands back a normalized
    // ResolvedSourceConfig, so nothing downstream branches on source kind.
    if (match.kind === "google-drive") {
        return resolveGoogleDriveSource(match);
    }
    return resolveSanityEntry(match, overrides);
}
/**
 * Build the resolved Sanity source from a `config/sources` array entry, applying
 * the same override/env precedence the keyed-object path used. `allowedOrigins`
 * and `headers` are no longer entry-declarable (the array `DocSourceConfig`
 * shape carries neither) — they come from overrides/env only.
 */
function resolveSanityEntry(entry, overrides) {
    const label = entry.name ?? "<unnamed>";
    const missing = (field) => new Error('Source "' + label + '" is missing required field "' + field + '".');
    if (!entry.baseUrl)
        throw missing("baseUrl");
    if (!entry.projectId)
        throw missing("projectId");
    if (!entry.dataset)
        throw missing("dataset");
    const { baseUrl } = entry;
    const headers = overrides?.headers ?? parseHeadersEnv();
    const allowedOrigins = overrides?.allowedOrigins ?? parseAllowedOriginsEnv();
    return {
        ...(allowedOrigins ? { allowedOrigins } : {}),
        baseUrl,
        dataset: entry.dataset,
        documentIds: overrides?.documentIds ?? parseDocumentIdsEnv() ?? [],
        ...(headers ? { headers } : {}),
        llmsTxt: baseUrl + "/llms.txt",
        name: label,
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to undefined
        perspective: overrides?.perspective ?? (process.env.SANITY_PERSPECTIVE || undefined),
        priorityDomain: extractHostname(baseUrl),
        projectId: entry.projectId,
        // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty string env var should fall back to default
        studioOrigin: overrides?.studioOrigin ??
            (process.env.SANITY_STUDIO_ORIGIN || "https://admin.sanity.io"),
        urls: overrides?.directUrls ?? parseDirectUrlsEnv(),
    };
}
// ---------------------------------------------------------------------------
// Value-equality resolution — override tuples that match a registered source
// ---------------------------------------------------------------------------
/**
 * Whether the resolved override carries a knob only the env-override path
 * can express (perspective, documentIds, headers, direct URLs). Any of
 * these means a registered named source cannot account for the resolved
 * configuration, so it must keep resolving to `env-override`.
 */
function hasOverrideOnlyKnobs(source) {
    return (Boolean(source.perspective) ||
        (source.documentIds?.length ?? 0) > 0 ||
        Boolean(source.headers && Object.keys(source.headers).length > 0) ||
        source.urls.length > 0);
}
/**
 * Find a registered named source whose definition is value-equal to the
 * resolved override tuple: `baseUrl` (trailing-slash tolerant), `projectId`,
 * and `dataset` must all match. Candidates that define their own
 * `perspective` or `headers` describe a substrate the bare tuple does not
 * reproduce, so they never match.
 */
function findValueEqualNamedSource(tuple) {
    const wantBaseUrl = stripTrailingSlashes(tuple.baseUrl);
    for (const def of loadRegisteredSourceDefs()) {
        if (!def.baseUrl || !def.projectId || !def.dataset)
            continue;
        if (def.perspective)
            continue;
        if (def.headers && Object.keys(def.headers).length > 0)
            continue;
        if (stripTrailingSlashes(def.baseUrl) !== wantBaseUrl)
            continue;
        if (def.projectId !== tuple.projectId)
            continue;
        if (def.dataset !== tuple.dataset)
            continue;
        return def;
    }
    return undefined;
}
/**
 * Collect the registered named-source definitions the value-equality check
 * compares against: config/sources entries (either the legacy
 * `{ sources: {...} }` record shape or the `defineSources([...])` array
 * shape) merged with the preset's statically addressed defs. Config-defined
 * entries shadow preset defs of the same name, mirroring the documented
 * override-point semantics of config/sources.
 */
function loadRegisteredSourceDefs() {
    const fromConfig = [];
    const loaded = tryLoadConfigFile("sources", PACKAGE_ROOT);
    if (loaded) {
        if (Array.isArray(loaded.data)) {
            fromConfig.push(...loaded.data);
        }
        else if (loaded.data?.sources) {
            for (const [name, rawEntry] of Object.entries(loaded.data.sources)) {
                try {
                    fromConfig.push({ name, ...interpolate(rawEntry) });
                }
                catch {
                    // Entry references an env var that isn't set — it cannot be the
                    // source the override resolves to; skip it.
                }
            }
        }
    }
    const configNames = new Set(fromConfig.map((def) => def.name));
    const fromPreset = sanityLiteracyStaticSourceDefs().filter((def) => !configNames.has(def.name));
    return [...fromConfig, ...fromPreset];
}
/** Strip trailing slashes for trailing-slash-tolerant baseUrl comparison. */
function stripTrailingSlashes(url) {
    return url.replace(/\/+$/, "");
}
/**
 * Match a hostname against an origin pattern that may contain `*` wildcards.
 *
 * The `*` wildcard matches any substring within a single hostname segment
 * (i.e., it does not cross dots). A bare `*` matches everything.
 *
 * @example
 * matchesOrigin("branch.sanity.build", "*.sanity.build")   // true
 * matchesOrigin("sanity.io", "*.sanity.build")              // false
 * matchesOrigin("anything.example.com", "*")                // true
 */
export function matchesOrigin(hostname, pattern) {
    if (pattern === "*") {
        return true;
    }
    // Convert glob pattern to regex: escape dots, replace * with [^.]* (within-segment)
    const regexStr = "^" + pattern.replace(/\./g, "\\.").replace(/\*/g, "[^.]*") + "$";
    return new RegExp(regexStr).test(hostname);
}
function extractHostname(url) {
    try {
        return new URL(url).hostname;
    }
    catch {
        return url;
    }
}
/**
 * Parse allowed origins from environment variables.
 *
 * Reads DOC_ALLOWED_ORIGINS (plural, comma-separated) first, then falls back
 * to DOC_ALLOWED_ORIGIN (singular) for backward compatibility.
 *
 * Returns undefined if neither is set (no sandboxing).
 */
function parseAllowedOriginsEnv() {
    const plural = process.env.DOC_ALLOWED_ORIGINS;
    if (plural) {
        const origins = plural
            .split(",")
            .map((o) => o.trim())
            .filter(Boolean);
        return origins.length > 0 ? origins : undefined;
    }
    const singular = process.env.DOC_ALLOWED_ORIGIN;
    if (singular) {
        return [singular];
    }
    return undefined;
}
// ---------------------------------------------------------------------------
// Origin matching (exported for use by agentic-provider and assertions)
// ---------------------------------------------------------------------------
/**
 * Parse direct documentation URLs from the DOC_DIRECT_URLS environment
 * variable (comma-separated). These are URLs classified as "direct-url"
 * by the URL classifier — they point to documentation pages rather than
 * Sanity CMS resources.
 */
function parseDirectUrlsEnv() {
    const env = process.env.DOC_DIRECT_URLS;
    if (!env) {
        return [];
    }
    return env
        .split(",")
        .map((u) => u.trim())
        .filter(Boolean);
}
/**
 * Parse the SANITY_DOCUMENT_IDS env var (comma-separated UUIDs) into an array.
 * Returns undefined if not set or empty.
 */
function parseDocumentIdsEnv() {
    const raw = process.env.SANITY_DOCUMENT_IDS;
    if (!raw) {
        return undefined;
    }
    const ids = raw
        .split(",")
        .map((id) => id.trim())
        .filter(Boolean);
    return ids.length > 0 ? ids : undefined;
}
/**
 * Parse the DOC_HEADERS env var (JSON string) into a Record.
 * Returns undefined if not set or invalid.
 */
function parseHeadersEnv() {
    const raw = process.env.DOC_HEADERS;
    if (!raw) {
        return undefined;
    }
    try {
        const parsed = JSON.parse(raw);
        if (typeof parsed === "object" &&
            parsed !== null &&
            !Array.isArray(parsed)) {
            return parsed;
        }
    }
    catch {
        // Invalid JSON — ignore
    }
    return undefined;
}
