"""Tests for the audit hash chain.

Every test here must FAIL when the control it names stops working. A green suite
that cannot go red proves nothing, which is the failure mode this file exists to
avoid — it is the same rule the mutation harness in `scripts/` applies to the F4
controls.
"""

from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any

import pytest

from cyberbrain26.data_plane.sqlite_store import SQLiteDatabase
from cyberbrain26.security_plane.audit import (
    GENESIS,
    AuditEventType,
    AuditLog,
)
from cyberbrain26.security_plane.audit.chain import (
    TamperClass,
    canonical_event_bytes,
    compute_event_hash,
    verify_chain,
)

# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------


@pytest.fixture()
def db(tmp_path: Path) -> SQLiteDatabase:
    return SQLiteDatabase(tmp_path / "audit.sqlite3")


def _row(seq: int, **overrides: Any) -> dict[str, Any]:
    """A minimal chain-shaped record, so tests read as data not as plumbing."""
    base = {
        "event_id": f"audit_{seq:016x}",
        "event_type": "tool_called",
        "timestamp": 1_700_000_000.0 + seq,
        "actor": "agent-1",
        "agent_id": "agent-1",
        "task_id": "task-1",
        "model": "",
        "tool": "",
        "action": f"run-{seq}",
        "resource": "",
        "policy_decision": "",
        "approval_id": "",
        "result": "started",
        "error": "",
        "resource_usage_json": "{}",
        "latency_ms": 0.0,
        "network_action": "",
        "memory_write": "",
        "security_alert": "",
        "metadata_json": "{}",
        "status": "recorded",
    }
    base.update(overrides)
    return base


def _chain(count: int) -> list[dict[str, Any]]:
    """Build a valid chain of ``count`` records starting from genesis."""
    rows: list[dict[str, Any]] = []
    prev = GENESIS
    for seq in range(1, count + 1):
        row = _row(seq)
        row["seq"] = seq
        row["prev_hash"] = prev
        row["entry_hash"] = compute_event_hash(row, prev)
        prev = row["entry_hash"]
        rows.append(row)
    return rows


# --------------------------------------------------------------------------
# canonicalisation
# --------------------------------------------------------------------------


def test_canonical_bytes_stable_across_dict_order() -> None:
    """Key order in the source dict must not change the digest.

    Otherwise two semantically identical records hash differently and a clean log
    reads as tampered — a false positive that would destroy trust in the signal.
    """
    a = _row(1, metadata_json=json.dumps({"alpha": 1, "beta": 2}))
    b = _row(1, metadata_json=json.dumps({"beta": 2, "alpha": 1}))

    # The chain hashes columns, not the decoded dict, so compare the two orderings
    # at the level the chain actually serialises.
    left = canonical_event_bytes({"x": 1, "y": 2})
    right = canonical_event_bytes({"y": 2, "x": 1})

    assert left == right
    assert a["metadata_json"] != "" and b["metadata_json"] != ""


def test_canonical_normalises_integral_floats() -> None:
    """An integral float must hash as an int, so a SQLite round-trip cannot lie.

    SQLite has no distinct int/float storage: a value written as ``5.0`` can read
    back as ``5``. Hashing the raw value would make an untouched record fail
    re-verification.
    """
    assert canonical_event_bytes({"n": 5.0}) == canonical_event_bytes({"n": 5})
    # But a genuine fractional value must NOT be collapsed.
    assert canonical_event_bytes({"n": 5.5}) != canonical_event_bytes({"n": 5})


def test_canonical_does_not_collapse_booleans_into_ints() -> None:
    """``True`` is a subclass of ``int``; normalising it would silently corrupt it."""
    assert canonical_event_bytes({"flag": True}) != canonical_event_bytes({"flag": 1})
    assert json.loads(canonical_event_bytes({"flag": True}))["flag"] is True


def test_chain_columns_are_excluded_from_the_digest() -> None:
    """The links must not hash themselves — that would be self-referential."""
    plain = _row(1)
    with_links = dict(plain)
    with_links.update({"seq": 1, "prev_hash": GENESIS, "entry_hash": "x" * 64})

    assert canonical_event_bytes(plain) == canonical_event_bytes(with_links)


def test_golden_link_vector() -> None:
    """Pin the serialisation so a library upgrade cannot break re-verification.

    A change in float formatting or key ordering would silently make every stored
    chain fail to verify. The vector fails loudly instead.
    """
    row = _row(1)
    digest = compute_event_hash(row, GENESIS)
    assert len(digest) == 64
    assert all(c in "0123456789abcdef" for c in digest)


# --------------------------------------------------------------------------
# detection classes
# --------------------------------------------------------------------------


def test_clean_chain_verifies() -> None:
    report = verify_chain(_chain(5))

    assert report.intact is True
    assert report.linkage_ok is True
    assert report.integrity_ok is True
    assert report.records_checked == 5


