"""Tests for manifest encryption at rest (AES-256-GCM)."""

from __future__ import annotations

import logging
import os
from pathlib import Path

import pytest

from certisigma_census.encryption import (
    HEADER,
    decrypt_file,
    encrypt_file,
    is_encrypted_file,
    validate_key,
)
from certisigma_census.manifest import Manifest, ManifestEntry


VALID_KEY = "a" * 64
ALT_KEY = "b" * 64


# ── validate_key ───────────────────────────────────────────────────────

def test_validate_key_valid():
    raw = validate_key(VALID_KEY)
    assert len(raw) == 32
    assert isinstance(raw, bytes)


def test_validate_key_too_short():
    with pytest.raises(ValueError, match="64 hex characters"):
        validate_key("abcd1234")


def test_validate_key_too_long():
    with pytest.raises(ValueError, match="64 hex characters"):
        validate_key("a" * 128)


def test_validate_key_not_hex():
    with pytest.raises(ValueError, match="64 hex characters"):
        validate_key("g" * 64)


def test_validate_key_none():
    with pytest.raises(ValueError):
        validate_key(None)  # type: ignore[arg-type]


# ── encrypt_file / decrypt_file round-trip ──────────────────────────────

def test_encrypt_decrypt_roundtrip(tmp_path: Path):
    src = tmp_path / "plain.db"
    enc = tmp_path / "plain.db.enc"
    content = b"SQLite format 3\x00" + os.urandom(4096)
    src.write_bytes(content)

    encrypt_file(src, enc, VALID_KEY)

    assert enc.exists()
    raw = enc.read_bytes()
    assert raw[:len(HEADER)] == HEADER

    recovered = decrypt_file(enc, VALID_KEY)
    assert recovered == content


def test_encrypt_creates_restrictive_permissions(tmp_path: Path):
    src = tmp_path / "data.db"
    enc = tmp_path / "data.db.enc"
    src.write_bytes(b"test content")

    encrypt_file(src, enc, VALID_KEY)

    stat = enc.stat()
    assert stat.st_mode & 0o777 == 0o600


def test_decrypt_wrong_key(tmp_path: Path):
    src = tmp_path / "plain.db"
    enc = tmp_path / "plain.db.enc"
    src.write_bytes(b"secret data")
    encrypt_file(src, enc, VALID_KEY)

    from cryptography.exceptions import InvalidTag
    with pytest.raises(InvalidTag):
        decrypt_file(enc, ALT_KEY)


def test_decrypt_corrupt_ciphertext(tmp_path: Path):
    src = tmp_path / "plain.db"
    enc = tmp_path / "plain.db.enc"
    src.write_bytes(b"test data")
    encrypt_file(src, enc, VALID_KEY)

    data = bytearray(enc.read_bytes())
    data[-1] ^= 0xFF
    enc.write_bytes(bytes(data))

    from cryptography.exceptions import InvalidTag
    with pytest.raises(InvalidTag):
        decrypt_file(enc, VALID_KEY)


def test_decrypt_not_encrypted_file(tmp_path: Path):
    plain = tmp_path / "not_encrypted.db"
    plain.write_bytes(b"SQLite format 3 regular database")

    with pytest.raises(ValueError, match="bad magic"):
        decrypt_file(plain, VALID_KEY)


def test_decrypt_file_too_small(tmp_path: Path):
    tiny = tmp_path / "tiny.enc"
    tiny.write_bytes(b"CENSUS_ENC")

    with pytest.raises(ValueError, match="too small"):
        decrypt_file(tiny, VALID_KEY)


def test_encrypt_large_file(tmp_path: Path):
    src = tmp_path / "large.db"
    enc = tmp_path / "large.db.enc"
    content = os.urandom(1024 * 1024)  # 1 MB
    src.write_bytes(content)

    encrypt_file(src, enc, VALID_KEY)
    recovered = decrypt_file(enc, VALID_KEY)
    assert recovered == content


# ── is_encrypted_file ──────────────────────────────────────────────────

