import { existsSync } from "node:fs";

import {
  getCurrentCommit,
  printQueryResult,
  queryRows,
} from "../application/index-engine";
import type { BadgeStyle } from "../application/output-formatters";
import {
  formatAnnotations,
  formatBadge,
  formatBadgeJson,
  formatCodeClimate,
  formatDiff,
  formatDiffJson,
  formatMermaid,
  formatSarif,
  noLocatableFindingsWarning,
} from "../application/output-formatters";
import { compareQueryBaseline } from "../application/query-baseline";
import { attachActions } from "../application/query-engine";
import {
  getQueryRecipeActionsRendered,
  getQueryRecipeCatalogEntry,
  getQueryRecipeParams,
  getQueryRecipeSql,
  listQueryRecipeCatalog,
  listQueryRecipeIds,
  QUERY_RECIPES,
} from "../application/query-recipes";
import {
  mergeParams,
  parseParamsCli,
  resolveRecipeParams,
} from "../application/recipe-params";
import type {
  RecipeParamValue,
  RecipeParamValues,
} from "../application/recipe-params";
import {
  enrichWithRecency,
  resolveRecencyDbPath,
  tryRecordRecipeRun,
} from "../application/recipe-recency";
import {
  closeDb,
  deleteQueryBaseline,
  listQueryBaselines,
  openDb,
  upsertQueryBaseline,
} from "../db";
import { filterRowsByChangedFiles, getFilesChangedSince } from "../git-changed";
import type { Bucketizer, GroupByMode } from "../group-by";
import {
  discoverWorkspaceRoots,
  firstDirectory,
  GROUP_BY_MODES,
  groupRowsBy,
  isGroupByMode,
  loadCodeowners,
  makePackageBucketizer,
} from "../group-by";
import { getProjectRoot } from "../runtime";
import { openCodemapDatabase } from "../sqlite-db";
import { bootstrapCodemap } from "./bootstrap-codemap";

/**
 * Parse `argv` after the global bootstrap: `rest[0]` must be `"query"`.
 * Supports `--json`, `--recipe <id>`, `--recipes-json`, `--print-sql <id>`, and raw SQL (see {@link printQueryCmdHelp}).
 */
/**
 * Output formats `codemap query` can emit. `text` (default) → console.table;
 * `json` → existing JSON envelope; `sarif` → SARIF 2.1.0 doc; `annotations`
 * → GitHub Actions `::notice file=…,line=…::msg` lines (one per row).
 *
 * `--format` overrides `--json` when both are passed; `--json` stays as the
 * alias for `--format json`. See [`docs/architecture.md` § Output formatters](../../docs/architecture.md#cli-usage).
 */
export const OUTPUT_FORMATS = [
  "text",
  "json",
  "sarif",
  "annotations",
  "mermaid",
  "diff",
  "diff-json",
  "codeclimate",
  "badge",
] as const;
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];

export const BADGE_STYLES = ["markdown", "json"] as const;

export function isOutputFormat(s: string): s is OutputFormat {
  return (OUTPUT_FORMATS as readonly string[]).includes(s);
}

export function isBadgeStyle(s: string): s is BadgeStyle {
  return (BADGE_STYLES as readonly string[]).includes(s);
}

