/**
 * api-client/build-request.ts — Build a PipelineRequest from local state.
 *
 * Translates the user's local `.ailf/tasks/` directory and resolved CLI
 * config into a `PipelineRequest` payload ready for the API. Reuses the
 * existing `RepoTaskSource` adapter for YAML parsing — no duplicated
 * parsing logic.
 *
 * The built request is validated against `PipelineRequestSchema` before
 * return (fail-fast on malformed payloads).
 *
 * @see packages/core/src/schemas/pipeline-request.ts — shared schema
 * @see packages/eval/src/adapters/task-sources/repo-task-source.ts
 */
import { existsSync } from "fs";
import { resolve } from "path";
import { PLACEHOLDER_OWNER_TEAM, PipelineRequestSchema, } from "../../_vendor/ailf-core/index.js";
import { LEGACY_EVAL_MODE_ALIASES, isRunClassification, } from "../../_vendor/ailf-shared/index.js";
import { RepoTaskSource } from "../task-sources/repo-task-source.js";
const LEGACY_LITERACY_VARIANT_SET = new Set(LEGACY_EVAL_MODE_ALIASES);
/**
 * Resolve a raw `config.mode` (which may be a CLI literacy variant such as
 * `"baseline"` or `"full"`) to the canonical task-level mode that appears on
 * `GeneralizedTaskDefinition.mode`. Literacy variants all map to `"literacy"`.
 */
function resolveCanonicalTaskMode(configMode) {
    return LEGACY_LITERACY_VARIANT_SET.has(configMode) ? "literacy" : configMode;
}
/**
 * Thrown when `buildRemoteRequest` can't find any runnable tasks.
 *
 * The CLI catches this separately from ZodError so it can print the
 * message without an accompanying stack trace — the message is already
 * the whole story for the user.
 */
