"""Tests for manifest creation, serialization, and round-trip (SQLite backend)."""

import json
from pathlib import Path

import pytest

from certisigma_census.manifest import Manifest, ManifestEntry


def test_add_and_get():
    m = Manifest(root_dir="/tmp/test")
    try:
        entry = ManifestEntry(hash_hex="ab" * 32, path="file.txt", size=1024, mtime=1.0)
        m.add(entry)
        assert m.total == 1
        got = m.get("ab" * 32)
        assert got is not None
        assert got.hash_hex == entry.hash_hex
        assert got.path == entry.path
        assert got.size == entry.size
        assert m.get("00" * 32) is None
    finally:
        m.close()


def test_mark_attested():
    m = Manifest(root_dir="/tmp/test")
    try:
        entry = ManifestEntry(hash_hex="ab" * 32, path="file.txt", size=1024, mtime=1.0)
        m.add(entry)
        assert m.attested_count == 0
        m.mark_attested("ab" * 32, "att_42", "2026-01-01T00:00:00Z")
        assert m.attested_count == 1
        refreshed = m.get("ab" * 32)
        assert refreshed.attested is True
        assert refreshed.attestation_id == "att_42"
    finally:
        m.close()


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

        fpath = tmp_path / "manifest.db"
        m.save(fpath)

        with Manifest.load(fpath) as loaded:
            assert loaded.total == 5
            assert loaded.attested_count == 1
            assert loaded.root_dir == "/data/sensitive"
            assert loaded.get(f"{0:064x}").attested is True
    finally:
        m.close()


def test_duplicate_hash_overwrites():
    m = Manifest(root_dir="/tmp")
    try:
        e1 = ManifestEntry(hash_hex="ab" * 32, path="a.txt", size=100, mtime=1.0)
        e2 = ManifestEntry(hash_hex="ab" * 32, path="b.txt", size=200, mtime=2.0)
        m.add(e1)
        m.add(e2)
        assert m.total == 1
        assert m.get("ab" * 32).path == "b.txt"
    finally:
        m.close()


# ─── has_path ──────────────────────────────────────────────────────────────

def test_has_path_found():
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="docs/report.pdf", size=500, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="data.csv", size=200, mtime=2.0))

        found = m.has_path("docs/report.pdf")
        assert found is not None
        assert found.hash_hex == "aa" * 32
    finally:
        m.close()


def test_has_path_not_found():
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="file.txt", size=100, mtime=1.0))
        assert m.has_path("other.txt") is None
    finally:
        m.close()


# ─── remove_by_path ──────────────────────────────────────────────────────

def test_remove_by_path():
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="file.txt", size=100, mtime=1.0))
        assert m.total == 1
        assert m.remove_by_path("file.txt") is True
        assert m.total == 0
        assert m.remove_by_path("nonexistent.txt") is False
    finally:
        m.close()


# ─── unattested_hashes ────────────────────────────────────────────────────

def test_unattested_hashes():
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=2.0))
        m.mark_attested("aa" * 32, "att_1", "2026-01-01")
        hashes = m.unattested_hashes()
        assert hashes == ["bb" * 32]
    finally:
        m.close()


# ─── iter_entries ─────────────────────────────────────────────────────────

def test_iter_entries():
    m = Manifest(root_dir="/tmp")
    try:
        for i in range(3):
            m.add(ManifestEntry(hash_hex=f"{i:064x}", path=f"f{i}.txt", size=i * 10, mtime=float(i)))
        entries = list(m.iter_entries())
        assert len(entries) == 3
        assert all(isinstance(e, ManifestEntry) for e in entries)
    finally:
        m.close()


# ─── merge ─────────────────────────────────────────────────────────────────

def test_merge_adds_new_entries():
    base = Manifest(root_dir="/tmp")
    incoming = Manifest(root_dir="/tmp")
    try:
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        base.mark_attested("aa" * 32, "att_1", "2026-01-01T00:00:00Z")

        incoming.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=2.0))

        changed = base.merge(incoming)
        assert changed == 1
        assert base.total == 2
        assert base.get("aa" * 32).attested is True
        assert base.get("bb" * 32).attested is False
    finally:
        incoming.close()
        base.close()


def test_merge_preserves_attestation_on_unchanged():
    base = Manifest(root_dir="/tmp")
    incoming = Manifest(root_dir="/tmp")
    try:
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        base.mark_attested("aa" * 32, "att_1", "2026-01-01T00:00:00Z")

        incoming.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))

        changed = base.merge(incoming)
        assert changed == 0
        assert base.get("aa" * 32).attested is True
        assert base.get("aa" * 32).attestation_id == "att_1"
    finally:
        incoming.close()
        base.close()


def test_merge_updates_changed_file():
    """Same hash but different path/size is treated as an update."""
    base = Manifest(root_dir="/tmp")
    incoming = Manifest(root_dir="/tmp")
    try:
        base.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        base.mark_attested("aa" * 32, "att_1", "2026-01-01T00:00:00Z")

        incoming.add(ManifestEntry(hash_hex="aa" * 32, path="a_renamed.txt", size=100, mtime=3.0))

        changed = base.merge(incoming)
        assert changed == 1
        entry = base.get("aa" * 32)
        assert entry.path == "a_renamed.txt"
        assert entry.attested is True
    finally:
        incoming.close()
        base.close()


