/**
 * 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 { ConsoleLogger } from "./adapters/loggers/index.js";
import { interpolate } from "./interpolate.js";
import { tryLoadConfigFile } from "./pipeline/compiler/config-loader.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: "next",
    documentIds: [],
    llmsTxt: "https://www.sanity.io/docs/llms.txt",
    name: "production",
    perspective: undefined,
    priorityDomain: "sanity.io",
    projectId: "3do82whm",
    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` doesn't consult). 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 || "next"),
        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 || "3do82whm"),
        // 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
 *   2. If config/sources exists, load and interpolate the named source
 *   3. Fall back to the built-in default (sanity.io production)
 *
 * @param name - Source name from config/sources. If omitted, uses DOC_SOURCE
 *               env var or the first source defined in config/sources.
 */
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 domain = extractHostname(envBaseUrl);
        log.debug("Source resolved from DOC_BASE_URL env override", {
            baseUrl: envBaseUrl,
            domain,
            hasOverrides: Boolean(overrides),
        });
        return {
            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 || "next"),
            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: domain,
            // 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 || "3do82whm"),
            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(),
        };
    }
    // Priority 2: Load from config/sources (TS, JS, YAML, or JSON)
    let rawFile;
    const loaded = tryLoadConfigFile("sources", PACKAGE_ROOT);
    if (loaded) {
        rawFile = loaded.data;
    }
    else {
        // No config file found — use built-in default
        log.debug("No config/sources found, falling back to built-in default", {
            defaultName: DEFAULT_SOURCE.name,
            defaultBaseUrl: DEFAULT_SOURCE.baseUrl,
        });
        console.log("  No config/sources found, using built-in default (sanity.io production)");
        return applyOverridesToDefault(overrides);
    }
    if (!rawFile?.sources || Object.keys(rawFile.sources).length === 0) {
        log.debug("config/sources is empty, falling back to built-in default");
        console.log("  config/sources is empty, using built-in default");
        return applyOverridesToDefault(overrides);
    }
    // Resolve which source to use
    const sourceName = 
    // oxlint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty strings should fall through to next candidate
    name || process.env.DOC_SOURCE || Object.keys(rawFile.sources)[0];
    log.debug("Resolving source from config/sources", {
        requestedName: name,
        resolvedName: sourceName,
        availableSources: Object.keys(rawFile.sources),
    });
    const rawEntry = rawFile.sources[sourceName];
    if (!rawEntry) {
        const available = Object.keys(rawFile.sources).join(", ");
        throw new Error('Source "' +
            sourceName +
            '" not found in config/sources. Available: ' +
            available);
    }
    // Interpolate ${{ VAR }} expressions
    const resolved = interpolate(rawEntry);
    // Validate after interpolation
    validateSource(sourceName, resolved);
    // Filter empty strings from YAML-interpolated allowedOrigins.
    // An interpolation like ${{ DOC_ALLOWED_ORIGIN | }} resolves to "",
    // Which would produce [""] — matching nothing and silently blocking all origins.
    const filteredOrigins = resolved.allowedOrigins?.filter(Boolean);
    log.debug("Source resolved from config/sources", {
        name: sourceName,
        baseUrl: resolved.baseUrl,
        dataset: resolved.dataset,
        projectId: resolved.projectId,
        hasOverrides: Boolean(overrides),
        hasAllowedOrigins: Boolean(filteredOrigins?.length),
        hasPerspective: Boolean(overrides?.perspective ?? process.env.SANITY_PERSPECTIVE),
    });
    return {
        allowedOrigins: filteredOrigins && filteredOrigins.length > 0
            ? filteredOrigins
            : undefined,
        baseUrl: resolved.baseUrl,
        dataset: resolved.dataset,
        documentIds: overrides?.documentIds ?? parseDocumentIdsEnv() ?? [],
        headers: mergeHeaders(resolved.headers, overrides?.headers ?? parseHeadersEnv()),
        llmsTxt: resolved.baseUrl + "/llms.txt",
        name: sourceName,
        // 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(resolved.baseUrl),
        projectId: resolved.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: [],
    };
}
/**
 * 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;
    }
}
/**
 * Merge headers from config/sources with DOC_HEADERS env var.
 * Env var headers take precedence over source-defined headers.
 * Returns undefined if both are empty.
 */
function mergeHeaders(sourceHeaders, envHeaders) {
    if (!sourceHeaders && !envHeaders) {
        return undefined;
    }
    if (!sourceHeaders) {
        return envHeaders;
    }
    if (!envHeaders) {
        return sourceHeaders;
    }
    return { ...sourceHeaders, ...envHeaders };
}
/**
 * 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;
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
function validateSource(name, raw) {
    if (!raw.baseUrl) {
        throw new Error('Source "' + name + '" is missing required field "baseUrl".');
    }
    if (!raw.projectId) {
        throw new Error('Source "' + name + '" is missing required field "projectId".');
    }
    if (!raw.dataset) {
        throw new Error('Source "' + name + '" is missing required field "dataset".');
    }
}
