/**
 * pipeline/mirror-repo-tasks.ts
 *
 * Mirrors repo-based TaskDefinitions into the Sanity Content Lake as
 * ailf.task documents with origin provenance. This makes the Content Lake
 * the universal registry — every task (native or repo-sourced) is visible
 * in Studio.
 *
 * The mirror is idempotent: deterministic document IDs + content hashing
 * means unchanged tasks are skipped. Changed tasks are upserted via
 * createOrReplace.
 *
 * @see docs/archive/exec-plans/tasks-as-content/phase-5-content-lake-mirroring.md
 */
import { createHash } from "crypto";
import { readFileSync } from "fs";
import { isIdRef, isPathRef, isPerspectiveRef, isSlugRef, } from "../_vendor/ailf-core/index.js";
import { normalizeTaskOwnership } from "../_vendor/ailf-shared/index.js";
import { ConsoleLogger } from "../adapters/loggers/index.js";
import { buildEditorialReference } from "../sanity/client.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Mirror repo tasks to the Content Lake.
 *
 * For each task:
 * 1. Compute deterministic document ID
 * 2. Compute content hash of the task definition
 * 3. Check if mirror document exists with same hash → skip if unchanged
 * 4. Resolve context doc slugs → Sanity references
 * 5. Auto-create feature areas if needed
 * 6. Upsert the ailf.task document with origin block
 */
export async function mirrorRepoTasks(options) {
    const { client, editorialClient, tasks, git, ownerTeam, dryRun = false, logger, } = options;
    const log = logger ?? new ConsoleLogger();
    const result = {
        total: tasks.length,
        upserted: 0,
        skipped: 0,
        areasCreated: [],
        unresolvedSlugs: [],
        errors: [],
    };
    if (tasks.length === 0)
        return result;
    // Batch-resolve all context doc slugs (slug refs only — other ref types
    // are stored without a resolved article reference for now). Slugs live on
    // `article` documents in the editorial dataset, so this must use the
    // editorial client. Without one, every slug ref stays unresolved.
    const allSlugs = [
        ...new Set(tasks.flatMap((t) => (t.context?.docs ?? []).filter(isSlugRef).map((d) => d.slug))),
    ];
    const slugToDocId = editorialClient
        ? await batchResolveDocSlugs(editorialClient, allSlugs)
        : new Map();
    if (!editorialClient && allSlugs.length > 0) {
        log.warn("  ⚠️  No editorial Sanity client provided — skipping slug→article resolution");
    }
    // Track unresolved slugs
    for (const slug of allSlugs) {
        if (!slugToDocId.has(slug)) {
            result.unresolvedSlugs.push(slug);
        }
    }
    // Ensure all feature areas exist. A newly created area is stamped with the
    // resolved owner-team reference (when one was passed) so the dashboard groups
    // the mirrored tasks under that team; existing areas are left untouched.
    const areas = [...new Set(tasks.map((t) => t.area ?? ""))];
    const teamRef = ownerTeam
        ? { _ref: ownerTeam.teamId, _type: "reference" }
        : undefined;
    const createdAreas = await ensureFeatureAreas(client, areas, dryRun, log, teamRef);
    result.areasCreated = createdAreas;
    // Fetch existing mirror document state for change detection + ownership check
    const mirrorIds = tasks.map((t) => mirrorDocId(git.owner, git.name, t.id));
    const existingDocState = await fetchExistingDocState(client, mirrorIds);
    // Mirror each task
    for (const task of tasks) {
        try {
            const docId = mirrorDocId(git.owner, git.name, task.id);
            const existing = existingDocState.get(docId);
            // Skip graduated tasks — ownership was changed to "studio"
            if (existing?.ownership === "studio") {
                log.info(`  ℹ️  Skipping "${task.id}" — graduated to Studio ownership`);
                result.skipped++;
                continue;
            }
            const contentHash = computeTaskHash(task);
            // Skip unchanged
            if (existing?.hash === contentHash) {
                result.skipped++;
                continue;
            }
            const doc = buildMirrorDocument(task, {
                contentHash,
                docId,
                existingAuthor: existing?.existingAuthor,
                git,
                slugToDocId,
            });
            // Ingestion guard (W-normalize-task-ownership): the persistence boundary
            // is the one place every synced doc passes through, so normalize
            // `ownership` here — a non-`{studio,repo}` value can never reach the
            // Content Lake via the sync path again. The sync default is `"repo"`
            // (synced tasks are read-only); a coercion is logged so the leak is
            // observable rather than silent.
            doc.ownership = normalizeTaskOwnership(doc.ownership, {
                fallback: "repo",
                onCoerce: ({ received }) => log.info(`  ⚠️  Coerced non-conforming ownership ${JSON.stringify(received)} → "repo" for "${task.id}"`),
            });
            if (dryRun) {
                log.info(`  [dry-run] Would upsert: ${docId}`);
                result.upserted++;
                continue;
            }
            await client.createOrReplace(doc);
            result.upserted++;
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            result.errors.push(`Failed to mirror "${task.id}": ${msg}`);
        }
    }
    return result;
}
// ---------------------------------------------------------------------------
// Detect git context from environment or CLI
// ---------------------------------------------------------------------------
/**
 * Detect git context from GitHub Actions environment variables,
 * falling back to git CLI commands.
 */
