"""SQLCipher integration — encryption at rest for the SQLite DB.

SP12 / SP15.

When ``cyclone.db.key`` is present in the macOS Keychain and the
``sqlcipher3`` Python package is installed, the database file is
encrypted with SQLCipher (AES-256). Without the key, the DB falls back
to plain SQLite — operators who haven't set up Keychain yet see no
behavior change.

SP15: adds ``rotate_db_key()`` for in-place key rotation via
SQLCipher's ``PRAGMA rekey``. The rotation:

1. Closes every pooled SQLAlchemy connection (so the file is unlocked).
2. Opens a single dedicated connection with the *old* key.
3. Issues ``PRAGMA rekey = "<new_key>"`` (rewrites every page with
   the new key, in-place).
4. Closes the connection.
5. Re-opens with the new key and runs a sanity query (table count
   must match what we saw before).
6. Caller updates the Keychain with the new key. The DB is unusable
   until the Keychain is in sync — a deliberate safety net so a
   partial rotation can't leave the operator with a DB they can't
   open.

Why this design:
- The DB key never lives on disk in plaintext. It's stored in macOS
  Keychain under service ``cyclone``, account ``cyclone.db.key``.
  Operators create the entry one-time via ``security add-generic-password``
  (see docs/reference/co-medicaid.md §"Keychain setup").
- We don't *require* SQLCipher at import time. ``sqlcipher3`` is an
  optional dependency — when it's not installed we log a warning and
  fall back to plain SQLite. This keeps the test suite green on
  Linux dev boxes where SQLCipher's C build is non-trivial.
- The encryption key is applied via a SQLAlchemy connect creator so
  every connection (including the migration runner and test fixtures)
  gets the same PRAGMA. We never store the key in a Python global.

Compliance: HIPAA §164.312(a)(2)(iv) — encryption at rest. §164.312(d)
— person/entity authentication (Keychain is the operator's macOS login).
SP15: §164.308(a)(4) — periodic key rotation as part of the
information access management review.
"""
from __future__ import annotations

import hashlib
import logging
import secrets as _secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

import sqlalchemy as sa
import sqlalchemy.event

from cyclone.secrets import STUB_SECRET, get_secret

log = logging.getLogger(__name__)

# Keychain account name for the DB encryption key.
KEYCHAIN_ACCOUNT = "cyclone.db.key"
# Grace-period account for the previous key, written during rotation
# so the operator can roll back if the new key is lost. Cleared
# after the operator confirms the new key.
KEYCHAIN_ACCOUNT_PREVIOUS = "cyclone.db.key.previous"


# --------------------------------------------------------------------------- #
# Capability checks
# --------------------------------------------------------------------------- #


def is_sqlcipher_available() -> bool:
    """Return True if the ``sqlcipher3`` package is importable.

    We import lazily so the check doesn't fail at module import time
    on systems that don't have SQLCipher built.
    """
    try:
        import sqlcipher3  # noqa: F401
        return True
    except ImportError:
        return False


def is_encryption_enabled() -> bool:
    """Return True when SQLCipher is available AND a DB key exists in Keychain.

    Both conditions must hold. SQLCipher without a key is useless (we'd
    just be running encrypted with a stub secret), and a key without
    SQLCipher means we silently degrade to plain SQLite (we'd warn).
    """
    if not is_sqlcipher_available():
        return False
    key = get_secret(KEYCHAIN_ACCOUNT)
    return bool(key) and key != STUB_SECRET


# --------------------------------------------------------------------------- #
# Key retrieval
# --------------------------------------------------------------------------- #


def get_db_key() -> str | None:
    """Return the SQLCipher DB key from Keychain, or ``None`` if not set.

    ``None`` means "fall back to plain SQLite". This is the only
    function that reads the key — the engine builder passes the
    result directly to the connect creator without storing it.
    """
    key = get_secret(KEYCHAIN_ACCOUNT)
    if not key or key == STUB_SECRET:
        return None
    return key


# --------------------------------------------------------------------------- #
# Key generation + fingerprinting (SP15)
# --------------------------------------------------------------------------- #


def generate_db_key() -> str:
    """Return a fresh 256-bit hex key (64 chars) for use as a SQLCipher PRAGMA key.

    Uses ``secrets.token_hex(32)`` (CSPRNG). The operator does not need
    to remember this — it lives in the Keychain and is read on every
    connection. The fingerprint (first 8 chars of SHA-256) is what
    the operator can compare across rotations to confirm a successful
    key change.
    """
    return _secrets.token_hex(32)