# ─── SQLite persistence ──────────────────────────────────────────────────

def test_save_creates_db(tmp_path: Path):
    """save() creates a .db SQLite file."""
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))

        fpath = tmp_path / "manifest.db"
        m.save(fpath)
        assert fpath.exists()
        header = fpath.read_bytes()[:6]
        assert header == b"SQLite"
    finally:
        m.close()


def test_save_with_json_extension_creates_db(tmp_path: Path):
    """save('foo.json') creates foo.db (SQLite), not a JSON file."""
    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))

        fpath = tmp_path / "manifest.json"
        m.save(fpath)
        db_path = tmp_path / "manifest.db"
        assert db_path.exists()
    finally:
        m.close()


# ─── JSON migration ──────────────────────────────────────────────────────

def test_load_migrates_json_to_sqlite(tmp_path: Path):
    """Loading a legacy JSON manifest auto-migrates to SQLite."""
    json_data = {
        "version": "1",
        "created_at": 1700000000.0,
        "updated_at": 1700000001.0,
        "root_dir": "/data/test",
        "entries": {
            "aa" * 32: {
                "hash_hex": "aa" * 32,
                "path": "doc.pdf",
                "size": 512,
                "mtime": 1.0,
                "attested": True,
                "attestation_id": "att_1",
                "attested_at": "2026-01-01",
            },
            "bb" * 32: {
                "hash_hex": "bb" * 32,
                "path": "data.csv",
                "size": 256,
                "mtime": 2.0,
                "attested": False,
                "attestation_id": None,
                "attested_at": None,
            },
        },
    }
    json_path = tmp_path / "manifest.json"
    json_path.write_text(json.dumps(json_data), encoding="utf-8")

    with Manifest.load(json_path) as loaded:
        assert loaded.total == 2
        assert loaded.root_dir == "/data/test"
        assert loaded.attested_count == 1
        assert loaded.get("aa" * 32).attested is True
        assert loaded.get("aa" * 32).attestation_id == "att_1"

    assert (tmp_path / "manifest.db").exists()
    assert (tmp_path / "manifest.json.bak").exists()
    assert not json_path.exists()


def test_schema_version(tmp_path: Path):
    """SQLite manifest uses PRAGMA user_version = SCHEMA_VERSION."""
    import sqlite3

    from certisigma_census.manifest import SCHEMA_VERSION

    m = Manifest(root_dir="/tmp")
    try:
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        db_path = tmp_path / "manifest.db"
        m.save(db_path)

        conn = sqlite3.connect(str(db_path))
        try:
            uv = conn.execute("PRAGMA user_version").fetchone()[0]
        finally:
            conn.close()
        assert uv == SCHEMA_VERSION
    finally:
        m.close()


# ─── close() ──────────────────────────────────────────────────────────────

def test_close_prevents_further_operations():
    """close() closes the DB connection; subsequent operations raise."""
    import sqlite3

    m = Manifest(root_dir="/tmp")
    m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
    m.close()
    with pytest.raises(sqlite3.ProgrammingError):
        m.total


# ─── load() error paths ──────────────────────────────────────────────────

def test_load_nonexistent_raises():
    """Manifest.load() with a nonexistent path raises FileNotFoundError."""
    with pytest.raises(FileNotFoundError):
        Manifest.load(Path("/nonexistent/path/manifest.db"))


# ─── JSON migration ──────────────────────────────────────────────────────

def test_load_legacy_json(tmp_path: Path):
    """Manifest.load() auto-migrates legacy JSON manifests to SQLite."""
    legacy = {
        "root_dir": "/legacy/data",
        "created_at": 1000.0,
        "updated_at": 2000.0,
        "entries": {
            "aa" * 32: {
                "hash_hex": "aa" * 32,
                "path": "a.txt",
                "size": 100,
                "mtime": 1.0,
                "attested": False,
                "attestation_id": None,
                "attested_at": None,
            }
        }
    }
    jp = tmp_path / "manifest.json"
    jp.write_text(json.dumps(legacy))

    with Manifest.load(jp) as m:
        assert m.total == 1
        assert m.root_dir == "/legacy/data"
        entry = m.get("aa" * 32)
        assert entry is not None
        assert entry.path == "a.txt"

    assert jp.with_suffix(".json.bak").exists()
    assert jp.with_suffix(".db").exists()


def test_load_legacy_json_invalid(tmp_path: Path):
    """Manifest.load() raises on invalid JSON manifest structure."""
    (tmp_path / "bad.json").write_text('{"entries": 42, "extra": "bad"}')
    with pytest.raises(ValueError, match="not a dict"):
        Manifest.load(tmp_path / "bad.json")


def test_load_legacy_json_invalid_entries(tmp_path: Path):
    (tmp_path / "bad.json").write_text('{"entries": "not_a_dict"}')
    with pytest.raises(ValueError, match="not a dict"):
        Manifest.load(tmp_path / "bad.json")


