/**
 * CLI entry point for `scripts/migrate-config.sh`.
 *
 * Reads `config.yaml`, runs it through `normalizeConfig`, and writes the
 * v2-shaped result back. Always backs up the original to
 * `config.yaml.bak-<timestamp>` before rewriting. Dry-run mode prints the
 * normalized YAML to stdout + a (rough) diff to stderr and never touches
 * the source file.
 *
 * Exit codes:
 *   0  success (incl. "already v2 → no-op")
 *   1  IO failure (read / write / backup)
 *   2  parse / validation error
 *   3  invalid CLI usage
 *
 * Design notes:
 * - We deliberately re-read the file AS YAML rather than going through
 *   loadConfig() — loadConfig applies defaults + env overrides that we
 *   don't want to persist back to disk. The migration must be a pure
 *   syntactic transform of what the operator wrote.
 * - We DO run `transformKeys` (snake→camel → normalize → snake) to reuse
 *   the same normalizer the runtime uses. This guarantees the dry-run
 *   output matches what loadConfig would produce internally.
 * - Comments + key ordering are NOT preserved (yaml.parse loses them).
 *   Users keep the .bak file as their source of truth for any custom
 *   comments they want to re-introduce.
 */
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'fs';
import { resolve } from 'path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { normalizeConfig, UnsupportedConfigVersionError } from '../config-normalize.js';
import { toSnakeKeys } from '../config.js';

interface Cli {
  dryRun: boolean;
  configPath: string;
  help: boolean;
}

function parseCli(argv: string[]): Cli {
  const out: Cli = { dryRun: false, configPath: 'config.yaml', help: false };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--dry-run') out.dryRun = true;
    else if (a === '--help' || a === '-h') out.help = true;
    else if (a === '--config' || a === '-c') {
      const v = argv[++i];
      if (!v) {
        process.stderr.write('error: --config requires a path argument\n');
        process.exit(3);
      }
      out.configPath = v;
    } else if (a && a.startsWith('-')) {
      process.stderr.write(`error: unknown flag ${a}\n`);
      process.exit(3);
    } else if (a) {
      // bare positional → treat as config path (for `migrate-config.sh path/to/config.yaml`)
      out.configPath = a;
    }
  }
  return out;
}

function printHelp(): void {
  process.stdout.write([
    'Usage: migrate-config [--dry-run] [--config PATH]',
    '',
    'Convert a legacy (v1) config.yaml to the v2 layout in-place.',
    '',
    'Options:',
    '  --dry-run        Print the normalized YAML to stdout + diff to stderr.',
    '                   Does not modify the source file.',
    '  --config PATH    Path to config.yaml (default: ./config.yaml).',
    '  -h, --help       Show this help and exit.',
    '',
    'Exit codes:',
    '  0  success (incl. already-v2 no-op)',
    '  1  IO failure',
    '  2  parse / validation error',
    '  3  invalid CLI usage',
    '',
  ].join('\n'));
}

/** snake_case → camelCase recursive (matches src/config.ts transformKeys). */
function toCamel(s: string): string {
  return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
}
function transformCamel(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(transformCamel);
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), transformCamel(v)]),
    );
  }
  return obj;
}

/**
 * Produce a tiny line-level diff suitable for stderr. We deliberately avoid
 * an extra dep (diff/jsdiff) — operators just need a "what changed" hint.
 */
function lineDiff(oldText: string, newText: string): string {
  const oldLines = oldText.split('\n');
  const newLines = newText.split('\n');
  const oldSet = new Set(oldLines);
  const newSet = new Set(newLines);
  const lines: string[] = [];
  for (const ln of oldLines) {
    if (!newSet.has(ln)) lines.push(`- ${ln}`);
  }
  for (const ln of newLines) {
    if (!oldSet.has(ln)) lines.push(`+ ${ln}`);
  }
  return lines.join('\n');
}

