import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { findFiles, ensureDirectory, copyFile, removeFile, readText, writeText, fileExists } from '../../../lib/fs.js';
import { detectDbContexts, findStartupProject, hasDotnetProject } from '../lib/detect-dbcontexts.js';
import { runDotnet, dotnetAvailable, dotnetEfAvailable, buildMigrationsAddArgs, migrationsOutputDir } from '../lib/ef-runner.js';
import { buildMigrationName } from '../lib/migration-name.js';
import { determineBaseBranch, type SquashBranchType } from '../lib/squash-base.js';
import { minBranchOnly, behindBaseBlocker, withGitignoreLine, decideSnapshotAction, rollbackRemovals, staleHistoryAdvisory, type SquashMode } from '../lib/squash-policy.js';
import { findMissingReferenceMigrations, referenceMigrationNames } from '../lib/squash-integrity.js';
import { buildHistoryRecord, appendHistory, type HistoryAssemblyEntry } from '../lib/efcore-history.js';
import { ensureSqlObjectsInMigration, previewSqlObjectsForMigration } from '../../../lib/sql-objects.js';
import type { SquashSpec, SquashResult, SquashAssemblyResult } from './types.js';

const BACKUP_DIR_NAME = '.efcore-squash-backup';

const execFileAsync = promisify(execFile);

async function git(args: string[], cwd: string): Promise<{ stdout: string; exitCode: number; stderr: string }> {
  try {
    const { stdout, stderr } = await execFileAsync('git', args, { cwd, encoding: 'utf-8', maxBuffer: 20 * 1024 * 1024 });
    return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
  } catch (err) {
    const e = err as { stdout?: string; stderr?: string; code?: number };
    return {
      stdout: (e.stdout || '').trim(),
      stderr: (e.stderr || '').trim(),
      exitCode: typeof e.code === 'number' ? e.code : 1,
    };
  }
}

/**
 * Resolve a single named branch to a verifiable ref. Tries `origin/<name>`
 * then the local `<name>`. Returns null if neither exists.
 *
 * IMPORTANT: there is NO cross-type fallback. A hotfix/release whose base is
 * `main` must resolve against `main` — never silently against `develop` —
 * otherwise migrations already in production would be treated as "branch-only"
 * and deleted. The caller errors out when this returns null.
 */
async function resolveRef(cwd: string, name: string): Promise<string | null> {
  for (const candidate of [`origin/${name}`, name]) {
    const r = await git(['rev-parse', '--verify', '--quiet', candidate], cwd);
    if (r.exitCode === 0 && r.stdout) return candidate;
  }
  return null;
}