export async function detectGitContext(repoTasksPath) {
    // GitHub Actions provides these env vars
    const ghRepo = process.env.GITHUB_REPOSITORY; // "owner/name"
    const ghRef = process.env.GITHUB_REF ?? "";
    const ghSha = process.env.GITHUB_SHA ?? "";
    const ghHeadRef = process.env.GITHUB_HEAD_REF ?? "";
    if (ghRepo) {
        const [owner, name] = ghRepo.split("/");
        const branch = ghHeadRef || ghRef.replace("refs/heads/", "").replace("refs/tags/", "");
        // Extract author from GitHub Actions environment
        const author = detectGitHubActionsAuthor();
        return {
            repo: ghRepo,
            owner: owner ?? "unknown",
            name: name ?? "unknown",
            branch: branch || "unknown",
            commitSha: ghSha || "unknown",
            author,
        };
    }
    // Fallback: try git CLI
    const { execSync } = await import("child_process");
    try {
        const remote = execSync("git remote get-url origin", {
            encoding: "utf-8",
            cwd: repoTasksPath,
        }).trim();
        const branch = execSync("git rev-parse --abbrev-ref HEAD", {
            encoding: "utf-8",
            cwd: repoTasksPath,
        }).trim();
        const commitSha = execSync("git rev-parse HEAD", {
            encoding: "utf-8",
            cwd: repoTasksPath,
        }).trim();
        // Parse remote URL: https://github.com/owner/name.git or git@github.com:owner/name.git
        const match = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/) ??
            remote.match(/([^/]+)\/([^/.]+?)(?:\.git)?$/);
        const owner = match?.[1] ?? "unknown";
        const name = match?.[2] ?? "unknown";
        // Extract author from git log
        const author = detectGitCliAuthor(repoTasksPath, execSync);
        return {
            repo: `${owner}/${name}`,
            owner,
            name,
            branch,
            commitSha,
            author,
        };
    }
    catch {
        return {
            repo: "unknown/unknown",
            owner: "unknown",
            name: "unknown",
            branch: "unknown",
            commitSha: "unknown",
            author: {},
        };
    }
}
/**
 * Build a {@link GitContext} from the calling repository's metadata instead of
 * the runner environment.
 *
 * An external eval executes inside the `sanity-labs/ai-literacy-framework`
 * checkout, so `detectGitContext()` (which reads `GITHUB_REPOSITORY`) would
 * stamp every mirrored task as the framework repo — the live
 * `ailf.task.mirror.unknown.unknown.*` damage W0145 cited. Deriving from
 * `callerGit` instead yields correct provenance (`origin.repo = sanity-io/sdk`)
 * and a correctly namespaced mirror id (`ailf.task.mirror.sanity-io.sdk.<id>`).
 *
 * This is the mirror's counterpart to what `buildRunContext` already does for
 * the run context: prefer the caller envelope over server-environment detection
 * (D0037). The step falls back to `detectGitContext()` only when `callerGit` is
 * absent (the dogfood path).
 */