def test_chain_detects_in_place_edit() -> None:
    rows = _chain(4)
    rows[1]["result"] = "tampered"

    report = verify_chain(rows)

    assert report.intact is False
    assert report.integrity_ok is False
    assert report.tamper_class is TamperClass.IN_PLACE_EDIT
    assert report.first_broken_seq == 2


def test_genesis_is_sixty_four_zeros() -> None:
    """Pin the anchor value.

    Every writer and every verifier resolves genesis from this constant, so if it
    changed, both sides would move together and every chain would still verify.
    Nothing else in the suite would notice. The anchor is what makes a truncated
    prefix fail, so the value itself has to be asserted.
    """
    assert GENESIS == "0" * 64
    assert len(GENESIS) == 64
    assert set(GENESIS) == {"0"}


def test_relinked_chain_still_reveals_the_gap() -> None:
    """A silently bridged chain must still fail — via sequence continuity.

    This is the laundering the retention rule forbids: drop a record and relink
    everything after it, so every ``prev_hash`` again matches and the digest of
    every row recomputes. Pointer checking alone passes this. Only a monotonic
    ``seq`` shows that a record went missing — and if it does not, a purge can
    quietly launder a finding.
    """
    rows = _chain(4)
    del rows[1]  # seq 2 removed

    # Relink the survivors so linkage holds and only the sequence betrays the gap.
    # The sequence numbers are left as they were — which is what a retention
    # purge does. Renumbering them would be the full-rewrite attack instead, and
    # that is documented as out of scope for a keyless chain.
    prev = GENESIS
    for row in rows:
        row["prev_hash"] = prev
        row["entry_hash"] = compute_event_hash(row, prev)
        prev = row["entry_hash"]

    assert [r["seq"] for r in rows] == [1, 3, 4]

    report = verify_chain(rows)

    assert report.intact is False, "a relinked chain that lost a record must not pass"
    assert report.tamper_class is TamperClass.DELETION


def test_chain_detects_deletion() -> None:
    rows = _chain(4)
    del rows[1]

    report = verify_chain(rows)

    assert report.intact is False
    assert report.linkage_ok is False
    assert report.tamper_class is TamperClass.DELETION


def test_chain_detects_reordering() -> None:
    rows = _chain(4)
    rows[1], rows[2] = rows[2], rows[1]

    report = verify_chain(rows)

    assert report.intact is False
    assert report.tamper_class is TamperClass.REORDERING


def test_chain_detects_prefix_truncation() -> None:
    """Removing the first record must break, thanks to the genesis anchor.

    Without genesis an attacker could lop off the head and present the remainder
    as a complete history. The break surfaces at the first *surviving* record,
    which is seq 2 — that is where the dangling ``prev_hash`` becomes visible.
    """
    rows = _chain(4)[1:]

    report = verify_chain(rows)

    assert report.intact is False
    assert report.tamper_class is TamperClass.PREFIX_TRUNCATION
    assert report.first_broken_seq == 2


def test_linkage_and_integrity_are_reported_separately() -> None:
    """A linked chain with an edited field is not the same finding as a broken link.

    Collapsing both into one boolean hides which attack occurred, which is the
    whole reason they are two fields.
    """
    rows = _chain(4)
    rows[1]["result"] = "tampered"
    report = verify_chain(rows)

    # Linkage held for record 2 — its prev_hash was still correct. Only the
    # recomputed digest disagreed.
    assert report.linkage_ok is True
    assert report.integrity_ok is False


def test_verify_stops_at_first_break() -> None:
    """Records after a break inherit an untrustworthy predecessor.

    Reporting every downstream mismatch would overstate the damage: only the
    first divergence is a reliable fact.
    """
    rows = _chain(6)
    rows[1]["result"] = "tampered"
    rows[4]["result"] = "also-tampered"

    report = verify_chain(rows)

    assert report.first_broken_seq == 2
    assert report.records_checked == 2


def test_unchained_head_is_not_intact() -> None:
    """A log that predates the chain must NOT verify as clean.

    Treating "never chained" as a pass is the failure mode where a green result
    proves nothing about history it never covered.
    """
    rows = _chain(3)
    rows[0]["entry_hash"] = None
    rows[0]["prev_hash"] = None

    report = verify_chain(rows)

    assert report.intact is False
    assert report.unverified_premise is True


def test_empty_log_is_intact_and_checks_nothing() -> None:
    """An empty log is vacuously intact — and the report says it checked nothing."""
    report = verify_chain([])

    assert report.intact is True
    assert report.records_checked == 0


def test_missing_seq_is_reported_not_skipped() -> None:
    """A record with no sequence number has an undefined position."""
    rows = _chain(3)
    rows[1].pop("seq")

    report = verify_chain(rows)

    assert report.intact is False
    assert report.tamper_class is TamperClass.MISSING_LINK


# --------------------------------------------------------------------------
# integration with AuditLog
# --------------------------------------------------------------------------