function capitalize(s: string): string {
  if (!s) return s;
  if (s.toLowerCase() === 'sqlserver') return 'SqlServer';
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function uniq(items: string[]): string[] {
  return Array.from(new Set(items));
}

/** Pure migration `.cs` files in `dir` — excludes ModelSnapshot and .Designer.cs sidecars. */
async function listMigrationFilesInDir(dir: string): Promise<string[]> {
  const files = await findFiles('*.cs', { cwd: dir });
  return files.filter(
    (f) =>
      path.dirname(f) === dir &&
      !f.endsWith('ModelSnapshot.cs') &&
      !f.endsWith('.Designer.cs'),
  );
}

interface Classification {
  /** Local migrations that are NOT present in the reference branch (safe to squash). */
  branchOnly: string[];
  /** Every `.cs` file the reference branch holds under the migrations dir (migrations + designers + snapshot), relative to cwd — restored verbatim. */
  baseRelFiles: string[];
  /** Count of pure migrations preserved from the reference branch. */
  basePreserved: number;
  /** False when the reference branch tree could not be read — caller must delete NOTHING. */
  baseListingOk: boolean;
  /**
   * Does the reference branch carry a `…ModelSnapshot.cs` under the migrations
   * dir? False ⇒ the reference has never seen this context (empty base): there
   * is no snapshot to restore, so the squash regenerates a full InitialCreate.
   */
  baseHasSnapshot: boolean;
  /** 🔒 Names of the reference-branch migrations (the INTOUCHABLE parent set). */
  parentMigrations: string[];
  /** ☠️ Reference-branch migrations MISSING from the working tree (deleted/renamed locally). */
  missingFromWorkingTree: string[];
}

/**
 * Split the local migrations into "branch-only" (consolidatable) vs the
 * reference-branch set (preserved). The reference set is computed from the
 * reference branch's git tree — a migration present there is NEVER deleted.
 *
 * Also surfaces the prod-break signal: reference migrations absent from the
 * working tree (`missingFromWorkingTree`) — a manual re-baseline that would
 * drop an already-applied migration from production at merge.
 */
async function classifyMigrations(migrationsDir: string, cwd: string, baseRef: string): Promise<Classification> {
  const all = await listMigrationFilesInDir(migrationsDir);
  const rel = path.relative(cwd, migrationsDir).replace(/\\/g, '/');

  const baseListing = await git(['ls-tree', '-r', '--name-only', baseRef, rel], cwd);
  if (baseListing.exitCode !== 0) {
    return { branchOnly: [], baseRelFiles: [], basePreserved: 0, baseListingOk: false, baseHasSnapshot: false, parentMigrations: [], missingFromWorkingTree: [] };
  }

  const baseAllCs = baseListing.stdout.split('\n').filter(Boolean).filter((f) => f.endsWith('.cs'));
  const baseHasSnapshot = baseAllCs.some((f) => f.endsWith('ModelSnapshot.cs'));
  const baseMigrationSet = new Set(
    baseAllCs.filter((f) => !f.endsWith('ModelSnapshot.cs') && !f.endsWith('.Designer.cs')),
  );

  const branchOnly = all.filter((abs) => {
    const r = path.relative(cwd, abs).replace(/\\/g, '/');
    return !baseMigrationSet.has(r);
  });

  const baseMigrationPaths = Array.from(baseMigrationSet);
  return {
    branchOnly,
    baseRelFiles: baseAllCs,
    basePreserved: baseMigrationSet.size,
    baseListingOk: true,
    baseHasSnapshot,
    parentMigrations: referenceMigrationNames(baseMigrationPaths),
    // "present" is DISK truth (`all`) — a file `rm`'d but still tracked is still
    // gone from the tree, so disk presence is the right signal.
    missingFromWorkingTree: findMissingReferenceMigrations(baseMigrationPaths, all),
  };
}

/**
 * Back up `files` under `backupDir`, preserving their path relative to `cwd`.
 * De-duplicates and silently skips files that don't exist on disk (e.g. a
 * migration without a `.Designer.cs`) so the caller can pass a superset.
 */
async function backupFiles(files: string[], backupDir: string, cwd: string): Promise<void> {
  await ensureDirectory(backupDir);
  for (const f of Array.from(new Set(files))) {
    if (!(await fileExists(f))) continue;
    const rel = path.relative(cwd, f);
    const dest = path.join(backupDir, rel);
    await ensureDirectory(path.dirname(dest));
    await copyFile(f, dest);
  }
}

/** Commits the current branch is BEHIND `baseRef` (HEAD..baseRef). 0 on error. */
async function countBehind(cwd: string, baseRef: string): Promise<number> {
  const r = await git(['rev-list', '--count', `HEAD..${baseRef}`], cwd);
  if (r.exitCode !== 0) return 0;
  const n = Number(r.stdout.trim());
  return Number.isFinite(n) ? n : 0;
}

/**
 * Transparency for finding #3: when the resolved reference is `origin/<name>`
 * but a local `<name>` exists and differs, say so — the squash aligns on the
 * remote, which may not be what the user has locally.
 */
async function warnIfRemoteDiverges(cwd: string, baseRef: string, warnings: string[]): Promise<void> {
  if (!baseRef.startsWith('origin/')) return;
  const local = baseRef.slice('origin/'.length);
  const localExists = (await git(['rev-parse', '--verify', '--quiet', local], cwd)).exitCode === 0;
  if (!localExists) return;
  const ahead = Number((await git(['rev-list', '--count', `${local}..${baseRef}`], cwd)).stdout.trim()) || 0;
  const behind = Number((await git(['rev-list', '--count', `${baseRef}..${local}`], cwd)).stdout.trim()) || 0;
  if (ahead || behind) {
    warnings.push(
      `Reference is "${baseRef}"; your local "${local}" differs (origin is ${ahead} ahead / ${behind} behind it). ` +
        `The squash aligns on "${baseRef}" — fetch/merge if that is not what you intend.`,
    );
  }
}

/** Finding #5: keep the backup folder out of git. Idempotent, append-only. */
async function ensureBackupGitignored(cwd: string, warnings: string[]): Promise<void> {
  try {
    const gitignore = path.join(cwd, '.gitignore');
    const existing = (await fileExists(gitignore)) ? await readText(gitignore) : null;
    const next = withGitignoreLine(existing, `${BACKUP_DIR_NAME}/`);
    if (next !== null) await writeText(gitignore, next);
  } catch {
    warnings.push(`Could not update .gitignore for ${BACKUP_DIR_NAME}/ — add it manually so backups are not committed.`);
  }
}

/**
 * Restore the reference-branch migration files (+ their .Designer.cs) verbatim
 * from the reference branch. This is the 4.x GOLDEN RULE — "NEVER retrieve only
 * the snapshot without the migrations" — and it guarantees every inherited
 * migration is byte-identical to the reference branch after the squash.
 */
async function restoreFromRef(relFiles: string[], cwd: string, baseRef: string): Promise<string[]> {
  const failed: string[] = [];
  for (const rel of relFiles) {
    const r = await git(['checkout', baseRef, '--', rel], cwd);
    if (r.exitCode !== 0) failed.push(rel);
  }
  return failed;
}

async function resetSnapshotToBase(snapshotPath: string, cwd: string, baseRef: string): Promise<boolean> {
  const rel = path.relative(cwd, snapshotPath).replace(/\\/g, '/');
  const r = await git(['checkout', baseRef, '--', rel], cwd);
  return r.exitCode === 0;
}

/** Delete a migration `.cs` and its `.Designer.cs` sidecar; return the basenames removed. */
async function deleteMigrationFile(file: string): Promise<string[]> {
  const removed: string[] = [];
  await removeFile(file);
  removed.push(path.basename(file));
  const designer = file.replace(/\.cs$/, '.Designer.cs');
  try {
    await fs.access(designer);
    await removeFile(designer);
    removed.push(path.basename(designer));
  } catch {
    /* no designer */
  }
  return removed;
}

/**
 * Atomic rollback for a NORMAL-squash assembly. Restores the working tree to its
 * exact pre-run state from the backup just taken, so a failed real run is a
 * NO-OP instead of a half-squashed, non-buildable tree (Part E of the empty-base
 * bug report). The backup mirrors every pre-run migration file (snapshot +
 * branch-only + designers + inherited-on-disk), so we:
 *   1. delete any `.cs` now directly in the migrations dir that the backup does
 *      NOT hold (e.g. the consolidated migration + its `.Designer.cs` that
 *      `dotnet ef migrations add` wrote before/while failing), then
 *   2. copy every backed-up file back to its original cwd-relative path
 *      (recreates the deleted branch-only migrations + reverts the snapshot).
 * Returns false if the restore itself failed (caller warns + keeps the backup).
 */
async function rollbackAssemblyFromBackup(migrationsDir: string, backupDir: string, cwd: string): Promise<boolean> {
  try {
    const backedUpAbs = await findFiles('**/*', { cwd: backupDir });
    const backedUpRel = backedUpAbs.map((abs) => path.relative(backupDir, abs).replace(/\\/g, '/'));

    // 1. Remove files created by the failed run (present now, absent from backup).
    const nowAbs = (await findFiles('*.cs', { cwd: migrationsDir })).filter(
      (f) => path.dirname(f) === migrationsDir,
    );
    const nowRel = nowAbs.map((abs) => path.relative(cwd, abs).replace(/\\/g, '/'));
    for (const rel of rollbackRemovals(nowRel, backedUpRel)) {
      await removeFile(path.join(cwd, rel));
    }

    // 2. Restore every backed-up file verbatim to its original location.
    for (const abs of backedUpAbs) {
      await copyFile(abs, path.join(cwd, path.relative(backupDir, abs)));
    }
    return true;
  } catch {
    return false;
  }
}

export async function execute(spec: SquashSpec): Promise<SquashResult> {
  const warnings: string[] = [];
  const mode: SquashMode = spec.mode ?? 'squash';
  const bruteForce = spec.bruteForce ?? false;
  let behindBase = 0;

  if (spec.aggressive) {
    warnings.push(
      '`aggressive` is deprecated and ignored — migrations present in the reference branch are ALWAYS preserved. For a deliberate full re-baseline use `bruteForce`.',
    );
  }

  const currentBranch = (await git(['branch', '--show-current'], spec.cwd)).stdout || 'HEAD';
  const decision = determineBaseBranch(currentBranch);
  const branchType: SquashBranchType = decision.branchType;

  const fail = (error: string, baseBranch = ''): SquashResult => ({
    success: false,
    cwd: spec.cwd,
    baseBranch,
    branchType,
    currentBranch,
    behindBase,
    assemblies: [],
    warnings,
    error,
  });

  // 1. Never squash on a protected production branch — bruteForce does NOT
  //    override this. A re-baseline is done on develop/release then merged to
  //    main in lock-step; you never rewrite history while sitting on prod.
  if (decision.blocked) {
    return fail(
      `Refusing to ${bruteForce ? 'brute-force re-baseline' : 'squash'} on "${currentBranch}" — ${decision.reason}` +
        (bruteForce
          ? ' bruteForce does NOT override the production-branch block — re-baseline on develop/release, then merge to main in lock-step.'
          : ''),
    );
  }

  if (!(await hasDotnetProject(spec.cwd))) {
    return fail('No .csproj in worktree.');
  }

  // 2. Resolve the reference branch BY TYPE (explicit override wins). No
  //    cross-type fallback — a release/hotfix must resolve against main.
  let baseRef: string | null;
  let baseName: string;
  if (spec.baseBranch) {
    baseName = spec.baseBranch;
    baseRef = await resolveRef(spec.cwd, spec.baseBranch);
    if (!baseRef) {
      return fail(`Could not resolve the requested base branch "${spec.baseBranch}" (tried origin/${spec.baseBranch} and ${spec.baseBranch}).`, baseName);
    }
  } else {
    baseName = decision.baseBranch as string; // non-null when not blocked
    baseRef = await resolveRef(spec.cwd, baseName);
    if (!baseRef) {
      return fail(
        `Could not resolve base branch "${baseName}" for ${branchType} branch "${currentBranch}". ` +
          `Fetch it first (git fetch origin ${baseName}) or pass an explicit baseBranch. ` +
          `Refusing to fall back to another branch — that would risk deleting migrations already present in the reference branch.`,
        baseName,
      );
    }
  }

  // 3. Safety (finding #1): refuse to squash a branch that is behind its
  //    reference — that regenerates a migration which can DROP what the
  //    reference added. bruteForce lifts this (deliberate re-baseline) but warns.
  behindBase = await countBehind(spec.cwd, baseRef);
  await warnIfRemoteDiverges(spec.cwd, baseRef, warnings);
  if (bruteForce) {
    if (behindBase > 0) {
      warnings.push(
        `⚠️ bruteForce: "${currentBranch}" is ${behindBase} commit(s) behind "${baseRef}" — the re-baseline proceeds anyway (you asked). Ensure "${baseRef}" is re-baselined in lock-step.`,
      );
    }
  } else {
    const blocker = behindBaseBlocker({
      behindBase,
      allowBehindBase: spec.allowBehindBase ?? false,
      currentBranch,
      baseRef,
      mode,
    });
    if (blocker) {
      if (spec.dryRun) {
        warnings.push(`⚠️ ${blocker}`);
      } else {
        return fail(blocker, baseRef);
      }
    }
  }

  const detection = await detectDbContexts(spec.cwd);
  const ctx = detection.contexts.find((c) => c.name === spec.contextName);
  if (!ctx) {
    return fail(`Context "${spec.contextName}" not detected in worktree`, baseRef);
  }

  const targets = spec.assembly
    ? ctx.assemblies.filter((a) => a.assemblyName === spec.assembly)
    : ctx.assemblies;

  if (targets.length === 0) {
    return fail('No migration assemblies matched filter', baseRef);
  }

  // 4. Classify ALL target assemblies up-front (read-only — git + disk). This
  //    feeds both the plan view (the 🔒 / ✂️ / ☠️ lists) and the safety gates
  //    below, and lets us refuse the whole operation before mutating anything.
  type Classified = { asm: (typeof targets)[number] } & Classification;
  const classified: Classified[] = [];
  for (const asm of targets) {
    const c = await classifyMigrations(asm.migrationsDir, spec.cwd, baseRef);
    classified.push({ asm, ...c });
  }

  // Build a per-assembly result row pre-filled with the classification lists so
  // every push carries parentMigrations + missingFromWorkingTree consistently.
  const baseRow = (c: Classified, over: Partial<SquashAssemblyResult>): SquashAssemblyResult => ({
    assemblyName: c.asm.assemblyName,
    contextName: spec.contextName,
    backupDir: null,
    deletedFiles: [],
    baseMigrationsPreserved: c.basePreserved,
    parentMigrations: c.parentMigrations,
    missingFromWorkingTree: c.missingFromWorkingTree,
    resetSnapshot: false,
    newMigrationName: null,
    sqlObjectsInlined: [],
    dotnetOutput: '',
    success: true,
    ...over,
  });

  // 5. THE HARD GATE (the incident fix). A reference migration missing from the
  //    working tree means a manual re-baseline already removed an
  //    already-applied migration — merging would drop it from production.
  //    Refuse by default; only a deliberate, logged bruteForce may proceed.
  if (!bruteForce) {
    const allMissing = uniq(classified.flatMap((c) => c.missingFromWorkingTree));
    if (allMissing.length > 0) {
      const msg =
        `Refusing to squash — ${allMissing.length} migration(s) present on "${baseRef}" are MISSING from your working tree: ${allMissing.join(', ')}. ` +
        `A merge would DROP them from production. Restore them (git checkout ${baseRef} -- <Migrations dir>), ` +
        `or — for a deliberate release re-baseline — re-run with "bruteForce": true and "confirmBruteForce": true ` +
        `(the one exception; it is logged to .claude/Efcore-history).`;
      if (spec.dryRun) {
        // Surface the danger but still produce the plan rows so the agent can
        // render the 🔒 / ✂️ / ☠️ lists.
        warnings.push(`☠️ ${msg}`);
      } else {
        return fail(msg, baseRef);
      }
    }
  }

  // 6. bruteForce confirmation-token gate. Without the explicit token, REFUSE
  //    and report exactly which production migrations would be destroyed.
  if (bruteForce && !spec.confirmBruteForce) {
    const casualties = uniq(classified.flatMap((c) => c.parentMigrations));
    const planRows = classified.map((c) =>
      baseRow(c, {
        deletedFiles: [...c.parentMigrations, ...c.branchOnly.map((f) => path.basename(f))],
        baseMigrationsPreserved: 0,
        success: false,
        error: 'Awaiting confirmBruteForce — these migrations (including reference/prod) would be destroyed.',
        dotnetOutput:
          `[brute-force preview] would delete ALL ${c.parentMigrations.length + c.branchOnly.length} migration(s) ` +
          `(incl. ${c.parentMigrations.length} from ${baseRef}) + snapshot, then regenerate one InitialCreate`,
      }),
    );
    return {
      success: false,
      requiresConfirmation: true,
      cwd: detection.cwd,
      baseBranch: baseRef,
      branchType,
      currentBranch,
      behindBase,
      assemblies: planRows,
      warnings,
      error:
        `BRUTE FORCE re-baseline requested. This will DELETE ${casualties.length} migration(s) that already live on "${baseRef}" ` +
        `(production history): ${casualties.join(', ') || '(none on the reference branch)'} — plus all branch-only migrations — ` +
        `and regenerate a single InitialCreate. Pass "confirmBruteForce": true to proceed. It will be logged to .claude/Efcore-history.`,
    };
  }

  if (!spec.dryRun) {
    if (!(await dotnetAvailable())) {
      return fail('dotnet CLI not on PATH', baseRef);
    }
    if (!(await dotnetEfAvailable())) {
      return fail('dotnet-ef not installed globally', baseRef);
    }
  }

  const startupProject = (await findStartupProject(spec.cwd)) ?? undefined;
  const startedAtIso = new Date().toISOString();
  const timestamp = startedAtIso.replace(/[:.]/g, '-');
  const backupRoot = path.join(spec.cwd, BACKUP_DIR_NAME, timestamp);

  // Finding #5: make sure the backup folder is git-ignored before we write into it.
  if (!spec.dryRun) await ensureBackupGitignored(spec.cwd, warnings);

  const results: SquashAssemblyResult[] = [];

  for (const c of classified) {
    const { asm, branchOnly, baseRelFiles, basePreserved, baseListingOk } = c;

    // Safety: if we cannot read the reference branch's tree we cannot prove
    // which migrations are inherited — so we delete NOTHING.
    if (!baseListingOk) {
      warnings.push(`${asm.assemblyName}: could not read migrations of base "${baseRef}" — skipped to avoid deleting reference-branch migrations.`);
      results.push(baseRow(c, { error: `Skipped — could not list migrations of base "${baseRef}".` }));
      continue;
    }

    const assemblyBackup = path.join(backupRoot, asm.assemblyName);

    // ===== BRUTE FORCE: full re-baseline (delete EVERYTHING incl. the snapshot) =====
    if (bruteForce) {
      const onDisk = await listMigrationFilesInDir(asm.migrationsDir);
      const newMigrationName = buildMigrationName({
        contextName: spec.contextName,
        version: asm.version,
        sequence: 1,
        description: spec.description,
      });

      if (spec.dryRun) {
        const wouldInline = await previewSqlObjectsForMigration({
          projectDir: path.dirname(asm.csprojPath),
          migrationsDir: asm.migrationsDir,
        });
        results.push(
          baseRow(c, {
            backupDir: assemblyBackup,
            deletedFiles: onDisk.map((f) => path.basename(f)),
            baseMigrationsPreserved: 0,
            resetSnapshot: true,
            newMigrationName,
            sqlObjectsInlined: wouldInline,
            dotnetOutput:
              `[dry-run BRUTE FORCE] would delete ALL ${onDisk.length} migration(s) ` +
              `(incl. ${c.parentMigrations.length} from ${baseRef}) + the snapshot, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)}` +
              (wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''),
          }),
        );
        continue;
      }

      // Backup the entire migrations set (+ designers + snapshot) before nuking.
      await backupFiles(
        [asm.snapshotPath, ...onDisk, ...onDisk.map((f) => f.replace(/\.cs$/, '.Designer.cs'))],
        assemblyBackup,
        spec.cwd,
      );

      const deleted: string[] = [];
      for (const f of onDisk) deleted.push(...(await deleteMigrationFile(f)));
      // Drop the snapshot too so EF regenerates a full InitialCreate (otherwise
      // the new migration would diff against the old snapshot → empty).
      await removeFile(asm.snapshotPath);

      const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined;
      const args = buildMigrationsAddArgs({
        migrationName: newMigrationName,
        csprojPath: asm.csprojPath,
        contextName: spec.contextName,
        migrationsDir: asm.migrationsDir,
        startupProject,
      });
      const addRes = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv });

      let sqlObjectsInlined: string[] = [];
      if (addRes.exitCode === 0) {
        try {
          const ensure = await ensureSqlObjectsInMigration({
            projectDir: path.dirname(asm.csprojPath),
            migrationsDir: asm.migrationsDir,
            migrationName: newMigrationName,
          });
          sqlObjectsInlined = ensure.inlined;
          if (ensure.unparsable.length > 0) {
            warnings.push(`${asm.assemblyName}: ${ensure.unparsable.length} .sql file(s) under SqlObjects have no CREATE [OR ALTER] header — not inlined: ${ensure.unparsable.join(', ')}`);
          }
        } catch (e) {
          warnings.push(`${asm.assemblyName}: SQL-object inlining skipped (${(e as Error).message}).`);
        }
      }

      results.push(
        baseRow(c, {
          backupDir: assemblyBackup,
          deletedFiles: deleted,
          baseMigrationsPreserved: 0,
          resetSnapshot: true,
          newMigrationName: addRes.exitCode === 0 ? newMigrationName : null,
          sqlObjectsInlined,
          dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`,
          success: addRes.exitCode === 0,
          error: addRes.exitCode === 0 ? undefined : addRes.stderr || addRes.stdout || 'dotnet ef migrations add failed',
        }),
      );
      continue;
    }

    // ===== NORMAL SQUASH: consolidate branch-only migrations only =====
    // Threshold by mode (finding #4): squash needs ≥2 to consolidate;
    // rebase-snapshot acts on ≥1 (and resets the snapshot even on 0).
    const min = minBranchOnly(mode);
    if (branchOnly.length < min) {
      if (mode === 'rebase-snapshot' && branchOnly.length === 0) {
        const snapBackup = path.join(backupRoot, asm.assemblyName);
        if (spec.dryRun) {
          results.push(
            baseRow(c, {
              resetSnapshot: true,
              dotnetOutput: `[dry-run] would reset ${path.basename(asm.snapshotPath)} to ${baseRef} (no branch-only migrations to consolidate)`,
            }),
          );
          continue;
        }
        await backupFiles([asm.snapshotPath], snapBackup, spec.cwd);
        const resetOk = await resetSnapshotToBase(asm.snapshotPath, spec.cwd, baseRef);
        results.push(
          baseRow(c, {
            backupDir: snapBackup,
            resetSnapshot: resetOk,
            success: resetOk,
            error: resetOk
              ? 'Snapshot reset to reference branch (no branch-only migrations to consolidate)'
              : `git checkout ${baseRef} -- ${asm.snapshotPath} failed`,
          }),
        );
        continue;
      }

      results.push(
        baseRow(c, {
          error:
            branchOnly.length === 0
              ? `No branch-only migrations (all ${basePreserved} are inherited from ${baseRef}) — nothing to ${mode === 'rebase-snapshot' ? 'rebase-snapshot' : 'squash'}`
              : 'Only 1 branch-only migration — nothing to consolidate',
        }),
      );
      continue;
    }

    // Predict the snapshot path so the plan matches what the real run will do.
    // Empty base ⇒ no snapshot to restore ⇒ consolidated as a full InitialCreate.
    const emptyBase = decideSnapshotAction({ baseHasSnapshot: c.baseHasSnapshot, basePreserved }) === 'delete-for-initial-create';

    if (spec.dryRun) {
      const wouldInline = await previewSqlObjectsForMigration({
        projectDir: path.dirname(asm.csprojPath),
        migrationsDir: asm.migrationsDir,
      });
      const newMigrationName = buildMigrationName({
        contextName: spec.contextName,
        version: asm.version,
        sequence: basePreserved + 1,
        description: spec.description,
      });
      if (emptyBase) {
        // Remove the false-GREEN: tell the user up front this is an empty-base
        // consolidation (the real run cannot "restore 0 from base" — there is
        // no snapshot on the reference; it deletes the snapshot and regenerates).
        warnings.push(
          `${asm.assemblyName}: reference "${baseRef}" carries no migrations/snapshot for ${spec.contextName} — ` +
            `the consolidated migration will be a full InitialCreate (empty base model).`,
        );
      }
      results.push(
        baseRow(c, {
          backupDir: assemblyBackup,
          deletedFiles: branchOnly.map((f) => path.basename(f)),
          resetSnapshot: true,
          emptyBaseModel: emptyBase,
          newMigrationName,
          sqlObjectsInlined: wouldInline,
          dotnetOutput: emptyBase
            ? `[dry-run] base "${baseRef}" has no snapshot for ${spec.contextName} (empty base model) — would delete ${branchOnly.length} migration(s) + the snapshot, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)} as a full InitialCreate${wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''}`
            : `[dry-run] would delete ${branchOnly.length} branch-only migration(s), restore ${basePreserved} from ${baseRef}, then dotnet ef migrations add ${newMigrationName} -o ${migrationsOutputDir(asm.csprojPath, asm.migrationsDir)}${wouldInline.length ? `, then inline ${wouldInline.length} SQL object(s): ${wouldInline.join(', ')}` : ''}`,
        }),
      );
      continue;
    }

    // 1. Backup snapshot + the branch-only migrations (+ their designers) AND
    //    the inherited reference files currently on disk (finding #5: capture
    //    any local edits to inherited migrations before restore overwrites them).
    const inheritedOnDisk = baseRelFiles.map((rel) => path.join(spec.cwd, rel));
    await backupFiles(
      [
        asm.snapshotPath,
        ...branchOnly,
        ...branchOnly.map((f) => f.replace(/\.cs$/, '.Designer.cs')),
        ...inheritedOnDisk,
      ],
      assemblyBackup,
      spec.cwd,
    );

    // 2. Delete branch-only migration files (+ .Designer.cs). NEVER the inherited ones.
    const deleted: string[] = [];
    for (const f of branchOnly) deleted.push(...(await deleteMigrationFile(f)));

    // 3. Restore the reference-branch migrations + designers verbatim (GOLDEN RULE:
    //    never keep only the snapshot). Snapshot itself reset in step 4.
    const restoreFailures = await restoreFromRef(
      baseRelFiles.filter((f) => !f.endsWith('ModelSnapshot.cs')),
      spec.cwd,
      baseRef,
    );
    if (restoreFailures.length) {
      warnings.push(`${asm.assemblyName}: could not restore ${restoreFailures.length} reference-branch file(s) from ${baseRef}.`);
    }

    // 4. Snapshot: reset it to the reference branch — OR, for an empty base
    //    (the reference has NEVER carried this context: no snapshot AND no
    //    inherited migrations), DELETE it so `dotnet ef migrations add`
    //    regenerates a full InitialCreate. This mirrors the bruteForce path
    //    (see the snapshot removal there) but with ZERO casualties — there are
    //    no reference migrations to destroy. Without this branch the
    //    `git checkout <base> -- <snapshot>` below necessarily fails (the file
    //    is absent on the reference) and leaves a half-squashed tree.
    let resetOk: boolean;
    if (emptyBase) {
      await removeFile(asm.snapshotPath); // backed up in step 1; safe — basePreserved === 0
      resetOk = true;
    } else {
      resetOk = await resetSnapshotToBase(asm.snapshotPath, spec.cwd, baseRef);
    }
    if (!resetOk) {
      // The tree is mutated (branch-only deleted, refs restored) but the snapshot
      // step failed (e.g. a malformed base with migrations but no snapshot).
      // Roll back to a NO-OP rather than leaving a half-squashed tree.
      const rolledBack = await rollbackAssemblyFromBackup(asm.migrationsDir, assemblyBackup, spec.cwd);
      if (!rolledBack) {
        warnings.push(`${asm.assemblyName}: automatic rollback FAILED — restore manually from ${assemblyBackup}.`);
      }
      results.push(
        baseRow(c, {
          backupDir: assemblyBackup,
          deletedFiles: rolledBack ? [] : deleted,
          resetSnapshot: false,
          rolledBack,
          success: false,
          error:
            `git checkout ${baseRef} -- ${asm.snapshotPath} failed` +
            (rolledBack ? ' — rolled back to the pre-run state (no-op).' : ''),
        }),
      );
      continue;
    }

    // 5. Compute sequence (after restore → reference migration count) + name.
    const existingCount = (await listMigrationFilesInDir(asm.migrationsDir)).length;
    const newMigrationName = buildMigrationName({
      contextName: spec.contextName,
      version: asm.version,
      sequence: existingCount + 1,
      description: spec.description,
    });

    // 6. Regenerate via dotnet ef migrations add.
    const extraEnv = asm.provider ? { STUDIO_DESIGN_PROVIDER: capitalize(asm.provider) } : undefined;
    const args = buildMigrationsAddArgs({
      migrationName: newMigrationName,
      csprojPath: asm.csprojPath,
      contextName: spec.contextName,
      migrationsDir: asm.migrationsDir,
      startupProject,
    });
    const addRes = await runDotnet(args, spec.cwd, { timeoutMs: 5 * 60_000, extraEnv });

    // If the regeneration failed, the tree is half-squashed (branch-only deleted,
    // refs restored, snapshot reset, no consolidated migration). Roll back to a
    // NO-OP so a failed squash never leaves a non-buildable migration set.
    if (addRes.exitCode !== 0) {
      const rolledBack = await rollbackAssemblyFromBackup(asm.migrationsDir, assemblyBackup, spec.cwd);
      if (!rolledBack) {
        warnings.push(`${asm.assemblyName}: automatic rollback FAILED — restore manually from ${assemblyBackup}.`);
      }
      results.push(
        baseRow(c, {
          backupDir: assemblyBackup,
          deletedFiles: rolledBack ? [] : deleted,
          resetSnapshot: false,
          emptyBaseModel: emptyBase,
          rolledBack,
          newMigrationName: null,
          dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`,
          success: false,
          error:
            (addRes.stderr || addRes.stdout || 'dotnet ef migrations add failed') +
            (rolledBack ? ' — rolled back to the pre-run state (no-op).' : ''),
        }),
      );
      continue;
    }

    // 7. Re-freeze the SQL objects (functions/views/procs) into the consolidated
    //    migration. The branch-only migrations we just deleted may have carried
    //    them; the model-diff regen never re-emits raw SQL, so without this the
    //    squash would silently drop them. Idempotent CREATE OR ALTER → safe.
    let sqlObjectsInlined: string[] = [];
    try {
      const ensure = await ensureSqlObjectsInMigration({
        projectDir: path.dirname(asm.csprojPath),
        migrationsDir: asm.migrationsDir,
        migrationName: newMigrationName,
      });
      sqlObjectsInlined = ensure.inlined;
      if (ensure.unparsable.length > 0) {
        warnings.push(
          `${asm.assemblyName}: ${ensure.unparsable.length} .sql file(s) under SqlObjects have no CREATE [OR ALTER] header — not inlined: ${ensure.unparsable.join(', ')}`,
        );
      }
      if (ensure.injected) {
        warnings.push(
          `${asm.assemblyName}: re-froze ${ensure.inlined.length} SQL object(s) into ${newMigrationName} (preserved through the squash): ${ensure.inlined.join(', ')}`,
        );
      }
    } catch (e) {
      warnings.push(`${asm.assemblyName}: SQL-object inlining skipped (${(e as Error).message}).`);
    }

    results.push(
      baseRow(c, {
        backupDir: assemblyBackup,
        deletedFiles: deleted,
        resetSnapshot: true,
        emptyBaseModel: emptyBase,
        newMigrationName,
        sqlObjectsInlined,
        dotnetOutput: `${addRes.stdout}${addRes.stderr ? `\n${addRes.stderr}` : ''}`,
        success: true,
      }),
    );
  }

  // 8. Audit trail: log every destructive run (anything deleted) and ALWAYS a
  //    brute-force re-baseline, to .claude/Efcore-history. Never blocks the op.
  let historyLogPath: string | undefined;
  if (!spec.dryRun) {
    // A successful consolidation changes migration IDs; a dev DB that already
    // applied the old ones now has a stale __EFMigrationsHistory. Tell the user.
    const advisory = staleHistoryAdvisory(results);
    if (advisory) warnings.push(advisory);

    const histAssemblies: HistoryAssemblyEntry[] = results
      .filter((r) => bruteForce || r.deletedFiles.length > 0)
      .map((r) => ({
        assemblyName: r.assemblyName,
        deletedMigrations: r.deletedFiles,
        parentMigrationsDestroyed: bruteForce ? r.parentMigrations : [],
        newMigrationName: r.newMigrationName,
        backupDir: r.backupDir,
      }));
    if (histAssemblies.length > 0) {
      const record = buildHistoryRecord({
        timestamp: startedAtIso,
        bruteForce,
        rebaseSnapshot: mode === 'rebase-snapshot',
        confirmed: bruteForce ? spec.confirmBruteForce ?? false : false,
        cwd: detection.cwd,
        currentBranch,
        baseBranch: baseRef,
        branchType,
        assemblies: histAssemblies,
      });
      const logged = await appendHistory(spec.cwd, record);
      if (logged.path) historyLogPath = logged.path;
      else warnings.push(`Could not write the audit record to .claude/Efcore-history (${logged.error}).`);
    }
  }

  const allOk = results.every((r) => r.success);
  return {
    success: allOk,
    cwd: detection.cwd,
    baseBranch: baseRef,
    branchType,
    currentBranch,
    behindBase,
    historyLogPath,
    assemblies: results,
    warnings,
    error: allOk ? undefined : 'One or more assemblies failed — inspect per-assembly error',
  };
}
