"""SQLite persistence: versioned migrations and connection helpers.

The database, migration runner and file storage choices are recorded in
``docs/decisions/002-persistence.md``. Migrations are plain SQL applied in
version order; each records itself in ``schema_migrations`` so re-running
``migrate`` on an existing database is a no-op. Every migration ships a
``down`` script so ``rollback`` can walk backwards for recovery and tests.
"""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import sqlite3


DEFAULT_DB_PATH = Path("data/app.db")


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


@dataclass(frozen=True)
class Migration:
    version: int
    name: str
    up: str
    down: str


MIGRATIONS: tuple[Migration, ...] = (
    Migration(
        version=1,
        name="0001_core_persistence",
        up="""
        CREATE TABLE companies (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT NOT NULL UNIQUE,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );

        CREATE TABLE bank_accounts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            company_id INTEGER REFERENCES companies (id),
            account_number TEXT NOT NULL UNIQUE,
            account_name TEXT,
            bank_name TEXT,
            status TEXT NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'active', 'disabled')),
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );

        CREATE TABLE source_files (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            sha256 TEXT NOT NULL UNIQUE,
            original_filename TEXT NOT NULL,
            size_bytes INTEGER NOT NULL,
            storage_path TEXT NOT NULL,
            created_at TEXT NOT NULL
        );

        CREATE TABLE import_batches (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            source_file_id INTEGER NOT NULL REFERENCES source_files (id),
            status TEXT NOT NULL
                CHECK (status IN ('parsing', 'parsed', 'exception', 'failed', 'duplicate')),
            duplicate_of_id INTEGER REFERENCES import_batches (id),
            diagnostics TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );

        CREATE TABLE sheet_batches (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
            sheet_name TEXT NOT NULL,
            bank_name TEXT NOT NULL,
            template_id TEXT NOT NULL,
            template_version INTEGER NOT NULL,
            header_row INTEGER NOT NULL,
            own_account TEXT,
            own_name TEXT,
            period_start TEXT,
            period_end TEXT,
            transaction_count INTEGER NOT NULL,
            warnings TEXT NOT NULL DEFAULT '[]',
            created_at TEXT NOT NULL,
            UNIQUE (import_batch_id, sheet_name)
        );

        CREATE TABLE source_rows (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            sheet_batch_id INTEGER NOT NULL REFERENCES sheet_batches (id),
            source_row INTEGER NOT NULL,
            transaction_at TEXT NOT NULL,
            income TEXT NOT NULL,
            expense TEXT NOT NULL,
            balance TEXT,
            own_account TEXT,
            own_name TEXT,
            counterparty_account TEXT,
            counterparty_name TEXT,
            counterparty_bank TEXT,
            summary TEXT,
            purpose TEXT,
            reference TEXT,
            currency TEXT,
            created_at TEXT NOT NULL,
            UNIQUE (sheet_batch_id, source_row)
        );

        CREATE TABLE import_exceptions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
            stage TEXT NOT NULL,
            message TEXT NOT NULL,
            diagnostics TEXT,
            created_at TEXT NOT NULL
        );

        CREATE TRIGGER source_files_no_update BEFORE UPDATE ON source_files
        BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
        CREATE TRIGGER source_files_no_delete BEFORE DELETE ON source_files
        BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
        CREATE TRIGGER sheet_batches_no_update BEFORE UPDATE ON sheet_batches
        BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
        CREATE TRIGGER sheet_batches_no_delete BEFORE DELETE ON sheet_batches
        BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
        CREATE TRIGGER source_rows_no_update BEFORE UPDATE ON source_rows
        BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
        CREATE TRIGGER source_rows_no_delete BEFORE DELETE ON source_rows
        BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
        """,
        down="""
        DROP TRIGGER IF EXISTS source_rows_no_delete;
        DROP TRIGGER IF EXISTS source_rows_no_update;
        DROP TRIGGER IF EXISTS sheet_batches_no_delete;
        DROP TRIGGER IF EXISTS sheet_batches_no_update;
        DROP TRIGGER IF EXISTS source_files_no_delete;
        DROP TRIGGER IF EXISTS source_files_no_update;
        DROP TABLE IF EXISTS import_exceptions;
        DROP TABLE IF EXISTS source_rows;
        DROP TABLE IF EXISTS sheet_batches;
        DROP TABLE IF EXISTS import_batches;
        DROP TABLE IF EXISTS source_files;
        DROP TABLE IF EXISTS bank_accounts;
        DROP TABLE IF EXISTS companies;
        """,
    ),
    Migration(
        version=2,
        name="0002_auth_and_sessions",
        up="""
        CREATE TABLE users (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT NOT NULL UNIQUE,
            password_hash TEXT NOT NULL,
            role TEXT NOT NULL CHECK (role IN ('admin', 'company')),
            company_id INTEGER REFERENCES companies (id),
            status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
            must_change_password INTEGER NOT NULL DEFAULT 0,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL,
            CHECK (role != 'company' OR company_id IS NOT NULL),
            CHECK (role != 'admin' OR company_id IS NULL)
        );

        CREATE TABLE sessions (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            token_hash TEXT NOT NULL UNIQUE,
            user_id INTEGER NOT NULL REFERENCES users (id),
            created_at TEXT NOT NULL,
            expires_at TEXT NOT NULL,
            revoked_at TEXT
        );

        CREATE TABLE login_attempts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            username TEXT NOT NULL,
            ip TEXT NOT NULL,
            success INTEGER NOT NULL,
            created_at TEXT NOT NULL
        );

        CREATE TABLE audit_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            actor_user_id INTEGER REFERENCES users (id),
            actor_username TEXT,
            action TEXT NOT NULL,
            target TEXT,
            detail TEXT,
            ip TEXT,
            created_at TEXT NOT NULL
        );

        ALTER TABLE import_batches ADD COLUMN company_id INTEGER REFERENCES companies (id);
        """,
        down="""
        DROP TABLE IF EXISTS audit_log;
        DROP TABLE IF EXISTS login_attempts;
        DROP TABLE IF EXISTS sessions;
        DROP TABLE IF EXISTS users;
        ALTER TABLE import_batches DROP COLUMN company_id;
        """,
    ),
    Migration(
        version=3,
        name="0003_dynamic_master_data",
        # bank_accounts is rebuilt (SQLite cannot alter CHECK constraints):
        # status gains 'returned', and the account gains type, effective
        # interval and review fields. account_number stays UNIQUE and stores
        # the normalized digits-only form (see master_data.normalize).
        up="""
        CREATE TABLE bank_accounts_new (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            company_id INTEGER NOT NULL REFERENCES companies (id),
            account_number TEXT NOT NULL UNIQUE,
            account_name TEXT,
            bank_name TEXT NOT NULL DEFAULT '',
            account_type TEXT NOT NULL DEFAULT '一般户'
                CHECK (account_type IN ('基本户', '一般户', '专用户')),
            status TEXT NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
            effective_from TEXT,
            effective_to TEXT,
            submitted_by INTEGER REFERENCES users (id),
            reviewed_by INTEGER REFERENCES users (id),
            reviewed_at TEXT,
            review_reason TEXT,
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        INSERT INTO bank_accounts_new (
            id, company_id, account_number, account_name, bank_name,
            status, created_at, updated_at
        )
        SELECT id, company_id, account_number, account_name,
               COALESCE(bank_name, ''), status, created_at, updated_at
        FROM bank_accounts WHERE company_id IS NOT NULL;
        DROP TABLE bank_accounts;
        ALTER TABLE bank_accounts_new RENAME TO bank_accounts;

        CREATE TABLE account_aliases (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
            alias_kind TEXT NOT NULL CHECK (alias_kind IN ('name', 'account')),
            alias_value TEXT NOT NULL,
            priority INTEGER NOT NULL DEFAULT 100,
            effective_from TEXT,
            effective_to TEXT,
            created_by INTEGER REFERENCES users (id),
            created_at TEXT NOT NULL,
            UNIQUE (bank_account_id, alias_kind, alias_value)
        );

        CREATE TABLE master_data_changes (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            entity_type TEXT NOT NULL
                CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
            entity_id INTEGER NOT NULL,
            action TEXT NOT NULL,
            before_json TEXT,
            after_json TEXT,
            reason TEXT,
            actor_user_id INTEGER REFERENCES users (id),
            actor_username TEXT,
            created_at TEXT NOT NULL
        );

        ALTER TABLE companies ADD COLUMN credit_code TEXT;
        ALTER TABLE companies ADD COLUMN cashier_name TEXT;
        ALTER TABLE companies ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
            CHECK (status IN ('active', 'preparing', 'disabled'));
        """,
        down="""
        ALTER TABLE companies DROP COLUMN status;
        ALTER TABLE companies DROP COLUMN cashier_name;
        ALTER TABLE companies DROP COLUMN credit_code;
        DROP TABLE IF EXISTS master_data_changes;
        DROP TABLE IF EXISTS account_aliases;

        CREATE TABLE bank_accounts_new (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            company_id INTEGER REFERENCES companies (id),
            account_number TEXT NOT NULL UNIQUE,
            account_name TEXT,
            bank_name TEXT,
            status TEXT NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'active', 'disabled')),
            created_at TEXT NOT NULL,
            updated_at TEXT NOT NULL
        );
        INSERT INTO bank_accounts_new (
            id, company_id, account_number, account_name, bank_name,
            status, created_at, updated_at
        )
        SELECT id, company_id, account_number, account_name, bank_name,
               CASE WHEN status = 'returned' THEN 'pending' ELSE status END,
               created_at, updated_at
        FROM bank_accounts;
        DROP TABLE bank_accounts;
        ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
        """,
    ),
)