export function parseQueryRest(rest: string[]):
  | { kind: "help" }
  | { kind: "error"; message: string }
  | {
      kind: "run";
      sql: string;
      json: boolean;
      format: OutputFormat;
      /** `--badge-style` when `--format badge` (default `markdown`). */
      badgeStyle: BadgeStyle;
      /** `--ci` aliases `--format sarif` + non-zero exit + quiet. */
      ci: boolean;
      summary: boolean;
      changedSince: string | undefined;
      recipeId: string | undefined;
      groupBy: GroupByMode | undefined;
      saveBaseline: string | true | undefined;
      baseline: string | true | undefined;
      recipeParams?: RecipeParamValues | undefined;
    }
  | { kind: "recipesCatalog" }
  | { kind: "printRecipeSql"; id: string }
  | { kind: "listBaselines"; json: boolean }
  | { kind: "dropBaseline"; name: string; json: boolean } {
  if (rest[0] !== "query") {
    throw new Error("parseQueryRest: expected query");
  }
  if (rest.length === 1) {
    return {
      kind: "error",
      message:
        'codemap: missing SQL or recipe. Usage: codemap query [--json | --format <fmt>] [--summary] [--changed-since <ref>] [--group-by <mode>] "<SQL>" | codemap query [...] --recipe <id> | codemap query --recipes-json | codemap query --print-sql <id>\nRun codemap query --help for more.',
    };
  }

  let i = 1;
  let json = false;
  let format: OutputFormat | undefined;
  // Aliases `--format sarif` + non-zero exit + quiet (audit `--ci` omits quiet).
  let ci = false;
  let summary = false;
  let changedSince: string | undefined;
  let recipeId: string | undefined;
  let recipesJson = false;
  let printSqlId: string | undefined;
  let groupBy: GroupByMode | undefined;
  let listBaselines = false;
  let dropBaselineName: string | undefined;
  let saveBaseline: string | true | undefined;
  let baseline: string | true | undefined;
  let recipeParams: RecipeParamValues | undefined;
  let badgeStyle: BadgeStyle = "markdown";

  while (i < rest.length) {
    const a = rest[i];
    if (a === "--help" || a === "-h") {
      return { kind: "help" };
    }
    if (a === "--json") {
      json = true;
      i++;
      continue;
    }
    if (a === "--ci") {
      ci = true;
      i++;
      continue;
    }
    if (a === "--badge-style" || a.startsWith("--badge-style=")) {
      const eq = a.indexOf("=");
      const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1];
      if (v === undefined || v === "" || v.startsWith("-")) {
        return {
          kind: "error",
          message: `codemap: "--badge-style" requires a value (${BADGE_STYLES.join(" | ")}).`,
        };
      }
      if (!isBadgeStyle(v)) {
        return {
          kind: "error",
          message: `codemap: unknown --badge-style "${v}". Known styles: ${BADGE_STYLES.join(", ")}.`,
        };
      }
      badgeStyle = v;
      i += eq !== -1 ? 1 : 2;
      continue;
    }
    if (a === "--format" || a.startsWith("--format=")) {
      const eq = a.indexOf("=");
      const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1];
      if (v === undefined || v === "" || v.startsWith("-")) {
        return {
          kind: "error",
          message: `codemap: "--format" requires a value (${OUTPUT_FORMATS.join(" | ")}).`,
        };
      }
      if (!isOutputFormat(v)) {
        return {
          kind: "error",
          message: `codemap: unknown --format "${v}". Known formats: ${OUTPUT_FORMATS.join(", ")}.`,
        };
      }
      format = v;
      i += eq !== -1 ? 1 : 2;
      continue;
    }
    if (a === "--summary") {
      summary = true;
      i++;
      continue;
    }
    if (a === "--changed-since") {
      const ref = rest[i + 1];
      if (ref === undefined || ref.startsWith("-")) {
        return {
          kind: "error",
          message:
            'codemap: "--changed-since" requires a git ref. Example: codemap query --changed-since origin/main -r fan-out',
        };
      }
      changedSince = ref;
      i += 2;
      continue;
    }
    if (a === "--group-by") {
      const mode = rest[i + 1];
      if (mode === undefined || mode.startsWith("-")) {
        return {
          kind: "error",
          message: `codemap: "--group-by" requires a mode (${GROUP_BY_MODES.join(" | ")}).`,
        };
      }
      if (!isGroupByMode(mode)) {
        return {
          kind: "error",
          message: `codemap: unknown --group-by mode "${mode}". Known modes: ${GROUP_BY_MODES.join(", ")}.`,
        };
      }
      groupBy = mode;
      i += 2;
      continue;
    }
    if (a === "--save-baseline" || a.startsWith("--save-baseline=")) {
      const eq = a.indexOf("=");
      if (eq !== -1) {
        const v = a.slice(eq + 1);
        if (!v) {
          return {
            kind: "error",
            message:
              'codemap: "--save-baseline=<name>" requires a non-empty name. Drop the "=" to use the recipe id as the default name.',
          };
        }
        saveBaseline = v;
        i++;
        continue;
      }
      const next = rest[i + 1];
      if (next === "") {
        return {
          kind: "error",
          message:
            'codemap: "--save-baseline=<name>" requires a non-empty name. Drop the "=" to use the recipe id as the default name.',
        };
      }
      if (next !== undefined && !next.startsWith("-")) {
        saveBaseline = next;
        i += 2;
        continue;
      }
      saveBaseline = true;
      i++;
      continue;
    }
    if (a === "--baseline" || a.startsWith("--baseline=")) {
      const eq = a.indexOf("=");
      if (eq !== -1) {
        const v = a.slice(eq + 1);
        if (!v) {
          return {
            kind: "error",
            message:
              'codemap: "--baseline=<name>" requires a non-empty name. Drop the "=" to use the recipe id as the default name.',
          };
        }
        baseline = v;
        i++;
        continue;
      }
      const next = rest[i + 1];
      if (next === "") {
        return {
          kind: "error",
          message:
            'codemap: "--baseline=<name>" requires a non-empty name. Drop the "=" to use the recipe id as the default name.',
        };
      }
      if (next !== undefined && !next.startsWith("-")) {
        baseline = next;
        i += 2;
        continue;
      }
      baseline = true;
      i++;
      continue;
    }
    if (a === "--baselines") {
      listBaselines = true;
      i++;
      continue;
    }
    if (a === "--drop-baseline") {
      const name = rest[i + 1];
      if (name === undefined || name.startsWith("-")) {
        return {
          kind: "error",
          message:
            'codemap: "--drop-baseline" requires a name. Example: codemap query --drop-baseline pre-refactor',
        };
      }
      dropBaselineName = name;
      i += 2;
      continue;
    }
    if (a === "--recipes-json") {
      recipesJson = true;
      i++;
      continue;
    }
    if (a === "--print-sql") {
      const name = rest[i + 1];
      if (name === undefined || name.startsWith("-")) {
        return {
          kind: "error",
          message:
            'codemap: "--print-sql" requires a recipe id. Example: codemap query --print-sql fan-out',
        };
      }
      printSqlId = name;
      i += 2;
      continue;
    }
    if (a === "--recipe" || a === "-r") {
      const name = rest[i + 1];
      if (name === undefined || name.startsWith("-")) {
        return {
          kind: "error",
          message: `codemap: "${a}" requires a recipe id. Example: codemap query ${a} fan-out`,
        };
      }
      recipeId = name;
      i += 2;
      continue;
    }
    if (a === "--params" || a.startsWith("--params=")) {
      const eq = a.indexOf("=");
      const v = eq !== -1 ? a.slice(eq + 1) : rest[i + 1];
      if (v === undefined || v === "" || v.startsWith("-")) {
        return {
          kind: "error",
          message:
            'codemap: "--params" requires key=value pairs. Example: --params old=foo,new=bar',
        };
      }
      recipeParams = mergeParams(recipeParams, parseParamsCli(v));
      i += eq !== -1 ? 1 : 2;
      continue;
    }
    break;
  }

  if (recipesJson) {
    if (
      recipeId !== undefined ||
      printSqlId !== undefined ||
      recipeParams !== undefined
    ) {
      return {
        kind: "error",
        message:
          "codemap: --recipes-json cannot be combined with --recipe, --params, or --print-sql.",
      };
    }
    if (i < rest.length) {
      return {
        kind: "error",
        message:
          "codemap: --recipes-json does not take SQL or extra arguments.",
      };
    }
    return { kind: "recipesCatalog" };
  }

  if (listBaselines) {
    if (
      recipeId !== undefined ||
      printSqlId !== undefined ||
      saveBaseline !== undefined ||
      baseline !== undefined ||
      dropBaselineName !== undefined ||
      summary ||
      changedSince !== undefined ||
      groupBy !== undefined ||
      recipeParams !== undefined ||
      i < rest.length
    ) {
      return {
        kind: "error",
        message:
          "codemap: --baselines is a list-only operation; it does not take SQL or any other --recipe / --params / --save-baseline / --baseline / --drop-baseline / --summary / --changed-since / --group-by flag.",
      };
    }
    return { kind: "listBaselines", json };
  }

  if (dropBaselineName !== undefined) {
    if (
      recipeId !== undefined ||
      printSqlId !== undefined ||
      saveBaseline !== undefined ||
      baseline !== undefined ||
      summary ||
      changedSince !== undefined ||
      groupBy !== undefined ||
      recipeParams !== undefined ||
      i < rest.length
    ) {
      return {
        kind: "error",
        message:
          "codemap: --drop-baseline only takes a name; it does not compose with SQL or any other --recipe / --params / --save-baseline / --baseline / --summary / --changed-since / --group-by flag.",
      };
    }
    return { kind: "dropBaseline", name: dropBaselineName, json };
  }

  if (saveBaseline !== undefined && baseline !== undefined) {
    return {
      kind: "error",
      message:
        "codemap: --save-baseline and --baseline are mutually exclusive in one run.",
    };
  }
  if (recipeParams !== undefined && saveBaseline === true) {
    return {
      kind: "error",
      message:
        'codemap: "--save-baseline" needs an explicit name when used with --params so different parameter sets do not overwrite each other. Use --save-baseline=<name>.',
    };
  }
  if (recipeParams !== undefined && baseline === true) {
    return {
      kind: "error",
      message:
        'codemap: "--baseline" needs an explicit name when used with --params. Use --baseline=<name>.',
    };
  }
  if (
    groupBy !== undefined &&
    (saveBaseline !== undefined || baseline !== undefined)
  ) {
    return {
      kind: "error",
      message:
        "codemap: --group-by cannot be combined with --save-baseline or --baseline (different output shapes).",
    };
  }

  if (printSqlId !== undefined) {
    if (recipeId !== undefined) {
      return {
        kind: "error",
        message: "codemap: use either --recipe or --print-sql, not both.",
      };
    }
    if (recipeParams !== undefined) {
      return {
        kind: "error",
        message: "codemap: --params can only be used with --recipe.",
      };
    }
    if (i < rest.length) {
      return {
        kind: "error",
        message:
          "codemap: --print-sql does not take a SQL string; only the recipe id.",
      };
    }
    const sql = getQueryRecipeSql(printSqlId);
    if (sql === undefined) {
      const known = listQueryRecipeIds().join(", ");
      return {
        kind: "error",
        message: `codemap: unknown recipe "${printSqlId}". Known recipes: ${known}`,
      };
    }
    return { kind: "printRecipeSql", id: printSqlId };
  }

  if (recipeId !== undefined) {
    if (i < rest.length) {
      return {
        kind: "error",
        message:
          "codemap: --recipe does not take a SQL string; remove arguments after the recipe id.",
      };
    }
    const sql = getQueryRecipeSql(recipeId);
    if (sql === undefined) {
      const known = listQueryRecipeIds().join(", ");
      return {
        kind: "error",
        message: `codemap: unknown recipe "${recipeId}". Known recipes: ${known}`,
      };
    }
    const resolved = resolveFormat(format, json, ci);
    if (resolved instanceof Error) {
      return { kind: "error", message: resolved.message };
    }
    const incompat = formatIncompatibility(resolved, {
      summary,
      groupBy,
      saveBaseline,
      baseline,
    });
    if (incompat !== undefined) return { kind: "error", message: incompat };
    if (badgeStyle !== "markdown" && resolved !== "badge") {
      return {
        kind: "error",
        message: 'codemap: "--badge-style" is only valid with --format badge.',
      };
    }
    return {
      kind: "run",
      sql,
      json,
      format: resolved,
      badgeStyle,
      ci,
      summary,
      changedSince,
      recipeId,
      groupBy,
      saveBaseline,
      baseline,
      recipeParams,
    };
  }

  const sql = rest.slice(i).join(" ").trim();
  if (!sql) {
    return {
      kind: "error",
      message:
        'codemap: missing SQL or recipe. Usage: codemap query [--json | --format <fmt>] [--summary] [--changed-since <ref>] [--group-by <mode>] [--save-baseline[=<name>] | --baseline[=<name>]] "<SQL>" | codemap query [...] --recipe <id> | codemap query --recipes-json | codemap query --print-sql <id> | codemap query --baselines | codemap query --drop-baseline <name>',
    };
  }
  if (recipeParams !== undefined) {
    return {
      kind: "error",
      message: "codemap: --params can only be used with --recipe.",
    };
  }
  // Ad-hoc SQL needs an explicit baseline name (no recipe id default).
  if (saveBaseline === true) {
    return {
      kind: "error",
      message:
        'codemap: "--save-baseline" needs an explicit name when used without --recipe (recipe id is the default name otherwise). Use --save-baseline=<name>.',
    };
  }
  if (baseline === true) {
    return {
      kind: "error",
      message:
        'codemap: "--baseline" needs an explicit name when used without --recipe. Use --baseline=<name>.',
    };
  }
  const resolved = resolveFormat(format, json, ci);
  if (resolved instanceof Error) {
    return { kind: "error", message: resolved.message };
  }
  const incompat = formatIncompatibility(resolved, {
    summary,
    groupBy,
    saveBaseline,
    baseline,
  });
  if (incompat !== undefined) return { kind: "error", message: incompat };
  if (badgeStyle !== "markdown" && resolved !== "badge") {
    return {
      kind: "error",
      message: 'codemap: "--badge-style" is only valid with --format badge.',
    };
  }
  return {
    kind: "run",
    sql,
    json,
    format: resolved,
    badgeStyle,
    ci,
    summary,
    changedSince,
    recipeId: undefined,
    groupBy,
    saveBaseline,
    baseline,
    recipeParams: undefined,
  };
}

