"""Tests for SQLCipher encryption at rest. SP12.

We exercise the encryption path end-to-end:
1. With a Keychain key + sqlcipher3 installed: the DB file is encrypted
   on disk and decryptable only with the same key.
2. Without a Keychain key: the DB falls back to plain SQLite.
3. With the wrong key: opening the DB raises on the first query.

``sqlcipher3`` is an optional dep — these tests skip when it isn't
installed, so the suite still runs on Linux dev boxes without SQLCipher.
"""
from __future__ import annotations

import os
import sqlite3
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest
import sqlalchemy as sa

from cyclone import db, db_crypto


# --------------------------------------------------------------------------- #
# Skip-if-no-sqlcipher gate
# --------------------------------------------------------------------------- #


pytestmark_sqlcipher = pytest.mark.skipif(
    not db_crypto.is_sqlcipher_available(),
    reason="sqlcipher3 not installed (pip install -e .[sqlcipher])",
)


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


class TestIsSqlcipherAvailable:
    def test_returns_true_when_package_installed(self):
        """This test only runs when sqlcipher3 is importable."""
        assert db_crypto.is_sqlcipher_available() is True


class TestIsEncryptionEnabled:
    def test_no_key_disables_encryption(self, monkeypatch):
        """Without a Keychain key, encryption is off even with sqlcipher3."""
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
        assert db_crypto.is_encryption_enabled() is False

    def test_stub_key_disables_encryption(self, monkeypatch):
        """The stub fallback secret doesn't count as a real key."""
        from cyclone.secrets import STUB_SECRET
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
        assert db_crypto.is_encryption_enabled() is False

    def test_real_key_enables_encryption(self, monkeypatch):
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "real-key-from-keychain")
        assert db_crypto.is_encryption_enabled() is True


class TestGetDbKey:
    def test_returns_none_when_no_key(self, monkeypatch):
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
        assert db_crypto.get_db_key() is None

    def test_returns_key_from_keychain(self, monkeypatch):
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "abc123")
        assert db_crypto.get_db_key() == "abc123"

    def test_stub_secret_returns_none(self, monkeypatch):
        from cyclone.secrets import STUB_SECRET
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: STUB_SECRET)
        assert db_crypto.get_db_key() is None


# --------------------------------------------------------------------------- #
# Engine integration
# --------------------------------------------------------------------------- #


@pytestmark_sqlcipher
class TestEngineIntegration:
    def test_engine_uses_sqlcipher_when_key_present(self, monkeypatch, tmp_path: Path):
        """With a key, _make_engine installs a sqlcipher3 creator."""
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "test-key-xyz")
        db_file = tmp_path / "encrypted.db"
        url = f"sqlite:///{db_file}"
        engine = db._make_engine(url)
        # Use the engine to write and read back.
        with engine.begin() as conn:
            conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
            conn.execute(sa.text("INSERT INTO t VALUES (1)"))
        with engine.connect() as conn:
            assert conn.execute(sa.text("SELECT x FROM t")).scalar() == 1
        engine.dispose()

    def test_engine_uses_plain_sqlite_without_key(self, monkeypatch, tmp_path: Path):
        """Without a key, _make_engine uses the default sqlite3 driver."""
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: None)
        db_file = tmp_path / "plain.db"
        url = f"sqlite:///{db_file}"
        engine = db._make_engine(url)
        with engine.begin() as conn:
            conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
        engine.dispose()
        # The file is a valid plain SQLite DB.
        with sqlite3.connect(str(db_file)) as conn:
            assert conn.execute("SELECT count(*) FROM sqlite_master").fetchone()[0] >= 1

    def test_encrypted_file_unreadable_without_key(self, monkeypatch, tmp_path: Path):
        """An encrypted file is unreadable as plain SQLite."""
        # Create an encrypted DB.
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "secret-1")
        db_file = tmp_path / "encrypted.db"
        url = f"sqlite:///{db_file}"
        engine = db._make_engine(url)
        with engine.begin() as conn:
            conn.execute(sa.text("CREATE TABLE t (secret TEXT)"))
            conn.execute(sa.text("INSERT INTO t VALUES ('classified')"))
        engine.dispose()

        # Plain sqlite3 cannot open it.
        with pytest.raises((sqlite3.DatabaseError, Exception)) as exc_info:
            sqlite3.connect(str(db_file)).execute("SELECT * FROM t").fetchall()
        # The error message comes from SQLite/SQLCipher, not Python.
        assert "not a database" in str(exc_info.value).lower() or "file is encrypted" in str(exc_info.value).lower()

    def test_wrong_key_raises_on_query(self, monkeypatch, tmp_path: Path):
        """A wrong key on the same encrypted file raises on the first query."""
        # Create with key A.
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-A")
        db_file = tmp_path / "encrypted.db"
        url = f"sqlite:///{db_file}"
        engine = db._make_engine(url)
        with engine.begin() as conn:
            conn.execute(sa.text("CREATE TABLE t (x INTEGER)"))
        engine.dispose()

        # Try to open with key B.
        monkeypatch.setattr(db_crypto, "get_secret", lambda account: "key-B")
        engine2 = db._make_engine(url)
        with pytest.raises(Exception) as exc_info:
            with engine2.connect() as conn:
                conn.execute(sa.text("SELECT * FROM t")).fetchall()
        # SQLCipher raises "file is not a database" or similar on bad key.
        msg = str(exc_info.value).lower()
        assert "not a database" in msg or "file is encrypted" in msg or "databaseerror" in msg
        engine2.dispose()


# --------------------------------------------------------------------------- #
# Make_creator function
# --------------------------------------------------------------------------- #


