"""Schema migration framework for filigree.

Migrations are version-keyed functions that transform the database schema
from one version to the next. Each migration receives a raw sqlite3.Connection
and must be idempotent (safe to re-run, using IF NOT EXISTS / IF EXISTS).

The migration runner:
  1. Reads the current schema version via PRAGMA user_version
  2. Applies each pending migration in order
  3. Bumps user_version after each successful migration
  4. Wraps each migration in a transaction (rollback on failure)

Usage — adding a new migration:
  1. Increment CURRENT_SCHEMA_VERSION in db_schema.py
  2. Add a function here: def migrate_v<N>_to_v<N+1>(conn) -> None
  3. Register it in MIGRATIONS: N: migrate_v<N>_to_v<N+1>
  4. Update SCHEMA_SQL in db_schema.py to match the post-migration state
  5. Add a test in tests/core/test_schema.py

SQLite ALTER TABLE limitations (why helpers exist):
  - ADD COLUMN: supported (but only with constant defaults, no NOT NULL without DEFAULT)
  - DROP COLUMN: supported only in SQLite >= 3.35.0
  - RENAME COLUMN: supported since SQLite 3.25.0
  - ALTER column type/constraints: NOT supported — use rebuild_table()
"""

from __future__ import annotations

import logging
import re
import sqlite3
from typing import Protocol

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Migration function protocol
# ---------------------------------------------------------------------------


class MigrationFn(Protocol):
    """Protocol for migration functions."""

    def __call__(self, conn: sqlite3.Connection) -> None: ...


# ---------------------------------------------------------------------------
# Migration registry
#
# Keys are the version being migrated FROM (i.e., the current user_version).
# Values are functions that transform the schema to the next version.
#
# Example: {1: migrate_v1_to_v2} means "if user_version == 1, run this to get to 2"
# ---------------------------------------------------------------------------