export class NoRunnableTasksError extends Error {
    name = "NoRunnableTasksError";
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Build a PipelineRequest from local tasks and config.
 *
 * 1. Loads tasks via RepoTaskSource (validates via Zod automatically)
 * 2. Applies filter options (areas, task IDs) from config
 * 3. Maps tasks to inline format
 * 4. Builds the full PipelineRequest
 * 5. Validates against PipelineRequestSchema
 *
 * Throws on validation errors (from RepoTaskSource or PipelineRequestSchema).
 */
export async function buildRemoteRequest(options) {
    const { tasksDir, config } = options;
    // 1. Resolve the task payload.
    //
    //    Content Lake source (SDK-1815): the server reads `ailf.task` documents,
    //    so we send `taskSource: { type: "content-lake" }` with no inline tasks
    //    and require no local `.ailf/tasks/`. Task count is resolved server-side.
    //
    //    Repo source (default): load + validate local tasks and inline them.
    //    `config.mode` may be a literacy variant (baseline/agentic/full/observed)
    //    — those all map to task mode "literacy". Other modes match 1:1.
    const raw = {};
    let taskCount = 0;
    if (config.taskSource?.type === "content-lake") {
        raw.taskSource = { type: "content-lake" };
    }
    else {
        if (!tasksDir) {
            throw new Error("buildRemoteRequest: tasksDir is required for the repo task source");
        }
        const taskSource = new RepoTaskSource(tasksDir);
        const filterOptions = buildFilterOptions(config);
        const allTasks = await taskSource.loadTasks(filterOptions);
        const taskModeFilter = config.mode
            ? resolveCanonicalTaskMode(config.mode)
            : undefined;
        const tasks = taskModeFilter
            ? allTasks.filter((t) => t.mode === taskModeFilter)
            : allTasks;
        if (tasks.length === 0) {
            throw await emptyTasksError({
                taskSource,
                tasksDir,
                config,
                filterOptions,
                taskModeFilter,
            });
        }
        raw.taskMode = "inline";
        raw.inlineTasks = tasks.map(taskToInlineFormat);
        taskCount = tasks.length;
    }
    // Mode + variant — send both when set so the server sees the caller's
    // canonical intent. Legacy aliases ("full", "baseline", …) are accepted
    // by `PipelineRequestSchema.mode` for back-compat but the CLI now emits
    // the canonical form (`mode: "literacy"` + explicit `variant`).
    if (config.mode)
        raw.mode = config.mode;
    if (config.variant)
        raw.variant = config.variant;
    // Debug
    if (config.debug?.enabled) {
        raw.debug = config.debug;
    }
    // Scoping
    if (config.areas?.length)
        raw.areas = config.areas;
    if (config.tasks?.length)
        raw.tasks = config.tasks;
    if (config.changedDocs?.length)
        raw.changedDocs = config.changedDocs;
    // Document-reference scoping (D0090). Forward the additive context-injection
    // list and the `--sanity-document` fetch override so `--remote` carries the
    // full document scope a local run does (these were previously dropped).
    if (config.injectContextDocs?.length) {
        raw.injectContextDocs = config.injectContextDocs;
    }
    if (config.sanityDocumentArgs?.length) {
        raw.documentIds = config.sanityDocumentArgs;
    }
    // Source
    if (config.source)
        raw.source = config.source;
    // Comparison
    if (config.compareEnabled)
        raw.compare = true;
    if (config.compareThreshold !== undefined) {
        raw.compareThreshold = config.compareThreshold;
    }
    // Publishing
    if (config.publishEnabled !== undefined)
        raw.publish = config.publishEnabled;
    if (config.publishTag)
        raw.publishTag = config.publishTag;
    // Concurrency
    if (config.concurrency)
        raw.concurrency = config.concurrency;
    // Source overrides
    if (config.datasetOverride)
        raw.dataset = config.datasetOverride;
    if (config.projectIdOverride)
        raw.projectId = config.projectIdOverride;
    if (config.perspectiveOverride)
        raw.perspective = config.perspectiveOverride;
    if (config.studioOriginOverride) {
        raw.studioOrigin = config.studioOriginOverride;
    }
    // A repo-config `source.baseUrl` rides the existing `urls` channel: the
    // server maps request.urls -> ResolvedConfig.urls -> source baseUrl
    // (configToSourceOverrides), mirroring the local path.
    if (config.baseUrlOverride && !raw.urls) {
        raw.urls = [config.baseUrlOverride];
    }
    // Advanced
    if (config.graderContext) {
        raw.graderContext = config.graderContext;
    }
    if (config.graderReplications) {
        raw.graderReplications = config.graderReplications;
    }
    if (config.borderlineReplications) {
        raw.borderlineReplications = config.borderlineReplications;
    }
    if (config.gapAnalysisEnabled)
        raw.gapAnalysis = true;
    if (config.noRemoteCache)
        raw.noRemoteCache = true;
    // Caller git metadata — auto-detect from CI environment variables.
    // When running via `ailf run --remote` in a GitHub Actions workflow,
    // the GITHUB_* env vars identify the *calling* repo (not the AILF core
    // repo). This ensures report provenance attributes to the right repo.
    const callerGit = detectCallerGit();
    if (callerGit)
        raw.callerGit = callerGit;
    // D0037 caller envelope — merge CLI flags + env vars and attach each
    // populated field. Flags override env. Skipped fields are omitted so
    // the server applies its own defaults.
    Object.assign(raw, buildCallerEnvelope(config));
    // 4. Validate the assembled request
    const parsed = PipelineRequestSchema.parse(raw);
    return { request: parsed, taskCount };
}
// ---------------------------------------------------------------------------
// Task directory resolution
// ---------------------------------------------------------------------------
/**
 * Find the tasks directory, trying in order:
 * 1. Explicit path (from --repo-tasks-path)
 * 2. .ailf/tasks/ relative to rootDir
 *
 * Returns the resolved path or throws if not found.
 */
export function resolveTasksDir(rootDir, explicitPath) {
    if (explicitPath) {
        if (!existsSync(explicitPath)) {
            throw new Error(`Tasks directory not found: ${explicitPath}\n` +
                "  Provide a valid path via --repo-tasks-path");
        }
        return explicitPath;
    }
    const defaultPath = resolve(rootDir, ".ailf", "tasks");
    if (existsSync(defaultPath)) {
        return defaultPath;
    }
    throw new Error("No tasks directory found.\n" +
        `  Looked for: ${defaultPath}\n` +
        "  Remote mode reads task definitions from .ailf/tasks/ in your repo.\n" +
        "  Run 'ailf init' to create example tasks, or specify a path:\n" +
        "  ailf eval --remote --repo-tasks-path ./my-tasks/");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
 * Convert a `GeneralizedTaskDefinition` to the wire format expected by the
 * pipeline's `inlineTasks` array. The canonical shape *is* the wire format —
 * `CanonicalTaskSchema` (the receiving validator in
 * `packages/eval/src/adapters/task-sources/repo-schemas.ts`) mirrors this
 * type per-variant via `.strict()`, so any reshaping here would either be a
 * no-op or rejected on the receiver. The helper exists as a typed boundary
 * between the typed task array and `PipelineRequestSchema.inlineTasks`'s
 * `Record<string, unknown>[]` shape.
 */
function taskToInlineFormat(task) {
    return task;
}
/**
 * Build a descriptive error when the task list is empty after filtering.
 *
 * Loads the full task list a second time with `includeDrafts: true` so we
 * can distinguish the two common failure modes:
 *
 * 1. Every discovered task is non-active (`status: "draft"` from
 *    `ailf init` scaffolding, or `status: "paused"`). Tell the user how
 *    to opt a task in.
 * 2. The tasks directory is genuinely empty for this filter combination.
 *    Echo the filters back so the mismatch is obvious.
 *
 * The directory-missing and file-missing cases are already surfaced
 * earlier by `RepoTaskSource.loadTasks()`, so we never reach this helper
 * for those.
 */
async function emptyTasksError(args) {
    const { taskSource, tasksDir, config, filterOptions, taskModeFilter } = args;
    // Re-load without the status gate to categorize what got filtered.
    let relaxed = [];
    try {
        relaxed = await taskSource.loadTasks({
            ...(filterOptions ?? {}),
            includeDrafts: true,
        });
    }
    catch {
        // Fall through to the generic message if re-loading fails for any
        // reason (e.g. directory removed mid-run).
    }
    const modeMatched = taskModeFilter
        ? relaxed.filter((t) => t.mode === taskModeFilter)
        : relaxed;
    const drafts = modeMatched.filter((t) => (t.status ?? "active") === "draft");
    const paused = modeMatched.filter((t) => t.status === "paused");
    const filtersBlock = (config.areas?.length
        ? `  Area filter: ${config.areas.join(", ")}\n`
        : "") +
        (config.tasks?.length
            ? `  Task filter: ${config.tasks.join(", ")}\n`
            : "") +
        (config.tags?.length ? `  Tag filter: ${config.tags.join(", ")}\n` : "") +
        (taskModeFilter ? `  Mode filter: ${taskModeFilter}\n` : "");
    if (modeMatched.length === 0) {
        return new NoRunnableTasksError("No tasks matched your filters.\n" +
            `  Tasks directory: ${tasksDir}\n` +
            filtersBlock +
            "  Check that your .ailf/tasks/ YAML or .task.ts files define tasks\n" +
            "  matching these filters.");
    }
    // All matched tasks were excluded by the status gate.
    const draftIds = drafts.map((t) => t.id);
    const pausedIds = paused.map((t) => t.id);
    const draftSample = draftIds.slice(0, 3).join(", ");
    const draftMore = draftIds.length > 3 ? `, +${draftIds.length - 3} more` : "";
    const pausedSample = pausedIds.slice(0, 3).join(", ");
    const pausedMore = pausedIds.length > 3 ? `, +${pausedIds.length - 3} more` : "";
    const lines = [];
    lines.push("No runnable tasks after applying filters.");
    lines.push(`  Tasks directory: ${tasksDir}`);
    if (filtersBlock)
        lines.push(filtersBlock.trimEnd());
    if (drafts.length > 0) {
        lines.push(`  ${drafts.length} task(s) skipped because status: "draft": ${draftSample}${draftMore}`);
    }
    if (paused.length > 0) {
        lines.push(`  ${paused.length} task(s) skipped because status: "paused": ${pausedSample}${pausedMore}`);
    }
    lines.push("");
    lines.push("  To run one of these anyway, either:");
    if (drafts.length > 0) {
        lines.push(`    • Change the task's status field from "draft" to "active", or`);
        lines.push(`    • Target it explicitly: --task ${drafts[0]?.id ?? "<id>"}`);
    }
    else if (paused.length > 0) {
        lines.push(`    • Target it explicitly by id: --task ${paused[0]?.id ?? "<id>"}, or`);
        lines.push(`    • Flip its status from "paused" to "active"`);
    }
    lines.push("  Tasks scaffolded by `ailf init` ship as drafts so you can edit");
    lines.push("  them before they start contributing to your literacy score.");
    return new NoRunnableTasksError(lines.join("\n"));
}
function buildFilterOptions(config) {
    const areas = config.areas?.length ? config.areas : undefined;
    const taskIds = config.tasks?.length ? config.tasks : undefined;
    const tags = config.tags?.length ? config.tags : undefined;
    if (!areas && !taskIds && !tags)
        return undefined;
    return { areas, taskIds, tags };
}
/**
 * Build the D0037 caller envelope payload from CLI flags + env vars.
 *
 * Precedence, highest first:
 *   1. Explicit CLI flag (--classification, --owner-team, --purpose, …)
 *   2. Env var (AILF_CLASSIFICATION, AILF_OWNER_TEAM, AILF_PURPOSE, …)
 *   3. Omit — server applies its own defaults (ad-hoc / unknown).
 *
 * Labels are additive: --label values concatenate with AILF_LABELS.
 *
 * `executor` is always set on remote submissions because we know the
 * invocation is a user-driven CLI call. Surface defaults to `"cli"`
 * unless AILF_EXECUTOR_SURFACE explicitly overrides; name falls back to
 * GITHUB_ACTOR when available.
 *
 * Returns partial `PipelineRequest` fields only. Omits any key whose
 * source (flag + env) was unset.
 */
export function buildCallerEnvelope(config) {
    const out = {};
    // Classification: flag > env. Validated against the closed enum.
    const rawClassification = config.classificationOption ??
        process.env.AILF_CLASSIFICATION?.trim() ??
        undefined;
    if (rawClassification) {
        if (isRunClassification(rawClassification)) {
            out.classification = rawClassification;
        }
        else {
            // Surface the invalid value so downstream Zod validation gives a
            // clear error message pointing at the flag, not the inner enum.
            out.classification =
                rawClassification;
        }
    }
    // Owner: flag > env. Team required, individual optional.
    // W0143: drop the init-template placeholder — when a consumer hasn't
    // filled in their team slug yet we treat it as unset rather than
    // shipping the literal `<REPLACE-WITH-YOUR-TEAM-SLUG>` across the wire
    // and into the report's Provenance card.
    const rawTeam = config.ownerTeamOption ?? process.env.AILF_OWNER_TEAM?.trim() ?? undefined;
    const team = rawTeam === PLACEHOLDER_OWNER_TEAM ? undefined : rawTeam;
    const individual = config.ownerIndividualOption ??
        process.env.AILF_OWNER_INDIVIDUAL?.trim() ??
        process.env.GITHUB_ACTOR?.trim() ??
        undefined;
    if (team) {
        out.owner = individual ? { team, individual } : { team };
    }
    // Purpose: flag > env.
    const purpose = config.purposeOption ?? process.env.AILF_PURPOSE?.trim() ?? undefined;
    if (purpose)
        out.purpose = purpose;
    // Labels: flag AND env are additive (dedup + trim).
    const flagLabels = config.labelOptions ?? [];
    const envLabels = (process.env.AILF_LABELS ?? "")
        .split(",")
        .map((s) => s.trim())
        .filter(Boolean);
    const mergedLabels = Array.from(new Set([...envLabels, ...flagLabels]));
    if (mergedLabels.length > 0)
        out.labels = mergedLabels;
    // Executor: always set on remote submissions — we know this is a CLI
    // user. Only omit when absolutely nothing identifying is available.
    const surfaceEnv = process.env.AILF_EXECUTOR_SURFACE?.trim();
    const surface = surfaceEnv === "studio" || surfaceEnv === "api" ? surfaceEnv : "cli";
    const githubActor = process.env.GITHUB_ACTOR?.trim() || undefined;
    const nameFromIndividual = config.ownerIndividualOption ??
        process.env.AILF_OWNER_INDIVIDUAL?.trim() ??
        undefined;
    const executorName = githubActor ?? nameFromIndividual;
    const executor = {
        type: "user",
        surface,
    };
    if (executorName)
        executor.name = executorName;
    if (githubActor)
        executor.githubActor = githubActor;
    out.executor = executor;
    return out;
}
/**
 * Auto-detect caller git metadata from GitHub Actions environment variables.
 *
 * When the CLI runs in a calling repo's CI (via `npx @sanity/ailf run
 * --remote`), the GITHUB_* env vars reflect that repo — not the AILF core
 * repo. We capture them here so the API can carry them through to report
 * provenance.
 *
 * Returns undefined when not running in GitHub Actions.
 */
function detectCallerGit() {
    const repo = process.env.GITHUB_REPOSITORY;
    if (!repo)
        return undefined;
    const sha = process.env.GITHUB_SHA;
    const ref = process.env.GITHUB_REF ?? "";
    // For PRs, GITHUB_HEAD_REF is the source branch name (e.g., "fix/docs").
    // For pushes, GITHUB_REF_NAME is the branch (e.g., "main").
    const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME;
    // Extract PR number from GITHUB_REF (refs/pull/123/merge)
    const prMatch = ref.match(/^refs\/pull\/(\d+)\//);
    const prNumber = prMatch ? parseInt(prMatch[1], 10) : undefined;
    return {
        repo,
        ...(sha ? { sha } : {}),
        ...(branch ? { branch } : {}),
        ...(prNumber ? { prNumber } : {}),
    };
}