@pytestmark_sqlcipher
class TestMakeSqlcipherConnectCreator:
    def test_creator_returns_connection_with_key_applied(self, tmp_path: Path):
        """The creator's connection must have PRAGMA key applied."""
        db_file = tmp_path / "x.db"
        url = f"sqlite:///{db_file}"
        creator = db_crypto.make_sqlcipher_connect_creator(url, "my-key")
        conn = creator()
        # The creator must have applied the key — verify by writing
        # data and reading it back via the same connection.
        conn.execute("CREATE TABLE t (x INTEGER)")
        conn.execute("INSERT INTO t VALUES (42)")
        conn.commit()
        result = conn.execute("SELECT x FROM t").fetchone()
        assert result[0] == 42
        conn.close()


# --------------------------------------------------------------------------- #
# SP15: Key generation + fingerprint
# --------------------------------------------------------------------------- #


class TestGenerateDbKey:
    def test_returns_64_char_hex(self):
        """A 256-bit key hex-encodes to 64 characters."""
        key = db_crypto.generate_db_key()
        assert len(key) == 64
        int(key, 16)  # parses as hex (raises if not)

    def test_two_calls_return_different_keys(self):
        """Distinct calls produce cryptographically distinct keys."""
        keys = {db_crypto.generate_db_key() for _ in range(8)}
        assert len(keys) == 8


class TestFingerprint:
    def test_deterministic(self):
        assert db_crypto.fingerprint("abc") == db_crypto.fingerprint("abc")

    def test_different_inputs_yield_different_fingerprints(self):
        assert db_crypto.fingerprint("abc") != db_crypto.fingerprint("xyz")

    def test_eight_chars(self):
        assert len(db_crypto.fingerprint("anything")) == 8


# --------------------------------------------------------------------------- #
# SP15: rotate_db_key (in-place rekey via PRAGMA rekey)
# --------------------------------------------------------------------------- #


@pytestmark_sqlcipher
class TestRotateDbKey:
    def _create_encrypted_db(self, tmp_path: Path, key: str) -> Path:
        """Create a small SQLCipher DB with two tables."""
        import sqlcipher3
        db_file = tmp_path / "rotate.db"
        conn = sqlcipher3.connect(str(db_file))
        conn.execute(f'PRAGMA key = "{key}"')
        conn.execute("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)")
        conn.execute("CREATE TABLE balances (acct_id INTEGER, amt REAL)")
        conn.execute("INSERT INTO accounts VALUES (1, 'alice'), (2, 'bob')")
        conn.execute("INSERT INTO balances VALUES (1, 100.5), (2, 250.75)")
        conn.commit()
        conn.close()
        return db_file

    def test_rotate_changes_key_preserves_data(self, tmp_path: Path):
        """The core SP15 contract: rekey with a new key, data survives."""
        db_file = self._create_encrypted_db(tmp_path, "old-key-aaaa")
        url = f"sqlite:///{db_file}"
        result = db_crypto.rotate_db_key(
            url=url, old_key="old-key-aaaa", new_key="new-key-bbbb",
        )
        assert result.ok, f"rotate failed: {result.reason}"
        assert result.old_fingerprint == db_crypto.fingerprint("old-key-aaaa")
        assert result.new_fingerprint == db_crypto.fingerprint("new-key-bbbb")
        assert result.table_count == 2  # accounts + balances

        # Open with the new key; data is intact.
        import sqlcipher3
        conn = sqlcipher3.connect(str(db_file))
        conn.execute(f'PRAGMA key = "new-key-bbbb"')
        rows = conn.execute("SELECT id, name FROM accounts ORDER BY id").fetchall()
        assert rows == [(1, "alice"), (2, "bob")]
        assert conn.execute("SELECT amt FROM balances WHERE acct_id = 2").fetchone()[0] == 250.75
        conn.close()

    def test_old_key_no_longer_opens_db(self, tmp_path: Path):
        """After rekey, the old key must not be able to open the DB."""
        import sqlcipher3
        db_file = self._create_encrypted_db(tmp_path, "old-key")
        url = f"sqlite:///{db_file}"
        result = db_crypto.rotate_db_key(
            url=url, old_key="old-key", new_key="new-key",
        )
        assert result.ok

        # Old key raises on first query.
        conn = sqlcipher3.connect(str(db_file))
        conn.execute(f'PRAGMA key = "old-key"')
        with pytest.raises(Exception) as exc_info:
            conn.execute("SELECT * FROM accounts").fetchall()
        msg = str(exc_info.value).lower()
        assert "not a database" in msg or "file is encrypted" in msg
        conn.close()

    def test_wrong_old_key_reports_helpful_reason(self, tmp_path: Path):
        """If the operator types the wrong old key, the rekey fails clean."""
        db_file = self._create_encrypted_db(tmp_path, "correct-old")
        url = f"sqlite:///{db_file}"
        result = db_crypto.rotate_db_key(
            url=url, old_key="WRONG-OLD-KEY", new_key="new",
        )
        assert result.ok is False
        assert "old key did not open" in result.reason.lower()

    def test_in_memory_url_is_rejected(self):
        """In-memory DBs cannot be rekeyed (nothing to persist)."""
        result = db_crypto.rotate_db_key(
            url="sqlite:///:memory:", old_key="a", new_key="b",
        )
        assert result.ok is False
        assert "file-backed" in result.reason.lower() or "in-memory" in result.reason.lower()

    def test_missing_db_file_is_rejected(self, tmp_path: Path):
        result = db_crypto.rotate_db_key(
            url=f"sqlite:///{tmp_path}/does-not-exist.db",
            old_key="a", new_key="b",
        )
        assert result.ok is False
        assert "not found" in result.reason.lower()