/**
 * Per plan § D9: `--format` > `--json` > default `text`.
 * `--ci` aliases `--format sarif`; rejects `--json` and `--format <non-sarif>`.
 */
function resolveFormat(
  explicit: OutputFormat | undefined,
  json: boolean,
  ci: boolean,
): OutputFormat | Error {
  if (ci) {
    if (json) {
      return new Error(
        'codemap: "--ci" and "--json" are mutually exclusive (--ci aliases --format sarif; --json aliases --format json).',
      );
    }
    if (explicit !== undefined && explicit !== "sarif") {
      return new Error(
        `codemap: "--ci" aliases "--format sarif"; cannot combine with --format ${explicit}.`,
      );
    }
    return "sarif";
  }
  if (explicit !== undefined) return explicit;
  return json ? "json" : "text";
}

/**
 * Reject combinations of non-`text`/`json` `--format` values with flags that
 * change the output shape away from "flat row list" (group-by buckets,
 * summary counts, baseline diffs). Returns an error message or `undefined`.
 *
 * Trade-off: keeps SARIF / annotations on the cleanest row → finding mapping
 * for v1; aggregate/diff shapes can be re-introduced if a real consumer asks.
 */
function formatIncompatibility(
  fmt: OutputFormat,
  opts: {
    summary: boolean;
    groupBy: GroupByMode | undefined;
    saveBaseline: string | true | undefined;
    baseline: string | true | undefined;
  },
): string | undefined {
  if (
    fmt !== "sarif" &&
    fmt !== "annotations" &&
    fmt !== "mermaid" &&
    fmt !== "diff" &&
    fmt !== "diff-json" &&
    fmt !== "codeclimate" &&
    fmt !== "badge"
  )
    return undefined;
  const offenders: string[] = [];
  if (opts.summary) offenders.push("--summary");
  if (opts.groupBy !== undefined) offenders.push("--group-by");
  if (opts.saveBaseline !== undefined) offenders.push("--save-baseline");
  if (opts.baseline !== undefined) offenders.push("--baseline");
  if (offenders.length === 0) return undefined;
  return `codemap: --format ${fmt} cannot be combined with ${offenders.join(", ")} (different output shapes — formatted outputs only support flat row lists).`;
}