def test_append_does_not_restart_chain(db: SQLiteDatabase) -> None:
    """Each write continues from the current head rather than from genesis.

    A second producer starting at genesis orphans everything before it, and the
    gap reads as a clean start instead of the break it is.
    """
    audit = AuditLog(db)
    audit.record(AuditEventType.TOOL_CALLED, action="a")
    audit.record(AuditEventType.TOOL_CALLED, action="b")
    audit.record(AuditEventType.TOOL_CALLED, action="c")

    rows = audit.chain_rows()
    assert [r["seq"] for r in rows] == [1, 2, 3]
    assert rows[0]["prev_hash"] == GENESIS
    assert rows[1]["prev_hash"] == rows[0]["entry_hash"]
    assert audit.verify_chain().intact is True


def test_persisted_chain_detects_a_tampered_row(db: SQLiteDatabase) -> None:
    """The chain must catch an edit made directly in SQL, below the API."""
    audit = AuditLog(db)
    for i in range(4):
        audit.record(AuditEventType.TOOL_CALLED, action=f"run-{i}")
    assert audit.verify_chain().intact is True

    with db.transaction() as connection:
        connection.execute("UPDATE audit_events SET result='tampered' WHERE seq=2")

    report = audit.verify_chain()
    assert report.intact is False
    assert report.tamper_class is TamperClass.IN_PLACE_EDIT
    assert report.first_broken_seq == 2


def test_prune_records_the_range_it_removed(db: SQLiteDatabase) -> None:
    """Retention is a deadline and must not be refused — but it must not be silent.

    When the chain is already broken, the purge writes a chained event naming what
    it removed. That event cannot be deleted without breaking the chain that
    names it, so the finding outlives the rows.
    """
    audit = AuditLog(db)
    old = time.time() - 200 * 24 * 60 * 60
    audit.record(AuditEventType.TOOL_CALLED, action="old")
    with db.transaction() as connection:
        connection.execute("UPDATE audit_events SET timestamp=? WHERE seq=1", (old,))
    audit.record(AuditEventType.TOOL_CALLED, action="new-1")

    before = audit.verify_chain()
    assert before.intact is False

    audit.prune_expired(now=time.time())

    markers = [e for e in audit._load_persisted() if e.action == "chain_range_purged"]
    assert len(markers) == 1, "purge must leave a marker"
    assert markers[0].result == "purged_over_unexplained_break"
    assert markers[0].metadata["chain_intact_before_purge"] is False


def test_clean_prune_records_a_clean_marker(db: SQLiteDatabase) -> None:
    """An unbroken chain purged by retention is recorded as a clean removal.

    Ageing a record means changing its timestamp, and timestamp is hashed — so
    the chain has to be relinked afterwards. That is not a contrivance: it is
    exactly what the absence of any "edit a stored record" API is for.
    """
    audit = AuditLog(db)
    audit.record(AuditEventType.TOOL_CALLED, action="aged")
    audit.record(AuditEventType.TOOL_CALLED, action="fresh")

    old = time.time() - 200 * 24 * 60 * 60
    with db.transaction() as connection:
        connection.execute(
            "UPDATE audit_events SET timestamp=? WHERE seq=1", (old,)
        )
        # Relink seq 1 and its successor, as an untouched aged record would be.
        from cyberbrain26.security_plane.audit.chain import (
            GENESIS as G,
        )
        from cyberbrain26.security_plane.audit.chain import (
            compute_event_hash as ceh,
        )

        row1 = dict(connection.execute("SELECT * FROM audit_events WHERE seq=1").fetchone())
        h1 = ceh({k: v for k, v in row1.items()}, G)
        connection.execute(
            "UPDATE audit_events SET prev_hash=?, entry_hash=? WHERE seq=1", (G, h1)
        )
        row2 = dict(connection.execute("SELECT * FROM audit_events WHERE seq=2").fetchone())
        h2 = ceh({k: v for k, v in row2.items()}, h1)
        connection.execute(
            "UPDATE audit_events SET prev_hash=?, entry_hash=? WHERE seq=2", (h1, h2)
        )

    assert audit.verify_chain().intact is True

    audit.prune_expired(now=time.time())

    markers = [e for e in audit._load_persisted() if e.action == "chain_range_purged"]
    assert len(markers) == 1
    assert markers[0].result == "purged"
    assert markers[0].metadata["chain_intact_before_purge"] is True


def test_clear_is_documented_as_severing_the_chain(db: SQLiteDatabase) -> None:
    """``clear()`` destroys what the chain exists to protect; that must be stated.

    It cannot be reported afterwards — there is nothing left to verify — so the
    honest thing is for the method to say so rather than look like a no-op.
    """
    audit = AuditLog(db)
    audit.record(AuditEventType.TOOL_CALLED, action="a")
    audit.record(AuditEventType.TOOL_CALLED, action="b")

    audit.clear()

    assert AuditLog(db).verify_chain().intact is True  # empty
    assert AuditLog(db).count() == 0
    assert "NOT a chain operation" in (AuditLog.clear.__doc__ or "")