export function gitContextFromCaller(callerGit, envelope) {
    const [owner, name] = callerGit.repo.split("/");
    const author = {};
    const githubUsername = envelope?.executor?.githubActor ?? envelope?.owner?.individual;
    if (githubUsername)
        author.githubUsername = githubUsername;
    if (envelope?.executor?.name)
        author.gitName = envelope.executor.name;
    return {
        repo: callerGit.repo,
        owner: owner ?? "unknown",
        name: name ?? "unknown",
        branch: callerGit.branch || "unknown",
        commitSha: callerGit.sha || "unknown",
        author,
    };
}
// ---------------------------------------------------------------------------
// Author detection helpers
// ---------------------------------------------------------------------------
/**
 * Extract author info from GitHub Actions environment variables and
 * the webhook event payload (GITHUB_EVENT_PATH).
 */
function detectGitHubActionsAuthor() {
    const ghActor = process.env.GITHUB_ACTOR ?? undefined;
    const author = { githubUsername: ghActor };
    // Try to read richer author info from the event payload
    const eventPath = process.env.GITHUB_EVENT_PATH;
    if (eventPath) {
        try {
            const event = JSON.parse(readFileSync(eventPath, "utf-8"));
            // Push event: head_commit.author has name + email
            if (event.head_commit?.author) {
                author.gitName = event.head_commit.author.name ?? undefined;
                author.gitEmail = event.head_commit.author.email ?? undefined;
            }
            // PR event: pull_request.user.login is the PR author
            if (event.pull_request?.user?.login) {
                author.githubUsername = event.pull_request.user.login;
            }
        }
        catch {
            // Event payload parsing is best-effort — fall through with GITHUB_ACTOR only
        }
    }
    return author;
}
/**
 * Extract author info from git CLI (local fallback when not in GitHub Actions).
 */
function detectGitCliAuthor(cwd, execSyncFn) {
    try {
        const authorLine = execSyncFn('git log -1 --format="%an|%ae"', {
            encoding: "utf-8",
            cwd,
        })
            .toString()
            .trim();
        const [gitName, gitEmail] = authorLine.split("|");
        return {
            gitName: gitName || undefined,
            gitEmail: gitEmail || undefined,
        };
    }
    catch {
        return {};
    }
}
// ---------------------------------------------------------------------------
// Document ID scheme
// ---------------------------------------------------------------------------
/**
 * Deterministic mirror document ID.
 *
 * Format: ailf.task.mirror.<owner>.<repo>.<taskId>
 *
 * Dots in owner/repo/taskId are replaced with hyphens to avoid
 * Sanity document ID issues.
 */
export function mirrorDocId(owner, repo, taskId) {
    const sanitize = (s) => s.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
    return `ailf.task.mirror.${sanitize(owner)}.${sanitize(repo)}.${sanitize(taskId)}`;
}
// ---------------------------------------------------------------------------
// Content hashing
// ---------------------------------------------------------------------------
/**
 * Compute a content hash of a LiteracyTaskDefinition for change detection.
 *
 * Includes all fields that affect the mirror document. Excludes
 * runtime metadata like referenceSolution (filesystem path) since
 * that's not mirrored.
 */