# ─── Encrypted manifest detection ────────────────────────────────────────

def test_resolve_enc_path():
    p = Path("/data/manifest.db")
    from certisigma_census.manifest import Manifest
    enc = Manifest._resolve_enc_path(p)
    assert enc == Path("/data/manifest.db.enc")


def test_resolve_enc_path_from_json():
    p = Path("/data/manifest.json")
    from certisigma_census.manifest import Manifest
    enc = Manifest._resolve_enc_path(p)
    assert enc == Path("/data/manifest.db.enc")


# ─── context manager ─────────────────────────────────────────────────────

def test_context_manager():
    """Manifest works as a context manager."""
    with Manifest(root_dir="/ctx") as m:
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=50, mtime=1.0))
        assert m.total == 1


# ─── conn_is_memory ──────────────────────────────────────────────────────

def test_conn_is_memory():
    m = Manifest(root_dir="/mem")
    try:
        assert m._conn_is_memory() is True
    finally:
        m.close()


def test_conn_is_not_memory(tmp_path: Path):
    m = Manifest(root_dir="/disk")
    fpath = tmp_path / "test.db"
    m.save(fpath)
    m.close()

    m2 = Manifest.load(fpath)
    try:
        assert m2._conn_is_memory() is False
    finally:
        m2.close()


# ─── _is_json_file / _is_sqlite_file ─────────────────────────────────────

def test_is_json_file_nonexistent(tmp_path: Path):
    from certisigma_census.manifest import _is_json_file
    assert _is_json_file(tmp_path / "gone.json") is False


def test_is_sqlite_file_nonexistent(tmp_path: Path):
    from certisigma_census.manifest import _is_sqlite_file
    assert _is_sqlite_file(tmp_path / "gone.db") is False


def test_is_json_file_positive(tmp_path: Path):
    from certisigma_census.manifest import _is_json_file
    f = tmp_path / "test.json"
    f.write_text('{"key": "val"}')
    assert _is_json_file(f) is True


def test_is_sqlite_file_positive(tmp_path: Path):
    from certisigma_census.manifest import _is_sqlite_file
    import sqlite3
    db = tmp_path / "test.db"
    conn = sqlite3.connect(str(db))
    conn.execute("CREATE TABLE t (id INTEGER)")
    conn.commit()
    conn.close()
    assert _is_sqlite_file(db) is True


def test_file_metadata_round_trip(tmp_path: Path):
    """ManifestEntry with owner/group/mode persists through save/load."""
    m = Manifest(root_dir="/test")
    try:
        m.add(ManifestEntry(
            hash_hex="dd" * 32, path="owned.txt", size=100, mtime=1.0,
            owner="root", file_group="staff", file_mode=0o644,
        ))
        db_path = tmp_path / "meta.db"
        m.save(db_path)
    finally:
        m.close()

    loaded = Manifest.load(db_path)
    try:
        entry = loaded.get("dd" * 32)
        assert entry is not None
        assert entry.owner == "root"
        assert entry.file_group == "staff"
        assert entry.file_mode == 0o644
    finally:
        loaded.close()


def test_schema_v2_to_v3_migration(tmp_path: Path):
    """Loading a v2 manifest auto-adds owner/group/mode columns."""
    import sqlite3

    db_path = tmp_path / "v2.db"
    conn = sqlite3.connect(str(db_path))
    conn.executescript("""
        PRAGMA journal_mode = WAL;
        CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
        CREATE TABLE entries (
            hash_hex TEXT PRIMARY KEY, path TEXT NOT NULL,
            size INTEGER NOT NULL, mtime REAL NOT NULL,
            attested INTEGER NOT NULL DEFAULT 0,
            attestation_id TEXT, attested_at TEXT
        );
        PRAGMA user_version = 2;
    """)
    conn.execute(
        "INSERT INTO meta VALUES ('root_dir', '/old'), ('created_at', '1.0'), ('updated_at', '1.0')"
    )
    conn.execute(
        "INSERT INTO entries (hash_hex, path, size, mtime, attested) VALUES (?, ?, ?, ?, ?)",
        ("ee" * 32, "old.txt", 50, 1.0, 0),
    )
    conn.commit()
    conn.close()

    loaded = Manifest.load(db_path)
    try:
        entry = loaded.get("ee" * 32)
        assert entry is not None
        assert entry.owner is None
        assert entry.file_group is None
        assert entry.file_mode is None

        conn2 = sqlite3.connect(str(db_path))
        try:
            uv = conn2.execute("PRAGMA user_version").fetchone()[0]
            assert uv == 3
        finally:
            conn2.close()
    finally:
        loaded.close()


def test_file_metadata_none_for_old_entries(tmp_path: Path):
    """Entries without file metadata default to None."""
    m = Manifest(root_dir="/test")
    try:
        m.add(ManifestEntry(hash_hex="ff" * 32, path="no_meta.txt", size=10, mtime=1.0))
        entry = m.get("ff" * 32)
        assert entry is not None
        assert entry.owner is None
        assert entry.file_group is None
        assert entry.file_mode is None
    finally:
        m.close()