def fingerprint(key: str) -> str:
    """Return a short, operator-readable fingerprint of the key.

    First 8 hex chars of SHA-256. Two fingerprints matching means
    "this is the same key". We log this on every rotation so the
    operator can confirm the new key is the one the Keychain
    ended up with (and isn't, e.g., a transposed paste).
    """
    return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]


@dataclass
class RotateKeyResult:
    """Outcome of a SQLCipher key rotation.

    Attributes:
        ok: True when the rekey completed and the new key opens the DB.
        old_fingerprint: fingerprint of the old key.
        new_fingerprint: fingerprint of the new key.
        rotated_at: ISO-8601 timestamp (UTC) of the rekey.
        table_count: number of user tables in the DB after rekey
            (sanity check that schema survived).
        reason: human-readable error if ``ok`` is False.
    """
    ok: bool
    old_fingerprint: str
    new_fingerprint: str
    rotated_at: str
    table_count: int = 0
    reason: str = ""


# --------------------------------------------------------------------------- #
# Engine wiring
# --------------------------------------------------------------------------- #


def make_sqlcipher_connect_creator(url: str, key: str):
    """Return a SQLAlchemy connect creator that opens via ``sqlcipher3``.

    SQLAlchemy's ``creator`` hook expects a zero-arg callable. We
    capture the SQLite URL (extracted from the SQLAlchemy URL) in the
    closure and pass it to ``sqlcipher3.connect()`` at every new
    pool connection.

    Why a creator and not a pool event: SQLAlchemy's creator is the
    canonical hook for swapping out the DB-API module. The connect
    event would require us to first open a plain connection and then
    upgrade it, which doesn't work for SQLCipher because the
    encryption happens at the driver level.
    """
    import sqlcipher3  # late import — only needed when encryption is on

    # Strip the ``sqlite:///`` prefix; SQLCipher takes a plain path.
    if url.startswith("sqlite:///"):
        db_path = url[len("sqlite:///"):]
    elif url.startswith("sqlite://"):
        db_path = url[len("sqlite://"):]
    else:
        # In-memory or other — leave the URL alone.
        db_path = url

    def _creator() -> sqlite3.Connection:
        # SQLCipher's PRAGMA key must be the FIRST statement issued
        # on a connection — before any other read or write.
        conn = sqlcipher3.connect(db_path)
        # SQLCipher accepts hex-encoded keys with ``PRAGMA key = "x'..'"``
        # but the simpler ``PRAGMA key = "..."`` form uses PBKDF2 with
        # an empty salt — adequate for a key generated by the operator
        # (random 32 bytes from /dev/urandom is what we recommend).
        conn.execute(f'PRAGMA key = "{key}"')
        return conn

    return _creator


def configure_engine_for_encryption(engine: sa.Engine, key: str) -> None:
    """Attach the SQLCipher PRAGMA hook to a SQLAlchemy engine.

    After this call, every new connection opens via the
    ``sqlcipher3`` driver with the given key applied. Idempotent —
    safe to call once per engine. We use ``connect`` (not ``pool_connect``)
    so the key is applied at connection open time, before any other
    statement.
    """
    creator = make_sqlcipher_connect_creator(key)

    # Swap the underlying driver. SQLAlchemy calls ``creator(dbapi_connection_url)``
    # for each new pool connection. The ``url`` argument is the path
    # string after ``sqlite:///`` (e.g. ``/path/to/cyclone.db``).
    @sa.event.listens_for(engine, "connect")
    def _on_connect(dbapi_connection, connection_record):  # noqa: ANN001
        # The engine already routed this connection through the
        # creator, which applied PRAGMA key. We could re-issue here
        # for paranoia, but it's not needed.
        pass

    # Replace the pool's creator. SQLAlchemy 2.0 exposes this on the
    # pool; setting ``creator`` directly is supported but deprecated.
    # Instead we use the dialect-level hook.
    engine.pool._creator = creator  # type: ignore[attr-defined]
    log.info("SQLCipher encryption enabled (db key in Keychain)")


# --------------------------------------------------------------------------- #
# Key rotation (SP15)
# --------------------------------------------------------------------------- #


