"""Application database schema definition and initialisation.

BanGUI maintains its own SQLite database that stores configuration, session
state, blocklist source definitions, and import run logs.  This module is
the single source of truth for the schema — all ``CREATE TABLE`` statements
live here and are applied on first run via :func:`init_db`.

The fail2ban database is separate and is accessed read-only by the history
and ban services.
"""

import aiosqlite
import structlog

log: structlog.stdlib.BoundLogger = structlog.get_logger()

# ---------------------------------------------------------------------------
# DDL statements
# ---------------------------------------------------------------------------

_CREATE_SETTINGS: str = """
CREATE TABLE IF NOT EXISTS settings (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    key         TEXT    NOT NULL UNIQUE,
    value       TEXT    NOT NULL,
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    updated_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

_CREATE_SESSIONS: str = """
CREATE TABLE IF NOT EXISTS sessions (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    token       TEXT    NOT NULL UNIQUE,
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    expires_at  TEXT    NOT NULL
);
"""

_CREATE_SESSIONS_TOKEN_INDEX: str = """
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token ON sessions (token);
"""

_CREATE_BLOCKLIST_SOURCES: str = """
CREATE TABLE IF NOT EXISTS blocklist_sources (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    name        TEXT    NOT NULL,
    url         TEXT    NOT NULL UNIQUE,
    enabled     INTEGER NOT NULL DEFAULT 1,
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    updated_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

_CREATE_IMPORT_LOG: str = """
CREATE TABLE IF NOT EXISTS import_log (
    id              INTEGER PRIMARY KEY AUTOINCREMENT,
    source_id       INTEGER REFERENCES blocklist_sources(id) ON DELETE SET NULL,
    source_url      TEXT    NOT NULL,
    timestamp       TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    ips_imported    INTEGER NOT NULL DEFAULT 0,
    ips_skipped     INTEGER NOT NULL DEFAULT 0,
    errors          TEXT
);
"""

_CREATE_GEO_CACHE: str = """
CREATE TABLE IF NOT EXISTS geo_cache (
    ip              TEXT    PRIMARY KEY,
    country_code    TEXT,
    country_name    TEXT,
    asn             TEXT,
    org             TEXT,
    cached_at       TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

_CREATE_HISTORY_ARCHIVE: str = """
CREATE TABLE IF NOT EXISTS history_archive (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    jail        TEXT    NOT NULL,
    ip          TEXT    NOT NULL,
    timeofban   INTEGER NOT NULL,
    bancount    INTEGER NOT NULL,
    data        TEXT    NOT NULL,
    action      TEXT    NOT NULL CHECK(action IN ('ban', 'unban')),
    created_at  TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    UNIQUE(ip, jail, action, timeofban)
);
"""

_CREATE_SCHEMA_MIGRATIONS: str = """
CREATE TABLE IF NOT EXISTS schema_migrations (
    version     INTEGER PRIMARY KEY,
    migrated_at TEXT    NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
"""

# Ordered list of DDL statements to execute on initialisation.
_SCHEMA_STATEMENTS: list[str] = [
    _CREATE_SETTINGS,
    _CREATE_SESSIONS,
    _CREATE_SESSIONS_TOKEN_INDEX,
    _CREATE_BLOCKLIST_SOURCES,
    _CREATE_IMPORT_LOG,
    _CREATE_GEO_CACHE,
    _CREATE_HISTORY_ARCHIVE,
]

_CURRENT_SCHEMA_VERSION: int = 1

_MIGRATIONS: dict[int, str] = {
    1: "\n".join(_SCHEMA_STATEMENTS),
}


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


async def _configure_connection(db: aiosqlite.Connection) -> None:
    """Apply hardening pragmas to a newly-opened SQLite connection."""
    await db.execute("PRAGMA journal_mode=WAL;")
    await db.execute("PRAGMA foreign_keys=ON;")
    await db.execute("PRAGMA busy_timeout=5000;")


async def _get_current_schema_version(db: aiosqlite.Connection) -> int:
    """Return the highest applied schema version for the given database."""
    await db.executescript(_CREATE_SCHEMA_MIGRATIONS)
    async with db.execute("SELECT MAX(version) FROM schema_migrations;") as cursor:
        row = await cursor.fetchone()
    if row is None or row[0] is None:
        return 0
    return int(row[0])


async def _apply_migration(db: aiosqlite.Connection, version: int) -> None:
    """Apply a single migration step and record its completion."""
    migration_script = _MIGRATIONS[version]
    await db.executescript(migration_script)
    await db.execute("INSERT INTO schema_migrations (version) VALUES (?);", (version,))
    await db.commit()


async def _migrate_schema(db: aiosqlite.Connection) -> None:
    """Migrate the database schema to the latest supported version."""
    current_version = await _get_current_schema_version(db)
    if current_version == _CURRENT_SCHEMA_VERSION:
        return

    if current_version > _CURRENT_SCHEMA_VERSION:
        raise RuntimeError(
            f"database schema version {current_version} is newer than supported "
            f"version {_CURRENT_SCHEMA_VERSION}"
        )

    log.info("migrating_database_schema", from_version=current_version, to_version=_CURRENT_SCHEMA_VERSION)
    for next_version in range(current_version + 1, _CURRENT_SCHEMA_VERSION + 1):
        await _apply_migration(db, next_version)
    log.info("database_schema_ready", schema_version=_CURRENT_SCHEMA_VERSION)


async def init_db(db: aiosqlite.Connection) -> None:
    """Create or migrate the BanGUI application database schema.

    This function is idempotent — calling it on an already-initialised
    database has no effect.  It should be called once during application
    startup inside the FastAPI lifespan handler.

    Args:
        db: An open :class:`aiosqlite.Connection` to the application database.
    """
    log.info("initialising_database_schema")
    await _configure_connection(db)
    await _migrate_schema(db)


async def open_db(database_path: str) -> aiosqlite.Connection:
    """Open a new application SQLite connection with the standard settings.

    Args:
        database_path: Path to the BanGUI SQLite database.

    Returns:
        A configured :class:`aiosqlite.Connection` instance.
    """
    db = await aiosqlite.connect(database_path)
    db.row_factory = aiosqlite.Row
    await _configure_connection(db)
    return db
