/**
 * studiograph admin migrate — bulk regex find-replace across workspace repos.
 *
 * Codifies the hand-rolled sed pattern the owner has reached for twice
 * during schema renames. Walks every repo in workspace.json (or a
 * `--repos`-filtered subset), applies the regex, and produces one commit
 * per repo with the same message. `--dry-run` reports without writing.
 * `--also-rename-files` does a second pass that `git mv`s any files
 * whose name matches the regex.
 *
 * Safety:
 *   - Refuses if ANY targeted repo has uncommitted changes. Catches
 *     half-finished local edits before they're caught up in the rewrite.
 *   - Runs the regex against the file contents read with utf-8; commit
 *     uses `git -c core.quotepath=false` so non-ASCII file paths in
 *     summaries don't render as `\xxx` escapes.
 *   - Skips the .git/, .studiograph/, and node_modules/ subtrees of every
 *     repo (defensive — none of those should ever match a workspace glob,
 *     but a stray `--in '**'` shouldn't pull them in either).
 */
import { Command } from 'commander';
import { intro, outro, log, spinner } from '@clack/prompts';
import { existsSync, readFileSync, writeFileSync, statSync } from 'fs';
import { execFileSync } from 'child_process';
import { join, dirname } from 'path';
import { glob } from 'glob';
import { Workspace } from '../../../core/workspace.js';
import { gitHasUncommittedChanges } from '../../../utils/git.js';
const EXCLUDED_DIRS = ['.git', '.studiograph', 'node_modules', '.svelte-kit', 'dist', 'build'];
export const adminMigrateCommand = new Command('migrate')
    .description('Bulk regex find-replace across workspace repos with one commit per repo')
    .requiredOption('--in <glob>', 'Glob pattern for files to consider (e.g. "**/*.md")')
    .requiredOption('--regex <pattern>', 'Regex to search for (JavaScript syntax, global flag implied)')
    .requiredOption('--replace <text>', 'Replacement text (supports $1, $2 backrefs)')
    .option('--commit-message <msg>', 'Commit message (default: "refactor: bulk migrate <regex> → <replace>")')
    .option('--dry-run', 'Print affected files + replacement counts; do not write')
    .option('--also-rename-files', 'After content rewrite, git mv any matching filenames too')
    .option('--repos <list>', 'Comma-separated repo names to limit the migration to')
    .action(async (opts) => {
    intro('Bulk migrate across workspace repos');
    const workspace = new Workspace();
    if (!workspace.isWorkspace()) {
        outro('Not a Studiograph workspace. Run from your workspace root.');
        process.exit(1);
        return;
    }
    const config = await workspace.loadConfig();
    const repoFilter = opts.repos
        ? new Set(opts.repos.split(',').map((s) => s.trim()).filter(Boolean))
        : null;
    const targetRepos = config.repos.filter((r) => !repoFilter || repoFilter.has(r.name));
    if (targetRepos.length === 0) {
        outro(repoFilter
            ? `No repos matched --repos filter "${opts.repos}". Known repos: ${config.repos.map((r) => r.name).join(', ')}.`
            : 'Workspace has no repos.');
        process.exit(1);
        return;
    }
    // Compile the regex once. Force the global flag so String.replace hits
    // every match per file. Validate up-front so a typo doesn't manifest
    // only after we've started rewriting.
    let pattern;
    try {
        pattern = new RegExp(opts.regex, 'g');
    }
    catch (err) {
        outro(`Invalid regex "${opts.regex}": ${err instanceof Error ? err.message : String(err)}`);
        process.exit(1);
        return;
    }
    // Uncommitted-changes guard. Catches a half-finished edit before it
    // gets caught up in the rewrite.
    const dirty = [];
    for (const repo of targetRepos) {
        const repoPath = join(process.cwd(), repo.path);
        if (existsSync(repoPath) && gitHasUncommittedChanges(repoPath)) {
            dirty.push(repo.name);
        }
    }
    if (dirty.length > 0) {
        outro(`Refusing to migrate — these repos have uncommitted changes:\n  ${dirty.join('\n  ')}\n` +
            'Commit or stash them first, then retry.');
        process.exit(1);
        return;
    }
    const commitMessage = opts.commitMessage?.trim()
        || `refactor: bulk migrate ${opts.regex} → ${opts.replace}`;
    // Pass 1: content rewrites.
    const changes = [];
    const spin = spinner();
    for (const repo of targetRepos) {
        spin.start(`Scanning ${repo.name}...`);
        const repoChange = await migrateRepoContent(repo, opts.in, pattern, opts.replace, !!opts.dryRun);
        if (repoChange.fileChanges.length > 0) {
            spin.stop(`${repo.name}: ${repoChange.fileChanges.length} file(s) touched, ${totalReplacements(repoChange)} replacement(s)`);
        }
        else {
            spin.stop(`${repo.name}: no matches`);
        }
        changes.push(repoChange);
    }
    // Pass 2: file renames (optional). Only runs after the content pass
    // so the rewritten contents land first; renames are a separate commit
    // step within the same per-repo message.
    if (opts.alsoRenameFiles) {
        for (const repoChange of changes) {
            if (repoChange.fileChanges.length === 0)
                continue; // nothing to rename in repos with no matches
            await migrateRepoFilenames(repoChange, opts.in, pattern, opts.replace, !!opts.dryRun);
        }
    }
    // Dry-run summary, no commits.
    if (opts.dryRun) {
        log.info('Dry-run summary:');
        for (const ch of changes) {
            if (ch.fileChanges.length === 0 && ch.fileRenames.length === 0)
                continue;
            log.message(`  ${ch.repoName}:`);
            for (const fc of ch.fileChanges) {
                log.message(`    ${fc.relPath} — ${fc.replacements} replacement${fc.replacements === 1 ? '' : 's'}`);
            }
            for (const fr of ch.fileRenames) {
                log.message(`    ${fr.from} → ${fr.to} (rename)`);
            }
        }
        outro('Dry-run only — no changes written.');
        return;
    }
    // Commit per repo. Skip repos with no changes.
    let committedRepos = 0;
    for (const ch of changes) {
        if (ch.fileChanges.length === 0 && ch.fileRenames.length === 0)
            continue;
        try {
            commitRepo(ch.repoPath, commitMessage);
            committedRepos++;
            log.success(`${ch.repoName}: committed (${ch.fileChanges.length} edit(s), ${ch.fileRenames.length} rename(s))`);
        }
        catch (err) {
            log.error(`${ch.repoName}: commit failed — ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    if (committedRepos === 0) {
        outro('No repos had any matches — nothing committed.');
        return;
    }
    outro(`Done — ${committedRepos} repo(s) committed.`);
});
// ── Internals (exported for tests) ──────────────────────────────────────────
function totalReplacements(ch) {
    return ch.fileChanges.reduce((acc, f) => acc + f.replacements, 0);
}
/**
 * Walk one repo, regex-replace contents in every file matching `pattern`
 * (the glob pattern, not the regex). Returns a summary; writes files
 * unless `dryRun` is true.
 */
export async function migrateRepoContent(repo, globPattern, regex, replacement, dryRun) {
    const repoPath = join(process.cwd(), repo.path);
    const change = { repoName: repo.name, repoPath, fileChanges: [], fileRenames: [] };
    if (!existsSync(repoPath))
        return change;
    const files = await glob(globPattern, {
        cwd: repoPath,
        nodir: true,
        ignore: EXCLUDED_DIRS.map((d) => `${d}/**`),
        dot: false,
    });
    for (const relPath of files) {
        const abs = join(repoPath, relPath);
        try {
            // Skip binary files cheaply (avoids reading 10MB PDFs into memory
            // when an owner runs `--in "**"` against a workspace with assets).
            const stat = statSync(abs);
            if (stat.size > 5 * 1024 * 1024)
                continue;
            const content = readFileSync(abs, 'utf-8');
            // Reset lastIndex — regex with the `g` flag is stateful.
            regex.lastIndex = 0;
            let count = 0;
            const rewritten = content.replace(regex, (match, ...args) => {
                count++;
                // Use String.replace's substitution semantics with the literal
                // replacement so $1/$2 backrefs work. Args = [...captureGroups, offset, string].
                return applyReplacementString(replacement, match, args);
            });
            if (count === 0 || rewritten === content)
                continue;
            if (!dryRun)
                writeFileSync(abs, rewritten, 'utf-8');
            change.fileChanges.push({ relPath, replacements: count });
        }
        catch {
            // Likely a binary file masquerading as one of the glob matches
            // (utf-8 decode threw). Skip silently — `--in` should pre-filter.
        }
    }
    return change;
}
/**
 * Second pass: rename files whose paths match the regex. Uses `git mv`
 * so the rename shows up in the staging area for the same commit. Only
 * runs in repos that already had content matches — otherwise a rename-
 * only commit would be confusing.
 */
async function migrateRepoFilenames(change, globPattern, regex, replacement, dryRun) {
    const files = await glob(globPattern, {
        cwd: change.repoPath,
        nodir: true,
        ignore: EXCLUDED_DIRS.map((d) => `${d}/**`),
        dot: false,
    });
    for (const relPath of files) {
        regex.lastIndex = 0;
        const newRel = relPath.replace(regex, replacement);
        if (newRel === relPath)
            continue;
        if (!dryRun) {
            const newAbs = join(change.repoPath, newRel);
            const newDir = dirname(newAbs);
            if (!existsSync(newDir)) {
                // Create the target dir so `git mv` doesn't fail on a rename
                // that crosses subdirectories.
                execFileSync('mkdir', ['-p', newDir], { stdio: 'pipe' });
            }
            execFileSync('git', ['-c', 'core.quotepath=false', '-C', change.repoPath, 'mv', relPath, newRel], { stdio: 'pipe' });
        }
        change.fileRenames.push({ from: relPath, to: newRel });
    }
}
function commitRepo(repoPath, message) {
    // `git add -A` catches both content rewrites (modified files) and
    // file renames (which were already staged by `git mv`). The migrate
    // command owns the entire commit boundary — nothing else should be
    // half-staged in the repo, because we refused on dirty trees.
    execFileSync('git', ['-c', 'core.quotepath=false', '-C', repoPath, 'add', '-A'], { stdio: 'pipe' });
    execFileSync('git', ['-c', 'core.quotepath=false', '-C', repoPath, 'commit', '-m', message], { stdio: 'pipe' });
}
/**
 * Apply a replacement-string template the way String.prototype.replace
 * does when given a string (not a function): expand $1..$9 to capture
 * groups, $$ to a literal $, $& to the whole match.
 *
 * We re-implement this because we call replace with a *function* (to
 * count matches) but want the literal-string substitution semantics for
 * the actual replacement text. Without this, $1/$2 would render
 * literally instead of being expanded.
 */
function applyReplacementString(template, match, args) {
    // Captures are everything in args except the last two (offset + whole string).
    const captures = args.slice(0, -2);
    return template.replace(/\$([1-9]|&|\$)/g, (_, key) => {
        if (key === '$')
            return '$';
        if (key === '&')
            return match;
        const idx = parseInt(key, 10) - 1;
        return captures[idx] ?? '';
    });
}
// Re-export the helper signature for test imports.
export const __internals = { applyReplacementString };
//# sourceMappingURL=migrate.js.map