def connect(path: str | Path) -> sqlite3.Connection:
    db_path = Path(path)
    if str(db_path) != ":memory:":
        db_path.parent.mkdir(parents=True, exist_ok=True)
    connection = sqlite3.connect(str(db_path))
    connection.row_factory = sqlite3.Row
    connection.execute("PRAGMA foreign_keys = ON")
    return connection


def applied_versions(connection: sqlite3.Connection) -> list[int]:
    exists = connection.execute(
        "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'"
    ).fetchone()
    if not exists:
        return []
    rows = connection.execute(
        "SELECT version FROM schema_migrations ORDER BY version"
    ).fetchall()
    return [row["version"] for row in rows]


def migrate(connection: sqlite3.Connection) -> list[int]:
    """Apply every pending migration; returns the versions applied now."""
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS schema_migrations (
            version INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            applied_at TEXT NOT NULL
        )
        """
    )
    applied = set(applied_versions(connection))
    newly_applied: list[int] = []
    for migration in MIGRATIONS:
        if migration.version in applied:
            continue
        with connection:
            connection.executescript(migration.up)
            connection.execute(
                "INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
                (migration.version, migration.name, utc_now()),
            )
        newly_applied.append(migration.version)
    return newly_applied


def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[int]:
    """Reverse migrations above ``target_version``; returns reversed versions."""
    applied = applied_versions(connection)
    reversed_versions: list[int] = []
    for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True):
        if migration.version <= target_version or migration.version not in applied:
            continue
        with connection:
            connection.executescript(migration.down)
            connection.execute(
                "DELETE FROM schema_migrations WHERE version = ?",
                (migration.version,),
            )
        reversed_versions.append(migration.version)
    return reversed_versions


def main() -> int:
    parser = argparse.ArgumentParser(description="Apply or roll back database migrations.")
    parser.add_argument(
        "db_path",
        type=Path,
        nargs="?",
        default=DEFAULT_DB_PATH,
        help="SQLite database path (default: data/app.db)",
    )
    parser.add_argument(
        "--rollback-to",
        type=int,
        default=None,
        metavar="VERSION",
        help="Reverse migrations above VERSION instead of migrating forward",
    )
    args = parser.parse_args()

    connection = connect(args.db_path)
    try:
        if args.rollback_to is None:
            applied = migrate(connection)
            print(f"applied migrations: {applied or 'none (already up to date)'}")
        else:
            reversed_versions = rollback(connection, args.rollback_to)
            print(f"reversed migrations: {reversed_versions or 'none'}")
    finally:
        connection.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