/**
 * Print the recipe catalog (bundled + project-local) as JSON to stdout. Each entry carries
 * `last_run_at` + `run_count` recency fields when an indexed DB exists,
 * else null/0 fallbacks. The verb runs before `bootstrapCodemap()` (the
 * catalog has historically been "no DB required") — keep it side-effect
 * free by using a path-based opener instead of `initCodemap()`.
 */
export function printRecipesCatalogJson(opts?: {
  root?: string;
  stateDir?: string | undefined;
}): void {
  const root = opts?.root;
  const dbFactory =
    root === undefined
      ? undefined
      : () => {
          const dbPath = resolveRecencyDbPath({
            root,
            stateDir: opts?.stateDir,
          });
          // Throw on never-indexed; enrichWithRecency catches and falls
          // back to null/0 entries (no .codemap dir gets created).
          if (!existsSync(dbPath)) {
            throw new Error(`recipe-recency: no DB at ${dbPath}`);
          }
          return openCodemapDatabase(dbPath);
        };
  const enriched = enrichWithRecency(listQueryRecipeCatalog(), {
    openDb: dbFactory,
  });
  console.log(JSON.stringify(enriched, null, 2));
}

/** Print one recipe's SQL to stdout, or false if the id is unknown (caller should exit 1). */
export function printRecipeSqlToStdout(id: string): boolean {
  const sql = getQueryRecipeSql(id);
  if (sql === undefined) {
    return false;
  }
  console.log(sql);
  return true;
}

function formatRecipeHelpLines(): string {
  const ids = listQueryRecipeIds();
  const width = ids.reduce((n, id) => Math.max(n, id.length), 0);
  const lines = ids.map((id) => {
    const meta = QUERY_RECIPES[id];
    const desc = meta?.description ?? "";
    return `    ${id.padEnd(width)}  ${desc}`;
  });
  return lines.join("\n");
}

/**
 * Print **`codemap query`** usage, flags, and recipe catalog ids to stdout.
 */