export function computeTaskHash(task) {
    const payload = JSON.stringify({
        id: task.id,
        title: task.title,
        area: task.area,
        prompt: task.prompt,
        docs: task.context?.docs,
        docCoverage: task.docCoverage,
        assertions: task.assertions,
        baseline: task.baseline,
    });
    return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
// ---------------------------------------------------------------------------
// Batch slug resolution
// ---------------------------------------------------------------------------
/**
 * Resolve an array of document slugs to Sanity document IDs in a single query.
 */
async function batchResolveDocSlugs(client, slugs) {
    if (slugs.length === 0)
        return new Map();
    const query = `*[_type == "article" && slug.current in $slugs] {
    _id,
    "slug": slug.current
  }`;
    const results = await client.fetch(query, {
        slugs,
    });
    const map = new Map();
    for (const r of results) {
        map.set(r.slug, r._id);
    }
    return map;
}
// ---------------------------------------------------------------------------
// Feature area auto-creation
// ---------------------------------------------------------------------------
/**
 * Ensure ailf.featureArea documents exist for all referenced areas.
 * Returns the list of newly created area IDs.
 *
 * `teamRef`, when provided, is stamped onto the `team` field of areas this
 * call *creates* — so a mirrored external suite's auto-created areas reference
 * their owning team and the dashboard's `area->team->slug` projection groups
 * the tasks under it. Areas that already exist are never modified, so this can
 * never re-own a framework area or another team's area (the registration
 * conflict guard upstream catches that case and refuses before mirroring).
 */
async function ensureFeatureAreas(client, areas, dryRun, log, teamRef) {
    if (areas.length === 0)
        return [];
    // Check which areas already exist
    const existing = await client.fetch(`*[_type == "ailf.featureArea" && areaId.current in $areas].areaId.current`, { areas });
    const existingSet = new Set(existing);
    const missing = areas.filter((a) => !existingSet.has(a));
    if (missing.length === 0)
        return [];
    if (dryRun) {
        for (const area of missing) {
            log.info(`  [dry-run] Would create feature area: ${area}`);
        }
        return missing;
    }
    const transaction = client.transaction();
    for (const area of missing) {
        const docId = `ailf.featureArea.${area}`;
        transaction.createOrReplace({
            _id: docId,
            _type: "ailf.featureArea",
            areaId: { _type: "slug", current: area },
            description: area.charAt(0).toUpperCase() + area.slice(1).replace(/-/g, " "),
            ...(teamRef ? { team: teamRef } : {}),
        });
    }
    await transaction.commit();
    return missing;
}
/**
 * Find in-scope feature areas that already exist under a *different* team than
 * the one a registration would attribute them to. A non-empty result is a
 * registration conflict: mirroring would mis-attribute an area the framework
 * (or another team) already owns. Areas with no team, or already owned by
 * `ownerTeamId`, are not conflicts.
 *
 * This surfaces the "never re-team an existing area" guard as a caller-facing
 * error. `ensureFeatureAreas` only stamps a team on create, so the mirror
 * itself never re-teams; the step calls this first and aborts on a conflict so
 * the refusal reaches the caller instead of silently leaving the area as-is.
 *
 * `existingTeam` is the conflicting area's team *slug* (for the error message);
 * the conflict decision compares the team *document id* (`team._ref`) against
 * `ownerTeamId`, which is the stable identity even if the slug were to change.
 */
export async function findAreaTeamConflicts(client, areas, ownerTeamId) {
    if (areas.length === 0)
        return [];
    const rows = await client.fetch(`*[_type == "ailf.featureArea" && areaId.current in $areas]{
      "area": areaId.current,
      "teamRef": team._ref,
      "teamSlug": team->slug.current
    }`, { areas });
    return rows
        .filter((r) => r.teamRef !== undefined && r.teamRef !== ownerTeamId)
        .map((r) => ({ area: r.area, existingTeam: r.teamSlug ?? r.teamRef }));
}
/**
 * Fetch existing mirror documents' ownership and content hashes.
 *
 * The ownership field determines whether the mirror step is allowed to
 * update the document:
 * - `"repo"` or absent → safe to update (active mirror)
 * - `"studio"` → skip (graduated to Studio ownership)
 *
 * The hash is stored in origin.contentHash for change detection.
 */
async function fetchExistingDocState(client, docIds) {
    if (docIds.length === 0)
        return new Map();
    const query = `*[_id in $ids] {
    _id,
    "hash": origin.contentHash,
    ownership,
    "existingAuthor": origin.author
  }`;
    const results = await client.fetch(query, { ids: docIds });
    const map = new Map();
    for (const r of results) {
        map.set(r._id, {
            hash: r.hash ?? undefined,
            ownership: r.ownership ?? undefined,
            existingAuthor: r.existingAuthor ?? undefined,
        });
    }
    return map;
}
// ---------------------------------------------------------------------------
// Build mirror document
// ---------------------------------------------------------------------------
/** @internal Exported for testing — not part of the public API. */
export function buildMirrorDocument(task, opts) {
    const { contentHash, docId, existingAuthor, git, slugToDocId } = opts;
    // Build context docs with resolved references and correct refType.
    // Each ref type gets the appropriate resolution fields set on the
    // mirror document so Studio can display them correctly.
    const contextDocs = (task.context?.docs ?? []).map((ref, i) => {
        const base = { _key: `cd${i}`, reason: ref.reason ?? "" };
        if (isSlugRef(ref)) {
            const resolvedId = slugToDocId.get(ref.slug);
            // When a slug resolves to a document, store as "id" ref with
            // the resolved article reference. When unresolved, store as
            // "slug" so Studio knows the resolution strategy even if the
            // article doesn't exist yet. The `doc` reference is a Cross
            // Dataset Reference per D0043 — `ailf.task` lives in the AILF
            // private dataset and `article` lives in the editorial dataset.
            return {
                ...base,
                refType: resolvedId ? "id" : "slug",
                ...(resolvedId ? { doc: buildEditorialReference(resolvedId) } : {}),
            };
        }
        if (isPathRef(ref)) {
            return { ...base, refType: "path", path: ref.path };
        }
        if (isIdRef(ref)) {
            return {
                ...base,
                refType: "id",
                ...(ref.id
                    ? { doc: buildEditorialReference(ref.id), docId: ref.id }
                    : {}),
            };
        }
        if (isPerspectiveRef(ref)) {
            return {
                ...base,
                refType: "perspective",
                perspective: ref.perspective,
            };
        }
        // Unknown ref type — store with reason only
        return base;
    });
    // Build assertions
    const assertArray = (task.assertions ?? []).map((a, i) => {
        const entry = {
            _key: `a${i}`,
            type: a.type,
        };
        if (a.type === "llm-rubric" && "template" in a) {
            entry.template = a.template;
            if ("criteria" in a &&
                Array.isArray(a.criteria)) {
                entry.criteria = a.criteria;
            }
        }
        if ("value" in a && a.value !== undefined) {
            entry.value =
                typeof a.value === "string" ? a.value : JSON.stringify(a.value);
        }
        if ("threshold" in a &&
            a.threshold !== undefined) {
            entry.threshold = a.threshold;
        }
        // Assertion-level weight was removed (D0083) — not mirrored to the
        // Content Lake document; dimension profiles are the sole scoring axis.
        return entry;
    });
    // Determine the source file path (best-effort from task's area)
    const area = task.area ?? "";
    const filePath = `.ailf/tasks/${area}.yaml`;
    return {
        _id: docId,
        _type: "ailf.task",
        ownership: "repo",
        status: task.status ?? "active",
        assertions: assertArray,
        contextDocs,
        title: task.title,
        docCoverage: task.docCoverage ?? false,
        area: {
            _ref: `ailf.featureArea.${area}`,
            _type: "reference",
        },
        id: { _type: "slug", current: task.id },
        origin: {
            // Existing provenance fields
            branch: git.branch,
            commitSha: git.commitSha,
            contentHash,
            lastSyncedAt: new Date().toISOString(),
            path: filePath,
            repo: git.repo,
            repoName: git.name,
            repoOwner: git.owner,
            type: "repo",
            // Authorship: author is write-once (preserve existing), lastEditor always updates
            author: existingAuthor ?? git.author,
            lastEditor: git.author,
        },
        promptText: task.prompt?.text ?? "",
        ...(task.baseline
            ? {
                baseline: {
                    ...(task.baseline.enabled !== undefined
                        ? { enabled: task.baseline.enabled }
                        : {}),
                    ...(task.baseline.rubric ? { rubric: task.baseline.rubric } : {}),
                },
            }
            : {}),
    };
}