def test_is_encrypted_file_positive(tmp_path: Path):
    enc = tmp_path / "manifest.db.enc"
    src = tmp_path / "src.db"
    src.write_bytes(b"test")
    encrypt_file(src, enc, VALID_KEY)

    assert is_encrypted_file(enc) is True


def test_is_encrypted_file_negative_sqlite(tmp_path: Path):
    db = tmp_path / "manifest.db"
    db.write_bytes(b"SQLite format 3\x00regular data")

    assert is_encrypted_file(db) is False


def test_is_encrypted_file_nonexistent(tmp_path: Path):
    assert is_encrypted_file(tmp_path / "missing.enc") is False


# ── Manifest integration ──────────────────────────────────────────────

def test_manifest_save_encrypted_load_roundtrip(tmp_path: Path):
    m = Manifest(root_dir="/data/encrypted")
    try:
        for i in range(10):
            m.add(ManifestEntry(
                hash_hex=f"{i:064x}", path=f"file_{i}.dat",
                size=i * 100 + 50, mtime=float(i),
            ))
        m.mark_attested(f"{0:064x}", "att_1", "2026-01-01T00:00:00Z")

        fpath = tmp_path / "manifest.db"
        m.save(fpath, encryption_key=VALID_KEY)
    finally:
        m.close()

    enc_path = fpath.with_suffix(".db.enc")
    assert enc_path.exists()
    assert not fpath.exists(), "Plaintext .db should be deleted after encryption"
    assert is_encrypted_file(enc_path)

    with Manifest.load(fpath, encryption_key=VALID_KEY) as loaded:
        assert loaded.total == 10
        assert loaded.attested_count == 1
        assert loaded.root_dir == "/data/encrypted"
        e0 = loaded.get(f"{0:064x}")
        assert e0 is not None
        assert e0.attested is True


def test_manifest_load_encrypted_no_key_raises(tmp_path: Path):
    m = Manifest(root_dir="/test")
    try:
        m.add(ManifestEntry(hash_hex="ab" * 32, path="f.txt", size=10, mtime=1.0))
        m.save(tmp_path / "manifest.db", encryption_key=VALID_KEY)
    finally:
        m.close()

    with pytest.raises(ValueError, match="encrypted"):
        Manifest.load(tmp_path / "manifest.db")


def test_manifest_save_no_key_stays_plaintext(tmp_path: Path):
    m = Manifest(root_dir="/plain")
    try:
        m.add(ManifestEntry(hash_hex="cd" * 32, path="p.txt", size=20, mtime=2.0))
        fpath = tmp_path / "manifest.db"
        m.save(fpath)
    finally:
        m.close()

    assert fpath.exists()
    assert not fpath.with_suffix(".db.enc").exists()
    assert not is_encrypted_file(fpath)

    with Manifest.load(fpath) as loaded:
        assert loaded.total == 1


def test_manifest_encrypted_temp_cleaned_on_close(tmp_path: Path):
    """Verify decrypted temp DB is removed when Manifest.close() is called."""
    m = Manifest(root_dir="/secure")
    try:
        m.add(ManifestEntry(hash_hex="ab" * 32, path="s.txt", size=10, mtime=1.0))
        m.save(tmp_path / "m.db", encryption_key=VALID_KEY)
    finally:
        m.close()

    loaded = Manifest.load(tmp_path / "m.db", encryption_key=VALID_KEY)
    temp_path = loaded._temp_db_path
    assert temp_path is not None
    assert os.path.exists(temp_path)
    loaded.close()
    assert not os.path.exists(temp_path), "Temp DB should be deleted on close()"