export function printQueryCmdHelp(): void {
  const recipeBlock = formatRecipeHelpLines();
  console.log(`Usage: codemap query [--json] [--format <fmt>] [--summary] [--changed-since <ref>] [--group-by <mode>] [--save-baseline[=<name>] | --baseline[=<name>]] "<SQL>"
       codemap query [...] --recipe <id>   (alias: -r)
       codemap query --recipes-json
       codemap query --print-sql <id>
       codemap query --baselines
       codemap query --drop-baseline <name>

Read-only SQL against the codemap index (default \`.codemap/index.db\`; after at least one successful index run).
The CLI does not cap row count — use SQL LIMIT (and ORDER BY) when you need a bounded result set.

Flags:
  --json                  Alias for --format json. Default success shape: JSON
                          array of row objects (or {"count": N} with --summary,
                          {group_by, groups} with --group-by, baseline diff
                          envelope with --baseline). On error, prints
                          {"error":"<message>"} to stdout.
  --format <fmt>          One of: ${OUTPUT_FORMATS.join(" | ")}. Overrides --json when both are passed
                          (so --format text + --json prints text). Default = text.
                            text         Terminal table via console.table (default).
                            json         Same as --json — default row array (see composed shapes above).
                            sarif        SARIF 2.1.0 doc (GitHub Code Scanning); rule.id = codemap.<recipe>
                                         (or codemap.adhoc for ad-hoc SQL); auto-detects file_path / path /
                                         to_path / from_path; aggregate recipes (no location) emit results: [].
                            annotations  GitHub Actions ::notice file=…,line=…::msg per row (PR-inline findings).
                            mermaid      Mermaid flowchart from rows shaped as {from, to, label?, kind?}.
                            diff         Unified diff from rows shaped as {file_path, line_start,
                                         before_pattern, after_pattern}.
                            diff-json    Structured diff envelope for agents.
                            codeclimate  GitLab Code Quality JSON array (severity minor; stable fingerprints).
                            badge        Single-line issue count from locatable rows only
                                         (codemap: N issues / codemap: clean — same contract as SARIF).
                          Formatted outputs require a flat row list — incompatible with --summary,
                          --group-by, --save-baseline, --baseline (parser rejects at parse time).
  --badge-style <style>   With --format badge only: markdown (default) or json (codemap-badge/v1 schema).
                          Agents triage via query JSON rows — badge is presentation for README / CI paste.
  --summary               Print only the row count (no rows). With --json: {"count": N}. Without: count: N.
                          With --group-by, output collapses to {"group_by": "<mode>", "groups": [{key, count}]}.
                          With --baseline, collapses to {baseline, current_row_count, added: N, removed: N}.
                          Useful for dashboards and agent context windows where the rows are noise.
  --changed-since <ref>   Filter result rows to those touching files changed since <ref>. The ref can be
                          any committish (origin/main, HEAD~5, a sha, a tag). Rows are kept if any of
                          path / file_path / from_path / to_path / resolved_path matches the changed set.
                          Rows with no path column pass through (pair with --summary if a count matters).
  --group-by <mode>       Partition result rows by mode = owner | directory | package and print as
                          {"group_by": "<mode>", "groups": [{key, count, rows}]} (with --json) or a
                          two-column table (without). Mode definitions:
                            owner     CODEOWNERS first-listed owner (last matching rule wins).
                                      Looked up in .github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS.
                            directory First path segment (src/cli/foo.ts → src).
                            package   Workspace dir from package.json/workspaces or pnpm-workspace.yaml;
                                      out-of-workspace paths bucket to "<root>".
  --save-baseline[=<name>]
                          Snapshot the result rows to the query_baselines table inside \`<state-dir>/index.db\`
                          (default \`.codemap/index.db\`; no parallel JSON files; survives --full and SCHEMA bumps).
                          for later --baseline diffs. Name defaults to the --recipe id; ad-hoc SQL
                          must pass an explicit =<name>. Stores SQL, rows, row count, current git
                          HEAD (when available), and a timestamp. Re-saving with the same name
                          overwrites in place. Survives --full and SCHEMA_VERSION rebuilds.
  --baseline[=<name>]     Diff the current result against the saved baseline of the same name.
                          Output: {baseline:{...}, current_row_count, added: [...], removed: [...]}
                          (with --json) or a two-section terminal dump. Set membership uses
                          JSON.stringify(row) — exact-match identity, no fuzzy "changed" category.
                          Recipe actions, when defined, attach to the added rows only.
  --baselines             List saved baselines (name, recipe_id, row_count, git_ref, created_at).
  --drop-baseline <name>  Delete a saved baseline. Exits 1 if the name doesn't exist.
  --recipe, -r <id>       Run recipe SQL by id (bundled or project-local; no SQL string on the command line).
  --params <k=v[,k=v]>    Bind params for a parametrised recipe. May be repeated;
                          last value wins on duplicate keys. Example:
                          --params kind=function,name_pattern=%Query%
  --recipes-json          Print the full recipe catalog (id, description, sql, source, …) as JSON. No DB.
  --print-sql <id>        Print one recipe's SQL text to stdout (does not run the query). No DB.
  --help, -h              Show this help.

Recipe catalog:
${recipeBlock}

Examples:
  # Ad-hoc SQL
  codemap query "SELECT name, file_path FROM symbols LIMIT 10"
  codemap query --json "SELECT COUNT(*) AS n FROM symbols"

  # Bundled recipe (full flag and short alias)
  codemap query --recipe fan-out
  codemap query -r fan-out
  codemap query --json -r deprecated-symbols

  # Counts only (skip the rows)
  codemap query --json --summary -r deprecated-symbols
  codemap query --summary "SELECT * FROM symbols WHERE doc_comment LIKE '%@todo%'"

  # PR-scoped: rows touching files changed since main
  codemap query --json --changed-since origin/main -r fan-out
  codemap query --json --summary --changed-since HEAD~5 "SELECT file_path FROM symbols"

  # Group by directory / owner / workspace package
  codemap query --json --group-by directory -r fan-in
  codemap query --json --summary --group-by owner -r deprecated-symbols
  codemap query --json --summary --group-by package "SELECT file_path FROM symbols"

  # Snapshot a result, refactor, then diff
  codemap query --save-baseline -r visibility-tags          # save under name "visibility-tags"
  # ... refactor ...
  codemap query --json --baseline -r visibility-tags        # full diff
  codemap query --json --summary --baseline -r visibility-tags   # counts only
  codemap query --save-baseline=pre-refactor "SELECT name, file_path FROM symbols WHERE visibility = 'beta'"
  codemap query --baseline=pre-refactor "SELECT name, file_path FROM symbols WHERE visibility = 'beta'"
  codemap query --baselines                                 # list
  codemap query --drop-baseline pre-refactor                # delete

  # Inspect recipes without touching the DB
  codemap query --recipes-json
  codemap query --print-sql fan-out
`);
}

/**
 * Initialize Codemap for `opts.root`, then run **`printQueryResult`**.
 * Sets **`process.exitCode`** on failure (no **`process.exit`**). With **`--json`**, bootstrap errors print **`{"error":"…"}`** on stdout like query failures.
 */
