/**
 * One-shot migration: synthesize `components: []` (and inline `workflows:`)
 * for legacy single-repo projects.
 *
 * See docs/design/multi-repo-components.md §1.3.
 *
 * Idempotent: if `components:` is already present, this is a no-op. The
 * migration runs once per project at server boot, before any pool fill.
 *
 * Behavior:
 *   1. Read `<configDir>/project.yaml`.
 *   2. If `components:` already present → log nothing and return.
 *   3. Build a one-element `components[]`:
 *        - name: project's name from the registry (NOT "default")
 *        - repo: "."
 *        - worktreeSetupCommand: legacy `worktree_setup_command` if non-empty
 *        - commands: { build, test, check, unit, e2e, ...other *_command }
 *      Drop empty/whitespace values.
 *   4. Move any `<configDir>/workflows/*.yaml` files into the inline
 *      `workflows:` block, then `rm -rf` the workflows dir.
 *   5. Atomic write (tmp + rename). Log `[migrate] project.yaml v2: <name>`.
 */
import fs from "node:fs";
import path from "node:path";
import yaml from "yaml";
/** Legacy top-level QA keys that move into `components[*].config[]`. */
const LEGACY_QA_KEYS = [
    "qa_start_command",
    "qa_build_command",
    "qa_health_check",
    "qa_browser_entry",
    "qa_env",
    "qa_max_duration_minutes",
    "qa_max_scenarios",
];
function shellQuoteSingle(v) {
    // Single-quote with internal '\'' escape for embedded quotes.
    return `'${v.replace(/'/g, `'\\''`)}'`;
}
/** Move legacy top-level qa_* keys onto a target component's `config:` map.
 *  Inlines `qa_env` entries into `qa_start_command` (single-quoted shell-escape).
 *  Idempotent: returns `{ changed: false }` when no legacy keys are present. */
function migrateLegacyQaToComponent(raw, components, projectName) {
    // Detect every legacy key that exists on `raw` *at all* — even empty
    // strings or empty objects — so they get cleaned up. Acceptance: no
    // top-level qa_* after boot. We split detection from emission below.
    const keysOnRaw = LEGACY_QA_KEYS.filter(k => k in raw);
    if (keysOnRaw.length === 0)
        return { changed: false };
    // Subset that should actually be emitted into the target component's
    // `config:` map (non-empty values only).
    const present = keysOnRaw.filter(k => {
        const v = raw[k];
        if (v === undefined || v === null)
            return false;
        if (typeof v === "string" && v.length === 0)
            return false;
        if (typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0)
            return false;
        return true;
    });
    // If there are only empty legacy keys (nothing to emit), still delete
    // them so the on-disk shape is clean.
    if (present.length === 0) {
        for (const k of keysOnRaw)
            delete raw[k];
        return { changed: true };
    }
    // Pick target component:
    //   (1) component referenced by the first agent-qa step in any inline workflow
    //   (2) else component whose `name === projectName`
    //   (3) else components[0]
    const wfs = (raw.workflows && typeof raw.workflows === "object" && !Array.isArray(raw.workflows))
        ? raw.workflows
        : {};
    let targetName;
    outer: for (const wf of Object.values(wfs)) {
        for (const g of wf.gates ?? []) {
            for (const s of g.verify ?? []) {
                if (s.type === "agent-qa" && typeof s.component === "string" && s.component) {
                    // Verify the referenced component actually exists; otherwise fall through.
                    if (components.some(c => typeof c.name === "string" && c.name === s.component)) {
                        targetName = s.component;
                        break outer;
                    }
                }
            }
        }
    }
    if (!targetName) {
        const byName = components.find(c => typeof c.name === "string" && c.name === projectName);
        if (byName)
            targetName = byName.name;
    }
    let usedFallback = false;
    if (!targetName) {
        const first = components[0];
        if (first && typeof first.name === "string") {
            targetName = first.name;
            usedFallback = true;
        }
    }
    if (!targetName)
        return { changed: false }; // genuinely no components
    if (usedFallback) {
        console.warn(`[migrate] qa_* settings: no agent-qa step or name-match found in ${projectName}; falling back to components[0] = "${targetName}"`);
    }
    const target = components.find(c => c.name === targetName);
    if (!target)
        return { changed: false };
    const cfg = (target.config && typeof target.config === "object" && !Array.isArray(target.config))
        ? { ...target.config } : {};
    // Compose qa_start_command with qa_env inlined.
    let startCmd = typeof raw.qa_start_command === "string" ? raw.qa_start_command : "";
    const env = (raw.qa_env && typeof raw.qa_env === "object" && !Array.isArray(raw.qa_env))
        ? raw.qa_env : {};
    const envPrefix = Object.entries(env)
        .filter(([, v]) => v !== undefined && v !== null)
        .map(([k, v]) => `${k}=${shellQuoteSingle(String(v))}`)
        .join(" ");
    if (envPrefix && startCmd)
        startCmd = `${envPrefix} ${startCmd}`;
    else if (envPrefix && !startCmd)
        startCmd = envPrefix;
    if (startCmd)
        cfg.qa_start_command = startCmd;
    for (const k of ["qa_build_command", "qa_health_check", "qa_browser_entry"]) {
        const v = raw[k];
        if (typeof v === "string" && v.length > 0)
            cfg[k] = v;
    }
    for (const k of ["qa_max_duration_minutes", "qa_max_scenarios"]) {
        const v = raw[k];
        if (typeof v === "number" && Number.isFinite(v))
            cfg[k] = String(Math.trunc(v));
        else if (typeof v === "string" && /^\d+$/.test(v.trim()))
            cfg[k] = v.trim();
    }
    if (Object.keys(cfg).length > 0)
        target.config = cfg;
    for (const k of LEGACY_QA_KEYS)
        delete raw[k];
    return { changed: true };
}
/** Map legacy `*_command` key → component command name. */
const LEGACY_KEY_MAP = {
    build_command: "build",
    test_command: "test",
    typecheck_command: "check",
    test_unit_command: "unit",
    test_e2e_command: "e2e",
    lint_command: "lint",
    format_command: "format",
};
function isNonEmpty(v) {
    return typeof v === "string" && v.trim().length > 0;
}
export function migrateProjectYaml(opts) {
    const yamlFile = path.join(opts.configDir, "project.yaml");
    const workflowsDir = path.join(opts.configDir, "workflows");
    let raw = {};
    let fileExisted = false;
    if (fs.existsSync(yamlFile)) {
        fileExisted = true;
        try {
            const parsed = yaml.parse(fs.readFileSync(yamlFile, "utf-8"));
            if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
                raw = parsed;
            }
        }
        catch (err) {
            console.error("[migrate] Failed to parse project.yaml; aborting migration:", err);
            return { migrated: false };
        }
    }
    // Idempotent: skip the components synthesis pass if components: already present.
    // BUT we may still need to seed default workflows for projects that were
    // migrated by an earlier server build (which left them with components but no
    // workflows, since `defaults/workflows/*.yaml` was the runtime fallback). See
    // Issue 1 of the multi-repo follow-up — without this, every existing project
    // loses access to general/feature/bug-fix/quick-fix after Follow-up A.
    if (Array.isArray(raw.components)) {
        return maybeSeedWorkflowsOnly({ raw, yamlFile, configDir: opts.configDir, projectName: opts.projectName });
    }
    // Bail out if there is genuinely nothing to migrate AND no workflows dir.
    const hasLegacyCommands = Object.keys(LEGACY_KEY_MAP).some(k => isNonEmpty(raw[k]))
        || Object.keys(raw).some(k => k.endsWith("_command") && k !== "worktree_setup_command" && !LEGACY_QA_KEYS.includes(k) && isNonEmpty(raw[k]));
    const hasWorktreeHook = isNonEmpty(raw.worktree_setup_command);
    const hasWorkflowsDir = fs.existsSync(workflowsDir) && fs.statSync(workflowsDir).isDirectory();
    if (!fileExisted && !hasWorkflowsDir) {
        // No project.yaml at all and no workflows dir — nothing to do.
        return { migrated: false };
    }
    // Build the default component named after the project.
    const commands = {};
    for (const [legacyKey, newKey] of Object.entries(LEGACY_KEY_MAP)) {
        const v = raw[legacyKey];
        if (isNonEmpty(v))
            commands[newKey] = v.trim();
    }
    // Pass-through: any remaining `*_command` keys we don't recognize get
    // their `_command` suffix stripped and stored under that name. This
    // preserves user-defined extensions to the legacy schema.
    for (const k of Object.keys(raw)) {
        if (!k.endsWith("_command"))
            continue;
        if (k in LEGACY_KEY_MAP)
            continue;
        // Skip project-level fields that happen to end in _command.
        if (k === "worktree_setup_command")
            continue;
        if (LEGACY_QA_KEYS.includes(k))
            continue; // QA keys move to component config
        const v = raw[k];
        if (!isNonEmpty(v))
            continue;
        const newKey = k.slice(0, -"_command".length);
        if (newKey && !(newKey in commands))
            commands[newKey] = v.trim();
    }
    const component = {
        name: opts.projectName,
        repo: ".",
    };
    if (hasWorktreeHook)
        component.worktree_setup_command = raw.worktree_setup_command.trim();
    if (Object.keys(commands).length > 0)
        component.commands = commands;
    const next = { ...raw, components: [component] };
    // Strip legacy command keys so the file shape is clean post-migration.
    for (const k of Object.keys(LEGACY_KEY_MAP))
        delete next[k];
    for (const k of Object.keys(next)) {
        if (k.endsWith("_command")
            && k !== "worktree_setup_command"
            && !LEGACY_QA_KEYS.includes(k)) {
            delete next[k];
        }
    }
    // `worktree_setup_command` moved onto the component.
    delete next.worktree_setup_command;
    // Migrate `<configDir>/workflows/*.yaml` files into the inline block.
    let workflowsMigrated = 0;
    let workflowsDirRemoved = false;
    const preExistingInlineWorkflows = (raw.workflows && typeof raw.workflows === "object" && !Array.isArray(raw.workflows))
        ? { ...raw.workflows }
        : {};
    const inlineWorkflows = { ...preExistingInlineWorkflows };
    if (hasWorkflowsDir) {
        const entries = fs.readdirSync(workflowsDir);
        for (const entry of entries) {
            if (!entry.endsWith(".yaml") && !entry.endsWith(".yml"))
                continue;
            const id = entry.replace(/\.ya?ml$/, "");
            const fp = path.join(workflowsDir, entry);
            try {
                const parsed = yaml.parse(fs.readFileSync(fp, "utf-8"));
                if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
                    inlineWorkflows[id] = parsed;
                    workflowsMigrated++;
                }
            }
            catch (err) {
                console.warn(`[migrate] Failed to parse workflow ${fp}:`, err);
            }
        }
        // Remove the workflows directory now that its contents are inlined.
        try {
            fs.rmSync(workflowsDir, { recursive: true, force: true });
            workflowsDirRemoved = true;
        }
        catch (err) {
            console.warn("[migrate] Failed to remove workflows directory:", err);
        }
    }
    // No default-workflow seeding. Workflows are the project assistant's
    // responsibility; a project may legitimately have zero workflows.
    if (Object.keys(inlineWorkflows).length > 0) {
        next.workflows = inlineWorkflows;
    }
    // Migrate legacy top-level qa_* keys onto the chosen component's config map.
    // Done AFTER inlineWorkflows is wired in so the agent-qa step's `component:` field
    // (if any) is visible to the target-picker.
    migrateLegacyQaToComponent(next, [component], opts.projectName);
    // Atomic write.
    try {
        fs.mkdirSync(opts.configDir, { recursive: true });
        const tmp = yamlFile + ".tmp";
        fs.writeFileSync(tmp, yaml.stringify(next), "utf-8");
        fs.renameSync(tmp, yamlFile);
    }
    catch (err) {
        console.error("[migrate] Failed to write migrated project.yaml:", err);
        return { migrated: false };
    }
    console.log(`[migrate] project.yaml v2: ${opts.projectName}`);
    if (!hasLegacyCommands && !hasWorktreeHook && workflowsMigrated === 0) {
        // Bare-bones write; user will likely need to re-run setup.
        console.warn(`[migrate] project ${opts.projectName} had no legacy commands or workflows; wrote a data-only default component.`);
    }
    return {
        migrated: true,
        componentName: opts.projectName,
        commandKeys: Object.keys(commands),
        workflowsMigrated,
        workflowsDirRemoved,
    };
}
/** Secondary pass for projects that already have `components:` set but might be
 *  missing `workflows:` (e.g. a project migrated by an earlier server build,
 *  before default workflows were seeded inline). Strictly idempotent.
 *
 *  Component-name is read from the existing components[0] when available, so
 *  the structural step refs (`{ component, command }`) point at the correct
 *  component even if the user later renamed it. */
function maybeSeedWorkflowsOnly(args) {
    const { raw, yamlFile, configDir, projectName } = args;
    const hasInlineWorkflows = raw.workflows
        && typeof raw.workflows === "object"
        && !Array.isArray(raw.workflows)
        && Object.keys(raw.workflows).length > 0;
    const workflowsDir = path.join(configDir, "workflows");
    const hasWorkflowsDir = fs.existsSync(workflowsDir) && fs.statSync(workflowsDir).isDirectory();
    // If a workflows dir is present, migrate it into the inline block (mirrors the
    // main path so the dir is removed and content is preserved). If neither inline
    // nor dir, this is a no-op — no default seeding. If only legacy top-level qa_*
    // keys remain, fall through so they get migrated onto the appropriate component's
    // config (any presence of a legacy qa_* top-level key triggers a write — even
    // empty values get deleted to satisfy the "no top-level qa_* after boot"
    // acceptance criterion).
    const hasLegacyQaTopLevel = LEGACY_QA_KEYS.some(k => k in raw);
    if (hasInlineWorkflows && !hasWorkflowsDir && !hasLegacyQaTopLevel) {
        return { migrated: false };
    }
    const components = raw.components;
    const componentName = components && components[0] && typeof components[0].name === "string"
        ? components[0].name
        : projectName;
    const inlineWorkflows = hasInlineWorkflows ? { ...raw.workflows } : {};
    let workflowsMigrated = 0;
    let workflowsDirRemoved = false;
    if (hasWorkflowsDir) {
        for (const entry of fs.readdirSync(workflowsDir)) {
            if (!entry.endsWith(".yaml") && !entry.endsWith(".yml"))
                continue;
            const id = entry.replace(/\.ya?ml$/, "");
            const fp = path.join(workflowsDir, entry);
            try {
                const parsed = yaml.parse(fs.readFileSync(fp, "utf-8"));
                if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
                    inlineWorkflows[id] = parsed;
                    workflowsMigrated++;
                }
            }
            catch (err) {
                console.warn(`[migrate] Failed to parse workflow ${fp}:`, err);
            }
        }
        try {
            fs.rmSync(workflowsDir, { recursive: true, force: true });
            workflowsDirRemoved = true;
        }
        catch (err) {
            console.warn("[migrate] Failed to remove workflows directory:", err);
        }
    }
    // Migrate any legacy top-level qa_* keys onto the appropriate component's config.
    // The components array is mutated in place; the same reference is then re-emitted in `next`.
    const nextComponents = (components ?? []).map(c => ({ ...c }));
    const nextRaw = { ...raw, components: nextComponents, workflows: inlineWorkflows };
    const qaResult = migrateLegacyQaToComponent(nextRaw, nextComponents, projectName);
    if (workflowsMigrated === 0 && !qaResult.changed) {
        // Nothing changed (no inline dir to migrate, no default seeding, no QA migration).
        return { migrated: false };
    }
    const next = nextRaw;
    try {
        fs.mkdirSync(configDir, { recursive: true });
        const tmp = yamlFile + ".tmp";
        fs.writeFileSync(tmp, yaml.stringify(next), "utf-8");
        fs.renameSync(tmp, yamlFile);
    }
    catch (err) {
        console.error("[migrate] Failed to write seeded project.yaml:", err);
        return { migrated: false };
    }
    if (workflowsMigrated > 0) {
        console.log(`[migrate] project.yaml: inlined ${workflowsMigrated} workflow files for ${projectName}`);
    }
    return {
        migrated: true,
        componentName,
        workflowsMigrated,
        workflowsDirRemoved,
    };
}
/** Run the migration for every registered project. Idempotent across calls. */
export function migrateAllProjects(projects) {
    for (const p of projects) {
        const configDir = path.join(p.rootPath, ".bobbit", "config");
        // If the project's rootPath doesn't exist (provisional, scaffolding) skip.
        if (!fs.existsSync(p.rootPath))
            continue;
        try {
            migrateProjectYaml({ configDir, projectName: p.name });
        }
        catch (err) {
            console.error(`[migrate] project ${p.name} (${p.id}) failed:`, err);
        }
    }
}
//# sourceMappingURL=migrate-project-yaml.js.map