function main(argv: string[]): number {
  const cli = parseCli(argv);
  if (cli.help) {
    printHelp();
    return 0;
  }

  const absPath = resolve(cli.configPath);
  if (!existsSync(absPath)) {
    process.stderr.write(`error: config file not found: ${absPath}\n`);
    return 1;
  }

  let rawText: string;
  try {
    rawText = readFileSync(absPath, 'utf-8');
  } catch (e) {
    process.stderr.write(`error: could not read ${absPath}: ${(e as Error).message}\n`);
    return 1;
  }

  let parsed: unknown;
  try {
    parsed = parseYaml(rawText);
  } catch (e) {
    process.stderr.write(`error: YAML parse failed: ${(e as Error).message}\n`);
    return 2;
  }

  // Early-out: already v2? Don't rewrite (would shuffle key order
  // needlessly + drop comments). We still allow --dry-run to confirm.
  const camelInput = transformCamel(parsed) as Record<string, unknown> | null;
  const inputVersion =
    camelInput && typeof camelInput === 'object' ? camelInput.configVersion : undefined;

  let normalized: ReturnType<typeof normalizeConfig>;
  try {
    normalized = normalizeConfig(camelInput);
  } catch (e) {
    if (e instanceof UnsupportedConfigVersionError) {
      process.stderr.write(`error: ${e.message}\n`);
      return 2;
    }
    process.stderr.write(`error: normalization failed: ${(e as Error).message}\n`);
    return 2;
  }

  if (inputVersion === 2 && !cli.dryRun) {
    process.stdout.write(`already up to date: config_version=2 at ${absPath}\n`);
    return 0;
  }

  // Compose the v2 output. We strip the legacy `provider.*` block because
  // that's the entire point of the migration — but preserve any other
  // top-level keys verbatim (gateway, auth, mcp, ssh, etc.).
  const out: Record<string, unknown> = {};
  out.config_version = 2;
  if (normalized.llm) out.llm = normalized.llm;
  // Skip empty storage blocks — happens when the source file used only
  // defaults (no worktree_dir / custom_pieces_dir / etc. overrides).
  if (normalized.storage && Object.keys(normalized.storage).length > 0) {
    out.storage = normalized.storage;
  }

  // Pass-through: every top-level key from input *except* the ones the v2
  // layout supersedes. We deliberately keep `gateway`, `auth`, `branding`,
  // `mcp`, `ssh`, `tools`, `reflection`, `notes`, `safety`, `context`,
  // `subtasks`, `ask`, `retry`, `concurrency`, `maxMovements`, `secrets`,
  // `searchFilter`, `browser`, `customPiecesDir`, `userFolderRoot`,
  // `worktreeDir`.
  //
  // The supersession list intentionally drops the *individual* legacy
  // keys that are now under storage.* so users get a clean v2 file. They
  // can re-add them as overrides if needed (storage.* always wins).
  const SUPERSEDED = new Set([
    'provider',           // → llm.*
    'configVersion',      // already written as snake above
    'llm',                // already written from normalizer
    'storage',            // already written from normalizer
    'worktreeDir',        // → storage.worktreeDir
    'customPiecesDir',    // → storage.customPiecesDir
    'userFolderRoot',     // → storage.userFolderRoot
  ]);
  const inputObj = (camelInput ?? {}) as Record<string, unknown>;
  for (const [k, v] of Object.entries(inputObj)) {
    if (SUPERSEDED.has(k)) continue;
    if (k === 'tools' && v && typeof v === 'object') {
      // Strip tools.task_upload_max_size_mb and tools.trash_retention_days
      // since they migrated into storage.*. Keep every other tools.* key.
      const tools = { ...(v as Record<string, unknown>) };
      delete tools.taskUploadMaxSizeMb;
      delete tools.trashRetentionDays;
      if (Object.keys(tools).length > 0) out.tools = tools;
      continue;
    }
    out[k] = v;
  }

  // Convert camelCase → snake_case for the on-disk YAML.
  // toSnakeKeys handles nested objects + arrays. The keys we wrote directly
  // (config_version, llm, storage, tools) get re-snake'd too — idempotent.
  const snakeOut = toSnakeKeys(out) as Record<string, unknown>;

  // Render. lineWidth: 120 matches ConfigManager.updateConfig's writer.
  let yamlOut: string;
  try {
    yamlOut = stringifyYaml(snakeOut, { lineWidth: 120 });
  } catch (e) {
    process.stderr.write(`error: YAML render failed: ${(e as Error).message}\n`);
    return 2;
  }

  // Re-validate by round-tripping through normalizeConfig once more — if
  // we produced something the loader can't read, we want to know now,
  // not at server restart time.
  try {
    normalizeConfig(transformCamel(parseYaml(yamlOut)));
  } catch (e) {
    process.stderr.write(
      `error: produced YAML does not re-normalize cleanly: ${(e as Error).message}\n`,
    );
    return 2;
  }

  if (cli.dryRun) {
    process.stdout.write(yamlOut);
    if (!yamlOut.endsWith('\n')) process.stdout.write('\n');
    const diff = lineDiff(rawText, yamlOut);
    if (diff) {
      process.stderr.write('--- diff (input vs migrated) ---\n');
      process.stderr.write(diff + '\n');
      process.stderr.write('--- end diff ---\n');
    } else {
      process.stderr.write('(no textual diff)\n');
    }
    return 0;
  }

  // Real write path: backup → write → done.
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const backupPath = `${absPath}.bak-${ts}`;
  try {
    copyFileSync(absPath, backupPath);
  } catch (e) {
    process.stderr.write(`error: backup failed (${backupPath}): ${(e as Error).message}\n`);
    return 1;
  }

  try {
    writeFileSync(absPath, yamlOut, 'utf-8');
  } catch (e) {
    // Roll back from backup. If even the rollback fails, leave both files
    // on disk and surface a loud error.
    try {
      copyFileSync(backupPath, absPath);
      process.stderr.write(
        `error: write failed, rolled back from ${backupPath}: ${(e as Error).message}\n`,
      );
    } catch (rb) {
      process.stderr.write(
        `error: write AND rollback failed. Original at ${backupPath}, ` +
          `current state of ${absPath} unknown: ${(rb as Error).message}\n`,
      );
    }
    return 1;
  }

  process.stdout.write(`migrated ${absPath} → v2; backup at ${backupPath}\n`);
  return 0;
}

const code = main(process.argv.slice(2));
process.exit(code);