export async function runQueryCmd(opts: {
  root: string;
  configFile: string | undefined;
  stateDir?: string | undefined;
  sql: string;
  json?: boolean;
  /**
   * Output format. Defaults to `"text"` for back-compat (callers that
   * pre-date the `--format` flag still work). When `"sarif"` or
   * `"annotations"`, group-by / summary / baseline are not allowed —
   * caller must reject those combos at parse time.
   */
  format?: OutputFormat;
  /** `--badge-style` when `format=badge` (default `markdown`). */
  badgeStyle?: BadgeStyle;
  /** `--ci`: exit 1 on ≥1 row + suppress no-locatable-rows warning. Parser enforces format=sarif. */
  ci?: boolean;
  summary?: boolean;
  changedSince?: string | undefined;
  recipeId?: string | undefined;
  groupBy?: GroupByMode | undefined;
  saveBaseline?: string | true | undefined;
  baseline?: string | true | undefined;
  recipeParams?: RecipeParamValues | undefined;
}): Promise<void> {
  // Resolve --format / --json once so every render path keys off the same
  // value. Pre-PR #43 callers pass only `json`; post-PR #43 the parser
  // pre-resolves into `format` (so `--format text --json` correctly emits
  // text per design § D9). Keep `isJson` as the boolean every downstream
  // helper expects until they're refactored to take an OutputFormat.
  const effectiveFormat: OutputFormat =
    opts.format ?? (opts.json === true ? "json" : "text");
  const isJson = effectiveFormat === "json";
  // Every non-text format expects a structured `{"error":"..."}` envelope
  // (formatter-side errors already emit one in `printFormattedQuery`).
  // Without this, bootstrap / param / `--changed-since` failures would
  // emit plain stderr for `--format diff-json` / `sarif` / etc., breaking
  // pipelines that key off the JSON envelope.
  const structuredErrors = effectiveFormat !== "text";
  // Recency tracker reads this in finally. Don't use process.exitCode:
  // --ci sets it to 1 on findings (success, not failure); exitCode also
  // poisons across calls when this helper runs multiple times per process.
  let recipeQuerySucceeded = false;
  try {
    await bootstrapCodemap(opts);

    let changedFiles: Set<string> | undefined;
    if (opts.changedSince !== undefined) {
      const result = getFilesChangedSince(opts.changedSince, getProjectRoot());
      if (!result.ok) {
        emitErrorMaybeJson(result.error, structuredErrors);
        return;
      }
      changedFiles = result.files;
    }

    const recipeActions =
      opts.recipeId !== undefined
        ? getQueryRecipeActionsRendered(opts.recipeId, opts.recipeParams)
        : undefined;
    const bindValues = resolveRecipeBindValues({
      recipeId: opts.recipeId,
      params: opts.recipeParams,
      json: structuredErrors,
    });
    if ("error" in bindValues) return;

    // Baseline ops branch off here — they don't compose with --group-by because
    // the diff semantics are about row identity, not bucketing. (--summary still
    // composes: collapses the diff to {added: N, removed: N}.)
    if (opts.saveBaseline !== undefined) {
      const before = process.exitCode;
      runSaveBaseline({
        sql: opts.sql,
        json: isJson,
        recipeId: opts.recipeId,
        baselineName:
          opts.saveBaseline === true
            ? (opts.recipeId as string)
            : opts.saveBaseline,
        changedFiles,
        bindValues: bindValues.values,
      });
      if (process.exitCode !== 1 || before === 1) recipeQuerySucceeded = true;
      return;
    }
    if (opts.baseline !== undefined) {
      const before = process.exitCode;
      runBaselineDiff({
        sql: opts.sql,
        json: isJson,
        summary: opts.summary === true,
        baselineName:
          opts.baseline === true ? (opts.recipeId as string) : opts.baseline,
        changedFiles,
        recipeActions,
        bindValues: bindValues.values,
      });
      if (process.exitCode !== 1 || before === 1) recipeQuerySucceeded = true;
      return;
    }

    if (opts.groupBy !== undefined) {
      const before = process.exitCode;
      runGroupedQuery({
        sql: opts.sql,
        json: isJson,
        summary: opts.summary === true,
        groupBy: opts.groupBy,
        changedFiles,
        recipeActions,
        bindValues: bindValues.values,
        root: getProjectRoot(),
      });
      if (process.exitCode !== 1 || before === 1) recipeQuerySucceeded = true;
      return;
    }

    if (effectiveFormat !== "text" && effectiveFormat !== "json") {
      const result = printFormattedQuery(opts.sql, {
        format: effectiveFormat,
        recipeId: opts.recipeId,
        changedFiles,
        bindValues: bindValues.values,
        ci: opts.ci === true,
        badgeStyle: opts.badgeStyle ?? "markdown",
      });
      if (result.ok) {
        // exitCode=1 here is the --ci gating signal, not a failure.
        recipeQuerySucceeded = true;
        if (result.exitCode !== 0) process.exitCode = result.exitCode;
      } else {
        process.exitCode = 1;
      }
      return;
    }

    const code = printQueryResult(opts.sql, {
      json: isJson,
      summary: opts.summary,
      changedFiles,
      recipeActions,
      bindValues: bindValues.values,
    });
    if (code === 0) recipeQuerySucceeded = true;
    if (code !== 0) process.exitCode = code;
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    emitErrorMaybeJson(msg, structuredErrors);
  } finally {
    if (opts.recipeId !== undefined && recipeQuerySucceeded) {
      tryRecordRecipeRun(opts.recipeId);
    }
  }
}

function resolveRecipeBindValues(opts: {
  recipeId: string | undefined;
  params: RecipeParamValues | undefined;
  json: boolean;
}): { values: RecipeParamValue[] } | { error: true } {
  if (opts.recipeId === undefined) return { values: [] };
  const resolved = resolveRecipeParams({
    recipeId: opts.recipeId,
    declared: getQueryRecipeParams(opts.recipeId),
    provided: opts.params,
  });
  if (!resolved.ok) {
    emitErrorMaybeJson(resolved.error, opts.json);
    return { error: true };
  }
  return { values: resolved.values };
}