def test_manifest_close_logs_when_temp_unlink_fails(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
    """If temp decrypted DB cannot be unlinked, emit a warning (operator visibility)."""
    import certisigma_census.manifest as manifest_mod

    m = Manifest(root_dir="/secure")
    try:
        m.add(ManifestEntry(hash_hex="ab" * 32, path="s.txt", size=10, mtime=1.0))
        m.save(tmp_path / "m.db", encryption_key=VALID_KEY)
    finally:
        m.close()

    orig_unlink = manifest_mod.os.unlink

    def flaky_unlink(p: str | os.PathLike[str]) -> None:
        if "census_" in str(p):
            raise OSError("simulated lock")
        orig_unlink(p)

    monkeypatch.setattr(manifest_mod.os, "unlink", flaky_unlink)

    loaded = Manifest.load(tmp_path / "m.db", encryption_key=VALID_KEY)
    tmp_db = loaded._temp_db_path
    assert tmp_db is not None
    with caplog.at_level(logging.WARNING, logger="certisigma_census.manifest"):
        loaded.close()
    assert "Cannot remove temporary decrypted manifest database" in caplog.text
    assert os.path.exists(tmp_db)
    monkeypatch.setattr(manifest_mod.os, "unlink", orig_unlink)
    os.unlink(tmp_db)


def test_encrypt_file_atomic_cleanup(tmp_path: Path, monkeypatch):
    """Verify temp file is cleaned up on write failure."""
    src = tmp_path / "src.db"
    dst = tmp_path / "dst.enc"
    src.write_bytes(b"test data")

    original_rename = os.rename

    def failing_rename(*args, **kwargs):
        raise OSError("disk full")

    monkeypatch.setattr("os.rename", failing_rename)

    with pytest.raises(OSError, match="disk full"):
        encrypt_file(src, dst, VALID_KEY)

    tmp_file = dst.with_suffix(dst.suffix + ".tmp")
    assert not tmp_file.exists(), "Temp file should be cleaned up on failure"


def test_encrypt_file_logs_when_temp_cleanup_fails(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
    """encrypt_file must log a warning if temp ciphertext file cannot be removed on failure."""
    import certisigma_census.encryption as enc_mod

    src = tmp_path / "plain.db"
    src.write_bytes(b"some data")
    dst = tmp_path / "output.db.enc"
    tmp_file = dst.with_suffix(dst.suffix + ".tmp")

    orig_unlink = enc_mod.os.unlink

    def block_tmp_unlink(p: str) -> None:
        if str(p).endswith(".tmp"):
            raise OSError("simulated permission denied")
        orig_unlink(p)

    orig_rename = enc_mod.os.rename

    def fail_rename(src_p: str, dst_p: str) -> None:
        raise OSError("simulated disk full")

    monkeypatch.setattr(enc_mod.os, "rename", fail_rename)
    monkeypatch.setattr(enc_mod.os, "unlink", block_tmp_unlink)

    with caplog.at_level(logging.WARNING, logger="certisigma_census.encryption"):
        with pytest.raises(OSError, match="simulated disk full"):
            encrypt_file(src, dst, VALID_KEY)

    assert "Cannot remove temporary encryption file" in caplog.text
    monkeypatch.setattr(enc_mod.os, "unlink", orig_unlink)
    if tmp_file.exists():
        tmp_file.unlink()


def test_save_encrypted_cleans_wal_shm(tmp_path: Path):
    """Verify save() with encryption removes .db, .db-wal, .db-shm files."""
    m = Manifest(root_dir="/test")
    try:
        m.add(ManifestEntry(hash_hex="cd" * 32, path="w.txt", size=5, mtime=1.0))
        db_path = tmp_path / "manifest.db"
        m.save(db_path)
        assert db_path.exists()

        wal = Path(str(db_path) + "-wal")
        shm = Path(str(db_path) + "-shm")
        wal.write_bytes(b"fake wal")
        shm.write_bytes(b"fake shm")

        m.save(db_path, encryption_key=VALID_KEY)

        assert not db_path.exists(), "Plaintext .db should be removed"
        assert not wal.exists(), "WAL file should be removed"
        assert not shm.exists(), "SHM file should be removed"
        enc_path = db_path.with_suffix(".db.enc")
        assert enc_path.exists(), "Encrypted file should exist"
    finally:
        m.close()