def migrate_v1_to_v2(conn: sqlite3.Connection) -> None:
    """v1 → v2: Add file records, scan findings, and file associations tables.

    Changes:
      - new table 'file_records' for tracking source code files
      - new table 'scan_findings' for security/code quality scan results
      - new table 'file_associations' for linking files to issues
      - indexes for efficient querying
    """
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS file_records (
            id          TEXT PRIMARY KEY,
            path        TEXT NOT NULL UNIQUE,
            language    TEXT DEFAULT '',
            file_type   TEXT DEFAULT '',
            first_seen  TEXT NOT NULL,
            updated_at  TEXT NOT NULL,
            metadata    TEXT DEFAULT '{}'
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_file_records_path ON file_records(path)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_file_records_language ON file_records(language)")

    conn.execute("""\
        CREATE TABLE IF NOT EXISTS scan_findings (
            id            TEXT PRIMARY KEY,
            file_id       TEXT NOT NULL REFERENCES file_records(id),
            issue_id      TEXT REFERENCES issues(id) ON DELETE SET NULL,
            scan_source   TEXT NOT NULL DEFAULT '',
            rule_id       TEXT DEFAULT '',
            severity      TEXT NOT NULL DEFAULT 'info',
            status        TEXT NOT NULL DEFAULT 'open',
            message       TEXT DEFAULT '',
            line_start    INTEGER,
            line_end      INTEGER,
            seen_count    INTEGER DEFAULT 1,
            first_seen    TEXT NOT NULL,
            updated_at    TEXT NOT NULL,
            last_seen_at  TEXT,
            metadata      TEXT DEFAULT '{}',
            CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')),
            CHECK (status IN ('open', 'acknowledged', 'fixed', 'false_positive', 'unseen_in_latest'))
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_findings_file ON scan_findings(file_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_findings_issue ON scan_findings(issue_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_findings_severity ON scan_findings(severity)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_findings_status ON scan_findings(status)")
    conn.execute("""\
        CREATE UNIQUE INDEX IF NOT EXISTS idx_scan_findings_dedup
          ON scan_findings(file_id, scan_source, rule_id, coalesce(line_start, -1))""")

    conn.execute("""\
        CREATE TABLE IF NOT EXISTS file_associations (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            file_id     TEXT NOT NULL REFERENCES file_records(id),
            issue_id    TEXT NOT NULL REFERENCES issues(id),
            assoc_type  TEXT NOT NULL,
            created_at  TEXT NOT NULL,
            UNIQUE(file_id, issue_id, assoc_type),
            CHECK (assoc_type IN ('bug_in', 'task_for', 'scan_finding', 'mentioned_in'))
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_file_assoc_file ON file_associations(file_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_file_assoc_issue ON file_associations(issue_id)")


def migrate_v2_to_v3(conn: sqlite3.Connection) -> None:
    """v2 → v3: Add scan_run_id, suggestion columns and scan_run_id index to scan_findings.

    Changes:
      - scan_findings: add 'suggestion' column (TEXT, default '')
      - scan_findings: add 'scan_run_id' column (TEXT, default '')
      - new index idx_scan_findings_run on scan_findings(scan_run_id)
    """
    add_column(conn, "scan_findings", "suggestion", "TEXT", "''")
    add_column(conn, "scan_findings", "scan_run_id", "TEXT", "''")
    add_index(conn, "idx_scan_findings_run", "scan_findings", ["scan_run_id"])


def migrate_v3_to_v4(conn: sqlite3.Connection) -> None:
    """v3 → v4: Add file_events table for file metadata timeline events.

    Changes:
      - new table 'file_events' for tracking file metadata field changes
      - new index idx_file_events_file on file_events(file_id)
    """
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS file_events (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            file_id     TEXT NOT NULL REFERENCES file_records(id),
            event_type  TEXT NOT NULL DEFAULT 'file_metadata_update',
            field       TEXT NOT NULL,
            old_value   TEXT DEFAULT '',
            new_value   TEXT DEFAULT '',
            created_at  TEXT NOT NULL
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_file_events_file ON file_events(file_id)")


def _now_iso() -> str:
    """ISO timestamp for migration operations (Z-suffix, no microseconds — distinct from db_base._now_iso)."""
    from datetime import UTC, datetime

    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")


def migrate_v4_to_v5(conn: sqlite3.Connection) -> None:
    """v4 → v5: Normalize release version fields to vMAJOR.MINOR.PATCH or Future.

    Changes:
      - Normalizes non-compliant version field values on existing release issues
      - Adds migration comments documenting changes
      - Clears un-normalizable versions with explanatory comments
    """
    import json

    semver_strict = re.compile(r"^v\d+\.\d+\.\d+$")
    semver_loose = re.compile(r"^v?(\d+)\.(\d+)(?:\.(\d+))?$")

    rows = conn.execute("SELECT id, title, fields FROM issues WHERE type = 'release'").fetchall()
    skipped_ids: list[str] = []

    for row in rows:
        try:
            fields = json.loads(row["fields"] or "{}")
        except (json.JSONDecodeError, TypeError):
            skipped_ids.append(row["id"])
            logger.warning(
                "migrate_v4_to_v5: skipping issue %s with corrupt fields JSON: %r",
                row["id"],
                (row["fields"] or "")[:200],
            )
            continue
        version = fields.get("version", "")
        if not version:
            continue

        # Already compliant
        if version == "Future" or semver_strict.match(version):
            continue

        # Try to normalize: "1.2.3" → "v1.2.3", "v1.2" → "v1.2.0"
        m = semver_loose.match(version.strip())
        if m:
            normalized = f"v{m.group(1)}.{m.group(2)}.{m.group(3) or '0'}"
            fields["version"] = normalized
            conn.execute(
                "UPDATE issues SET fields = ? WHERE id = ?",
                (json.dumps(fields), row["id"]),
            )
            now = _now_iso()
            conn.execute(
                "INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
                (row["id"], "migration", f"Version normalized: '{version}' → '{normalized}'", now),
            )
        else:
            # Un-normalizable — clear and comment
            original = version
            fields.pop("version", None)
            conn.execute(
                "UPDATE issues SET fields = ? WHERE id = ?",
                (json.dumps(fields), row["id"]),
            )
            now = _now_iso()
            comment_text = f"Version field cleared during migration (was: '{original}'). Set a valid vX.Y.Z version before freezing."
            conn.execute(
                "INSERT INTO comments (issue_id, author, text, created_at) VALUES (?, ?, ?, ?)",
                (row["id"], "migration", comment_text, now),
            )

    if skipped_ids:
        logger.error(
            "migrate_v4_to_v5: skipped %d issue(s) with corrupt fields JSON: %s",
            len(skipped_ids),
            skipped_ids,
        )


def _issues_parent_fk_has_set_null(conn: sqlite3.Connection) -> bool:
    """Return True when issues.parent_id has an ON DELETE SET NULL self-FK."""
    for row in conn.execute("PRAGMA foreign_key_list(issues)").fetchall():
        if row[2] == "issues" and row[3] == "parent_id" and row[6].upper() == "SET NULL":
            return True
    return False


def _recreate_issues_fts_triggers(conn: sqlite3.Connection) -> None:
    """Recreate issues FTS triggers after rebuilding the issues table."""
    has_fts = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'issues_fts'").fetchone()
    if has_fts is None:
        return

    conn.execute("DROP TRIGGER IF EXISTS issues_fts_insert")
    conn.execute("DROP TRIGGER IF EXISTS issues_fts_update")
    conn.execute("DROP TRIGGER IF EXISTS issues_fts_delete")
    conn.execute("""\
        CREATE TRIGGER issues_fts_insert AFTER INSERT ON issues BEGIN
            INSERT INTO issues_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
        END""")
    conn.execute("""\
        CREATE TRIGGER issues_fts_update AFTER UPDATE OF title, description ON issues BEGIN
            INSERT INTO issues_fts(issues_fts, rowid, title, description)
                VALUES('delete', old.rowid, old.title, old.description);
            INSERT INTO issues_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
        END""")
    conn.execute("""\
        CREATE TRIGGER issues_fts_delete AFTER DELETE ON issues BEGIN
            INSERT INTO issues_fts(issues_fts, rowid, title, description)
                VALUES('delete', old.rowid, old.title, old.description);
        END""")


def _rebuild_issues_fts_index(conn: sqlite3.Connection) -> None:
    """Resync the issues_fts external-content index after an issues table rebuild."""
    has_fts = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'issues_fts'").fetchone()
    if has_fts is None:
        return

    conn.execute("INSERT INTO issues_fts(issues_fts) VALUES ('rebuild')")


def migrate_v5_to_v6(conn: sqlite3.Connection) -> None:
    """v5 → v6: Restore the parent self-FK with ON DELETE SET NULL.

    Historical filigree databases already used schema version 6 for this
    change. Some later code paths kept the v6 schema shape but regressed the
    reported current version to v5. This migration handles both states:
      - old v5 databases without the parent self-FK are rebuilt safely
      - already-correct databases simply get restamped to v6
    """
    conn.execute("UPDATE issues SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM issues)")

    if _issues_parent_fk_has_set_null(conn):
        return

    rebuild_table(
        conn,
        "issues",
        """\
        CREATE TABLE issues (
            id          TEXT PRIMARY KEY,
            title       TEXT NOT NULL,
            status      TEXT NOT NULL DEFAULT 'open',
            priority    INTEGER NOT NULL DEFAULT 2,
            type        TEXT NOT NULL DEFAULT 'task',
            parent_id   TEXT REFERENCES issues(id) ON DELETE SET NULL,
            assignee    TEXT DEFAULT '',
            created_at  TEXT NOT NULL,
            updated_at  TEXT NOT NULL,
            closed_at   TEXT,
            description TEXT DEFAULT '',
            notes       TEXT DEFAULT '',
            fields      TEXT DEFAULT '{}',
            CHECK (priority BETWEEN 0 AND 4)
        )""",
    )
    add_index(conn, "idx_issues_status", "issues", ["status"])
    add_index(conn, "idx_issues_type", "issues", ["type"])
    add_index(conn, "idx_issues_parent", "issues", ["parent_id"])
    add_index(conn, "idx_issues_priority", "issues", ["priority"])
    add_index(conn, "idx_issues_status_priority", "issues", ["status", "priority", "created_at"])
    _recreate_issues_fts_triggers(conn)
    _rebuild_issues_fts_index(conn)


def migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
    """v6 → v7: Add observations and dismissed_observations tables.

    Changes:
      - new table 'observations' for lightweight agent-reported observations
      - new table 'dismissed_observations' for dismissal audit trail
      - new indexes on observations(priority, created_at), observations(expires_at),
        and dedup index on (summary, file_path, line)

    Rollback: DROP TABLE IF EXISTS dismissed_observations;
              DROP TABLE IF EXISTS observations;
              PRAGMA user_version = 6;
    """
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS observations (
            id              TEXT PRIMARY KEY,
            summary         TEXT NOT NULL,
            detail          TEXT DEFAULT '',
            file_id         TEXT REFERENCES file_records(id) ON DELETE SET NULL,
            file_path       TEXT DEFAULT '',
            line            INTEGER,
            source_issue_id TEXT DEFAULT '',
            priority        INTEGER DEFAULT 3 CHECK (priority BETWEEN 0 AND 4),
            actor           TEXT DEFAULT '',
            created_at      TEXT NOT NULL,
            expires_at      TEXT NOT NULL
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_observations_priority ON observations(priority, created_at)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_observations_expires ON observations(expires_at)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_observations_file_id ON observations(file_id)")
    conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ON observations(summary, file_path, coalesce(line, -1))")
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS dismissed_observations (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            obs_id       TEXT NOT NULL,
            summary      TEXT NOT NULL,
            actor        TEXT DEFAULT '',
            reason       TEXT DEFAULT '',
            dismissed_at TEXT NOT NULL
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_dismissed_obs_id ON dismissed_observations(obs_id)")


def migrate_v7_to_v8(conn: sqlite3.Connection) -> None:
    """v7 → v8: Add scan_runs table for scan lifecycle tracking.

    Changes:
      - new table 'scan_runs' for tracking scanner invocations and their status
      - new indexes on scan_runs(status) and scan_runs(scanner_name)

    Rollback: DROP TABLE IF EXISTS scan_runs;
              DROP INDEX IF EXISTS idx_scan_runs_status;
              DROP INDEX IF EXISTS idx_scan_runs_scanner;
              PRAGMA user_version = 7;
    """
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS scan_runs (
            id            TEXT PRIMARY KEY,
            scanner_name  TEXT NOT NULL,
            scan_source   TEXT NOT NULL DEFAULT '',
            status        TEXT NOT NULL DEFAULT 'pending',
            file_paths    TEXT NOT NULL DEFAULT '[]',
            file_ids      TEXT NOT NULL DEFAULT '[]',
            pid           INTEGER,
            api_url       TEXT DEFAULT '',
            log_path      TEXT DEFAULT '',
            started_at    TEXT NOT NULL,
            updated_at    TEXT NOT NULL,
            completed_at  TEXT,
            exit_code     INTEGER,
            findings_count INTEGER DEFAULT 0,
            error_message TEXT DEFAULT '',
            CHECK (status IN ('pending', 'running', 'completed', 'failed', 'timeout'))
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_runs_status ON scan_runs(status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_scan_runs_scanner ON scan_runs(scanner_name)")


MIGRATIONS: dict[int, MigrationFn] = {
    1: migrate_v1_to_v2,
    2: migrate_v2_to_v3,
    3: migrate_v3_to_v4,
    4: migrate_v4_to_v5,
    5: migrate_v5_to_v6,
    6: migrate_v6_to_v7,
    7: migrate_v7_to_v8,
}


# ---------------------------------------------------------------------------
# Migration runner
# ---------------------------------------------------------------------------


class MigrationError(Exception):
    """Raised when a migration fails."""

    def __init__(self, from_version: int, to_version: int, cause: Exception) -> None:
        self.from_version = from_version
        self.to_version = to_version
        self.cause = cause
        super().__init__(f"Migration v{from_version} → v{to_version} failed: {cause}")


def apply_pending_migrations(conn: sqlite3.Connection, target_version: int) -> int:
    """Apply all pending migrations from current version up to target_version.

    Args:
        conn: Open SQLite connection (must have row_factory and PRAGMAs already set).
        target_version: The CURRENT_SCHEMA_VERSION from db_schema.py.

    Returns:
        Number of migrations applied (0 if already up to date).

    Raises:
        MigrationError: If any individual migration fails (DB rolled back to
            the last successful migration).
        ValueError: If current version > target (downgrade not supported).
    """
    if conn.in_transaction:
        msg = (
            "apply_pending_migrations() must not be called inside an existing transaction. "
            "Commit or roll back the current transaction first."
        )
        raise RuntimeError(msg)

    current: int = conn.execute("PRAGMA user_version").fetchone()[0]

    if current == target_version:
        return 0

    if current > target_version:
        msg = f"Database schema v{current} is newer than this version of filigree (expects v{target_version}). Downgrade is not supported."
        raise ValueError(msg)

    # Capture caller's FK setting so we can restore it in finally.
    original_fk: int = conn.execute("PRAGMA foreign_keys").fetchone()[0]

    applied = 0
    for version in range(current, target_version):
        migration = MIGRATIONS.get(version)
        if migration is None:
            msg = (
                f"No migration registered for v{version} → v{version + 1}. "
                f"Database is at v{version}, target is v{target_version}. "
                f"Register the migration in filigree.migrations.MIGRATIONS."
            )
            raise MigrationError(version, version + 1, KeyError(msg))

        logger.info("Applying migration v%d → v%d ...", version, version + 1)
        try:
            # Disable FK enforcement so rebuild_table() can atomically
            # DROP + RENAME FK-referenced tables within the transaction.
            # This PRAGMA only takes effect outside a transaction.
            conn.execute("PRAGMA foreign_keys=OFF")
            conn.execute("BEGIN IMMEDIATE")
            migration(conn)
            conn.execute(f"PRAGMA user_version = {version + 1}")
            # Validate FK integrity before committing — catches both
            # migration bugs and pre-existing violations.
            violations = conn.execute("PRAGMA foreign_key_check").fetchall()
            if violations:
                raise sqlite3.IntegrityError(f"Foreign key violations after migration: {violations}")
            conn.commit()
            applied += 1
            logger.info("Migration v%d → v%d complete.", version, version + 1)
        except Exception as exc:
            conn.rollback()
            raise MigrationError(version, version + 1, exc) from exc
        finally:
            # Restore caller's original FK enforcement setting.
            # After commit/rollback the connection is in autocommit mode,
            # so this PRAGMA takes effect immediately.
            conn.execute(f"PRAGMA foreign_keys={'ON' if original_fk else 'OFF'}")

    return applied


# ---------------------------------------------------------------------------
# SQLite migration helpers
#
# These handle the quirks of SQLite's limited ALTER TABLE support.
# ---------------------------------------------------------------------------


def add_column(
    conn: sqlite3.Connection,
    table: str,
    column: str,
    col_type: str = "TEXT",
    default: str | None = "''",
) -> None:
    """Add a column to a table (idempotent).

    Args:
        conn: SQLite connection.
        table: Table name.
        column: New column name.
        col_type: SQL type (TEXT, INTEGER, REAL, BLOB).
        default: DEFAULT value as a SQL literal (e.g., "''" or "0" or "NULL").
                 If None, no DEFAULT clause is added.

    Note: SQLite requires a DEFAULT for ADD COLUMN with NOT NULL.
    """
    # Check if column already exists (idempotent)
    existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
    if column in existing:
        return

    default_clause = f" DEFAULT {default}" if default is not None else ""
    conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}{default_clause}")


def add_index(
    conn: sqlite3.Connection,
    index_name: str,
    table: str,
    columns: list[str],
    *,
    unique: bool = False,
) -> None:
    """Create an index (idempotent via IF NOT EXISTS).

    Args:
        conn: SQLite connection.
        index_name: Name for the index.
        table: Table to index.
        columns: Column names to include.
        unique: Whether to create a UNIQUE index.
    """
    unique_kw = "UNIQUE " if unique else ""
    cols = ", ".join(columns)
    conn.execute(f"CREATE {unique_kw}INDEX IF NOT EXISTS {index_name} ON {table}({cols})")


def drop_index(conn: sqlite3.Connection, index_name: str) -> None:
    """Drop an index (idempotent via IF EXISTS)."""
    conn.execute(f"DROP INDEX IF EXISTS {index_name}")


def rename_column(conn: sqlite3.Connection, table: str, old_name: str, new_name: str) -> None:
    """Rename a column (SQLite >= 3.25.0, idempotent).

    Checks column existence before attempting rename.
    """
    existing = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
    if new_name in existing:
        return  # Already renamed
    if old_name not in existing:
        msg = f"Column {old_name!r} not found in table {table!r}"
        raise ValueError(msg)
    conn.execute(f"ALTER TABLE {table} RENAME COLUMN {old_name} TO {new_name}")


def rebuild_table(
    conn: sqlite3.Connection,
    table: str,
    new_schema_sql: str,
    column_mapping: dict[str, str] | None = None,
) -> None:
    """Recreate a table with a new schema, preserving data.

    This is the "12-step" pattern for SQLite schema changes that ALTER TABLE
    can't handle (changing types, constraints, removing columns, etc.):
      1. Create new table with temp name
      2. Copy data from old table
      3. Drop old table
      4. Rename new table to original name

    Args:
        conn: SQLite connection.
        table: Existing table name.
        new_schema_sql: Full CREATE TABLE statement for the new schema.
                        Must use the table name directly (not a temp name —
                        this function handles the rename dance).
        column_mapping: Optional mapping of {new_col: old_col_or_expr}.
                        If None, copies all columns that exist in both schemas.
                        Use SQL expressions as values for transformations, e.g.:
                        {"priority": "CASE WHEN priority > 4 THEN 4 ELSE priority END"}

    Warning: This drops all indexes, triggers, and views that reference the table.
             Recreate them after calling this function.
    """
    temp_table = f"_filigree_migrate_{table}"

    # Create temp table with new schema (case-insensitive replace of table name)
    pattern = re.compile(
        rf"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?{re.escape(table)}\b",
        re.IGNORECASE,
    )
    temp_schema = pattern.sub(f"CREATE TABLE {temp_table}", new_schema_sql, count=1)

    conn.execute(f"DROP TABLE IF EXISTS {temp_table}")  # Clean up any leftover from failed run
    conn.execute(temp_schema)

    if column_mapping is None:
        # Auto-detect shared columns
        old_cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
        new_cols = [row[1] for row in conn.execute(f"PRAGMA table_info({temp_table})").fetchall()]
        shared = [c for c in new_cols if c in old_cols]
        if not shared:
            conn.execute(f"DROP TABLE IF EXISTS {temp_table}")
            msg = f"No shared columns between old and new schema for table '{table}'"
            raise ValueError(msg)
        select_cols = ", ".join(shared)
        insert_cols = select_cols
    else:
        insert_cols = ", ".join(column_mapping.keys())
        select_cols = ", ".join(column_mapping.values())

    # S608: table/column names are from internal schema, not user input
    insert_sql = f"INSERT INTO {temp_table} ({insert_cols}) SELECT {select_cols} FROM {table}"  # noqa: S608
    conn.execute(insert_sql)

    # The caller (migration runner) is responsible for disabling FK
    # enforcement before starting the transaction, so DROP TABLE works
    # even for FK-referenced tables without breaking atomicity.
    conn.execute(f"DROP TABLE {table}")
    conn.execute(f"ALTER TABLE {temp_table} RENAME TO {table}")


# ---------------------------------------------------------------------------
# Migration templates
#
# Copy one of these as a starting point for a new migration. Keep the
# docstring — it serves as the migration's changelog entry.
# ---------------------------------------------------------------------------


def _template_simple_migration(conn: sqlite3.Connection) -> None:
    """v1 → v2: <describe what changes and why>.

    Changes:
      - issues: add 'foo' column (TEXT, default '')
      - new index idx_issues_foo on issues(foo)
    """
    add_column(conn, "issues", "foo", "TEXT", "''")
    add_index(conn, "idx_issues_foo", "issues", ["foo"])


def _template_table_rebuild_migration(conn: sqlite3.Connection) -> None:
    """v2 → v3: <describe what changes and why>.

    Changes:
      - issues: change priority CHECK constraint from 0-4 to 0-5
      - issues: drop legacy 'foo' column

    Uses rebuild_table because ALTER TABLE can't modify constraints.
    """
    new_schema = """\
    CREATE TABLE issues (
        id          TEXT PRIMARY KEY,
        title       TEXT NOT NULL,
        status      TEXT NOT NULL DEFAULT 'open',
        priority    INTEGER NOT NULL DEFAULT 2,
        -- ... full table definition with new constraints ...
        CHECK (priority BETWEEN 0 AND 5)
    )"""

    rebuild_table(
        conn,
        "issues",
        new_schema,
        # Explicit column mapping — omit 'foo' to drop it, transform priority
        column_mapping={
            "id": "id",
            "title": "title",
            "status": "status",
            "priority": "MIN(priority, 5)",  # clamp old values to new range
        },
    )

    # Recreate indexes (rebuild_table drops them)
    add_index(conn, "idx_issues_status", "issues", ["status"])
    add_index(conn, "idx_issues_priority", "issues", ["priority"])


def _template_new_table_migration(conn: sqlite3.Connection) -> None:
    """v3 → v4: <describe what changes and why>.

    Changes:
      - new table 'attachments' for file references
      - new index on attachments(issue_id)
    """
    # Use execute() not executescript() — executescript implicitly commits,
    # breaking the migration runner's per-migration transaction guarantees.
    conn.execute("""\
        CREATE TABLE IF NOT EXISTS attachments (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            issue_id   TEXT NOT NULL REFERENCES issues(id),
            filename   TEXT NOT NULL,
            mime_type  TEXT DEFAULT '',
            size_bytes INTEGER DEFAULT 0,
            created_at TEXT NOT NULL
        )""")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_attachments_issue ON attachments(issue_id)")