/** Bootstrap a DB connection and run the list-baselines command. */
export async function runListBaselinesCmd(opts: {
  root: string;
  configFile: string | undefined;
  stateDir?: string | undefined;
  json: boolean;
}): Promise<void> {
  try {
    await bootstrapCodemap(opts);
    const db = openDb();
    try {
      const rows = listQueryBaselines(db);
      if (opts.json) {
        console.log(JSON.stringify(rows));
      } else if (rows.length === 0) {
        console.log("(no baselines)");
      } else {
        console.table(rows);
      }
    } finally {
      closeDb(db, { readonly: true });
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    emitErrorMaybeJson(msg, opts.json);
  }
}

/** Bootstrap a DB connection and drop the named baseline; exits 1 on miss. */
export async function runDropBaselineCmd(opts: {
  root: string;
  configFile: string | undefined;
  stateDir?: string | undefined;
  name: string;
  json: boolean;
}): Promise<void> {
  try {
    await bootstrapCodemap(opts);
    const db = openDb();
    try {
      const dropped = deleteQueryBaseline(db, opts.name);
      if (!dropped) {
        emitErrorMaybeJson(
          `codemap: no baseline named "${opts.name}". Use --baselines to list saved baselines.`,
          opts.json,
        );
        return;
      }
      if (opts.json) {
        console.log(JSON.stringify({ dropped: opts.name }));
      } else {
        console.log(`Dropped baseline: ${opts.name}`);
      }
    } finally {
      closeDb(db);
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    emitErrorMaybeJson(msg, opts.json);
  }
}

/**
 * Run `sql` and emit results in `--format sarif` or `--format annotations`.
 * Open / read / close pattern matches `printQueryResult`. Errors emit a
 * single-line `{"error": "..."}` JSON envelope on stdout (stable across
 * formatters so callers can branch on format). Returns 0 on success, 1 on
 * any error.
 *
 * Empty / no-location row sets emit a valid SARIF doc with `results: []`
 * + a stderr warning so the operator knows the recipe doesn't have a
 * findings shape; annotations emit nothing + the same warning. See
 * [`docs/architecture.md` § Output formatters](../../docs/architecture.md#cli-usage).
 */
/**
 * Disambiguates a CI-gating exit (recipe SUCCEEDED, `--ci` wants exit 1
 * to flag findings) from a real failure (SQL error, formatter rejection).
 * Callers (recency tracker, etc.) need this distinction.
 */
type FormattedQueryResult = { ok: true; exitCode: 0 | 1 } | { ok: false };

function printFormattedQuery(
  sql: string,
  opts: {
    format: Exclude<OutputFormat, "text" | "json">;
    recipeId: string | undefined;
    changedFiles: Set<string> | undefined;
    bindValues: RecipeParamValue[] | undefined;
    /** `--ci`: suppress no-locatable-rows warning + exit 1 on `rows.length > 0`. */
    ci?: boolean;
    badgeStyle: BadgeStyle;
  },
): FormattedQueryResult {
  try {
    let rows = queryRows(sql, opts.bindValues) as Record<string, unknown>[];
    if (opts.changedFiles !== undefined) {
      rows = filterRowsByChangedFiles(rows, opts.changedFiles) as Record<
        string,
        unknown
      >[];
    }

    const locWarning = noLocatableFindingsWarning(opts.format, rows, {
      ci: opts.ci === true,
    });
    if (locWarning !== undefined) console.error(locWarning);

    const catalog =
      opts.recipeId !== undefined
        ? getQueryRecipeCatalogEntry(opts.recipeId)
        : undefined;

    if (opts.format === "sarif") {
      const out = formatSarif({
        rows,
        recipeId: opts.recipeId,
        recipeDescription: catalog?.description,
        recipeBody: catalog?.body,
      });
      console.log(out);
      // `--ci` gates the runner step on findings (non-zero exit).
      return {
        ok: true,
        exitCode: opts.ci === true && rows.length > 0 ? 1 : 0,
      };
    }

    if (opts.format === "mermaid") {
      console.log(formatMermaid({ rows, recipeId: opts.recipeId }));
      return { ok: true, exitCode: 0 };
    }

    if (opts.format === "diff") {
      console.log(formatDiff({ rows, projectRoot: getProjectRoot() }));
      return { ok: true, exitCode: 0 };
    }

    if (opts.format === "diff-json") {
      console.log(formatDiffJson({ rows, projectRoot: getProjectRoot() }));
      return { ok: true, exitCode: 0 };
    }

    if (opts.format === "codeclimate") {
      const formatOpts = {
        rows,
        recipeId: opts.recipeId,
      };
      console.log(formatCodeClimate(formatOpts));
      return { ok: true, exitCode: 0 };
    }

    if (opts.format === "badge") {
      const formatOpts = {
        rows,
        recipeId: opts.recipeId,
      };
      const out =
        opts.badgeStyle === "json"
          ? formatBadgeJson(formatOpts)
          : formatBadge(formatOpts);
      console.log(out);
      return { ok: true, exitCode: 0 };
    }

    const annotations = formatAnnotations({ rows, recipeId: opts.recipeId });
    if (annotations.length > 0) console.log(annotations);
    return { ok: true, exitCode: 0 };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    console.log(JSON.stringify({ error: msg }));
    return { ok: false };
  }
}

function emitErrorMaybeJson(message: string, json: boolean | undefined) {
  if (json === true) {
    console.log(JSON.stringify({ error: message }));
  } else {
    console.error(message);
  }
  process.exitCode = 1;
}

function runGroupedQuery(opts: {
  sql: string;
  json: boolean;
  summary: boolean;
  groupBy: GroupByMode;
  changedFiles: Set<string> | undefined;
  recipeActions: ReadonlyArray<unknown> | undefined;
  bindValues: RecipeParamValue[] | undefined;
  root: string;
}) {
  let bucketize: Bucketizer;
  if (opts.groupBy === "owner") {
    const ownerBucketize = loadCodeowners(opts.root);
    if (ownerBucketize === null) {
      emitErrorMaybeJson(
        "--group-by owner: no CODEOWNERS file found (looked in .github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS).",
        opts.json,
      );
      return;
    }
    bucketize = ownerBucketize;
  } else if (opts.groupBy === "package") {
    const roots = discoverWorkspaceRoots(opts.root);
    bucketize = makePackageBucketizer(roots);
  } else {
    bucketize = (path: string) => firstDirectory(path);
  }

  let rows: unknown[];
  try {
    rows = queryRows(opts.sql, opts.bindValues);
  } catch (err) {
    emitErrorMaybeJson(
      err instanceof Error ? err.message : String(err),
      opts.json,
    );
    return;
  }

  if (opts.changedFiles !== undefined) {
    rows = filterRowsByChangedFiles(rows, opts.changedFiles);
  }

  const enriched =
    opts.recipeActions !== undefined && opts.recipeActions.length > 0
      ? rows.map((row) => attachActions(row, opts.recipeActions!))
      : rows;

  const noBucketLabel = opts.groupBy === "owner" ? "<no-owner>" : "<unknown>";
  const grouped = groupRowsBy(enriched, bucketize, noBucketLabel);

  if (opts.json) {
    if (opts.summary) {
      console.log(
        JSON.stringify({
          group_by: opts.groupBy,
          groups: grouped.map((g) => ({ key: g.key, count: g.count })),
        }),
      );
    } else {
      console.log(
        JSON.stringify({
          group_by: opts.groupBy,
          groups: grouped,
        }),
      );
    }
    return;
  }

  if (grouped.length === 0) {
    console.log("(no results)");
    return;
  }
  if (opts.summary) {
    console.log(`group_by: ${opts.groupBy}`);
    for (const g of grouped) console.log(`  ${g.key}: ${g.count}`);
    return;
  }
  console.log(`group_by: ${opts.groupBy}`);
  console.table(grouped.map((g) => ({ key: g.key, count: g.count })));
}

// `git rev-parse HEAD` may legitimately fail (no git, detached worktree, etc.).
// Baselines just record git_ref = NULL in that case — no fatal error.
function tryGetGitRef(): string | null {
  try {
    const sha = getCurrentCommit();
    return sha || null;
  } catch {
    return null;
  }
}

function runSaveBaseline(opts: {
  sql: string;
  json: boolean;
  recipeId: string | undefined;
  baselineName: string;
  changedFiles: Set<string> | undefined;
  bindValues: RecipeParamValue[] | undefined;
}) {
  let rows: unknown[];
  try {
    rows = queryRows(opts.sql, opts.bindValues);
  } catch (err) {
    emitErrorMaybeJson(
      err instanceof Error ? err.message : String(err),
      opts.json,
    );
    return;
  }
  if (opts.changedFiles !== undefined) {
    rows = filterRowsByChangedFiles(rows, opts.changedFiles);
  }

  const db = openDb();
  let savedAt: number;
  let gitRef: string | null;
  try {
    savedAt = Date.now();
    gitRef = tryGetGitRef();
    upsertQueryBaseline(db, {
      name: opts.baselineName,
      recipe_id: opts.recipeId ?? null,
      sql: opts.sql,
      rows_json: JSON.stringify(rows),
      row_count: rows.length,
      git_ref: gitRef,
      created_at: savedAt,
    });
  } finally {
    closeDb(db);
  }

  if (opts.json) {
    console.log(
      JSON.stringify({
        saved: opts.baselineName,
        recipe_id: opts.recipeId ?? null,
        row_count: rows.length,
        git_ref: gitRef,
        created_at: savedAt,
      }),
    );
  } else {
    const ref = gitRef ? ` @ ${gitRef.slice(0, 8)}` : "";
    console.log(
      `Saved baseline "${opts.baselineName}" (${rows.length} rows${ref}).`,
    );
  }
}

function runBaselineDiff(opts: {
  sql: string;
  json: boolean;
  summary: boolean;
  baselineName: string;
  changedFiles: Set<string> | undefined;
  recipeActions: ReadonlyArray<unknown> | undefined;
  bindValues: RecipeParamValue[] | undefined;
}) {
  const result = compareQueryBaseline({
    baselineName: opts.baselineName,
    sql: opts.sql,
    bindValues: opts.bindValues,
    changedFiles: opts.changedFiles,
    summary: opts.summary,
    recipeActions: opts.recipeActions,
  });

  if ("error" in result) {
    emitErrorMaybeJson(result.error, opts.json);
    return;
  }

  if (opts.summary) {
    const added = result.added as number;
    const removed = result.removed as number;
    if (opts.json) {
      console.log(JSON.stringify(result));
    } else {
      console.log(
        `baseline "${result.baseline.name}": ${result.baseline.row_count} rows → ${result.current_row_count} rows  (+${added} / -${removed})`,
      );
    }
    return;
  }

  const added = result.added as unknown[];
  const removed = result.removed as unknown[];

  if (opts.json) {
    console.log(JSON.stringify(result));
    return;
  }

  console.log(
    `baseline "${result.baseline.name}": ${result.baseline.row_count} rows → ${result.current_row_count} rows  (+${added.length} / -${removed.length})`,
  );
  if (added.length > 0) {
    console.log(`\n  added (+${added.length}):`);
    console.table(added);
  }
  if (removed.length > 0) {
    console.log(`\n  removed (-${removed.length}):`);
    console.table(removed);
  }
  if (added.length === 0 && removed.length === 0) {
    console.log("  no diff.");
  }
}