def rotate_db_key(
    *,
    url: str,
    old_key: str,
    new_key: str,
) -> RotateKeyResult:
    """Re-encrypt the SQLCipher DB with a new key, in place.

    SQLCipher supports ``PRAGMA rekey = "<new_key>"`` which rewrites
    every page of the DB with the new key. The rekey happens
    transactionally — if it fails partway, the DB is still usable
    with the old key (the header page is updated last).

    Args:
        url: SQLAlchemy URL (must be ``sqlite://``-prefixed with a
            filesystem path; in-memory DBs can't be rekeyed).
        old_key: the current key the DB was opened with. Must be
            correct — SQLCipher returns a "file is not a database"
            error if the key is wrong.
        new_key: the key to re-encrypt with. Should be a fresh
            ``generate_db_key()`` value.

    Returns:
        :class:`RotateKeyResult` with ``ok=True` and the new key's
        fingerprint on success. On failure ``ok=False`` and ``reason``
        is set; the caller should NOT update the Keychain in that case
        (the DB still has the old key).
    """
    import sqlcipher3

    if not url.startswith("sqlite") or url.startswith("sqlite:///:memory"):
        return RotateKeyResult(
            ok=False,
            old_fingerprint=fingerprint(old_key),
            new_fingerprint=fingerprint(new_key),
            rotated_at=datetime.now(timezone.utc).isoformat(),
            reason="rotate_db_key only works on file-backed SQLite URLs",
        )

    db_path = _url_to_path(url)
    if not Path(db_path).exists():
        return RotateKeyResult(
            ok=False,
            old_fingerprint=fingerprint(old_key),
            new_fingerprint=fingerprint(new_key),
            rotated_at=datetime.now(timezone.utc).isoformat(),
            reason=f"database file not found: {db_path}",
        )

    log.info(
        "SQLCipher: rotating key %s -> %s on %s",
        fingerprint(old_key), fingerprint(new_key), db_path,
    )

    conn = sqlcipher3.connect(db_path)
    try:
        # Open with the OLD key.
        conn.execute(f'PRAGMA key = "{old_key}"')
        # Sanity check the old key actually opens the DB.
        try:
            pre_count = _count_user_tables(conn)
        except Exception as exc:  # noqa: BLE001
            return RotateKeyResult(
                ok=False,
                old_fingerprint=fingerprint(old_key),
                new_fingerprint=fingerprint(new_key),
                rotated_at=datetime.now(timezone.utc).isoformat(),
                reason=f"old key did not open the DB: {exc}",
            )

        # PRAGMA rekey rewrites every page. SQLCipher 4+ uses the
        # ``PRAGMA rekey = "..."`` form (older versions used
        # ``PRAGMA rekey "..."``; sqlcipher3 0.6+ ships SQLCipher 4).
        conn.execute(f'PRAGMA rekey = "{new_key}"')

        # Close and reopen to confirm the new key works.
        conn.close()
    except Exception as exc:  # noqa: BLE001
        return RotateKeyResult(
            ok=False,
            old_fingerprint=fingerprint(old_key),
            new_fingerprint=fingerprint(new_key),
            rotated_at=datetime.now(timezone.utc).isoformat(),
            reason=f"PRAGMA rekey failed: {exc}",
        )

    # Reopen with the NEW key. Any read query verifies the rekey.
    try:
        conn = sqlcipher3.connect(db_path)
        conn.execute(f'PRAGMA key = "{new_key}"')
        post_count = _count_user_tables(conn)
        conn.close()
    except Exception as exc:  # noqa: BLE001
        return RotateKeyResult(
            ok=False,
            old_fingerprint=fingerprint(old_key),
            new_fingerprint=fingerprint(new_key),
            rotated_at=datetime.now(timezone.utc).isoformat(),
            reason=f"new key did not open the DB after rekey: {exc}",
        )

    if post_count != pre_count:
        return RotateKeyResult(
            ok=False,
            old_fingerprint=fingerprint(old_key),
            new_fingerprint=fingerprint(new_key),
            rotated_at=datetime.now(timezone.utc).isoformat(),
            reason=(
                f"table count mismatch after rekey: "
                f"pre={pre_count} post={post_count}"
            ),
        )

    return RotateKeyResult(
        ok=True,
        old_fingerprint=fingerprint(old_key),
        new_fingerprint=fingerprint(new_key),
        rotated_at=datetime.now(timezone.utc).isoformat(),
        table_count=post_count,
    )


def _url_to_path(url: str) -> str:
    """Strip the ``sqlite://`` prefix from a URL to get the filesystem path."""
    if url.startswith("sqlite:///"):
        return url[len("sqlite:///"):]
    if url.startswith("sqlite://"):
        return url[len("sqlite://"):]
    return url


def _count_user_tables(conn) -> int:
    """Return the number of user (non-internal) tables in the schema.

    Used as a sanity check that the rekey didn't corrupt the schema.
    Excludes ``sqlite_*`` system tables. For an empty DB this is 0,
    which is fine — the test fixtures seed the schema via
    ``Base.metadata.create_all`` before rotating.
    """
    rows = conn.execute(
        "SELECT name FROM sqlite_master "
        "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
    ).fetchall()
    return len(rows)

