"""Tests for the tamper-evident hash-chained audit_log.

SP11 T1.
"""
from __future__ import annotations

from datetime import datetime, timezone

import json
import pytest

from cyclone import db
from cyclone.audit_log import (
    GENESIS_PREV_HASH,
    HASH_LEN,
    AuditEvent,
    append_event,
    verify_chain,
)
from cyclone.db import AuditLog, init_db


@pytest.fixture(autouse=True)
def _fresh_db():
    init_db()
    yield


def _row_count() -> int:
    with db.SessionLocal()() as s:
        return s.query(AuditLog).count()


# --------------------------------------------------------------------------- #
# Append
# --------------------------------------------------------------------------- #


class TestAppendEvent:
    def test_first_row_has_genesis_prev_hash(self):
        """The first row in a fresh chain uses the all-zeros prev_hash."""
        with db.SessionLocal()() as s:
            row = append_event(s, AuditEvent(
                event_type="claim.parsed",
                entity_type="claim",
                entity_id="c1",
                payload={"patient_control_number": "PCN-1"},
            ))
            s.commit()
        assert row.id == 1
        assert row.prev_hash == GENESIS_PREV_HASH
        assert len(row.hash) == HASH_LEN

    def test_second_row_chains_to_first(self):
        """Row N's prev_hash == row N-1's hash."""
        with db.SessionLocal()() as s:
            r1 = append_event(s, AuditEvent(
                event_type="claim.parsed", entity_type="claim", entity_id="c1",
            ))
            s.commit()
            r2 = append_event(s, AuditEvent(
                event_type="claim.rejected", entity_type="claim", entity_id="c1",
            ))
            s.commit()
        assert r2.prev_hash == r1.hash
        assert r2.id == r1.id + 1

    def test_payload_canonicalization(self):
        """Two payloads with the same content but different key order hash the same.

        We rely on sort_keys=True to make the canonical form independent
        of dict insertion order.
        """
        with db.SessionLocal()() as s:
            r1 = append_event(s, AuditEvent(
                event_type="x", entity_type="y", entity_id="z",
                payload={"a": 1, "b": 2},
            ))
            s.commit()
            # New session to drop any in-memory ordering cache.
            with db.SessionLocal()() as s2:
                r2 = append_event(s2, AuditEvent(
                    event_type="x", entity_type="y", entity_id="z",
                    payload={"b": 2, "a": 1},
                ))
                s2.commit()
        # The two rows have different IDs and different prev_hash
        # inputs, but their hash *recipe* (modulo id + prev_hash) is
        # the same. Just confirm the rows are different.
        assert r1.id != r2.id
        # The payload_json fields ARE byte-identical (canonical form).
        assert r1.payload_json == r2.payload_json


# --------------------------------------------------------------------------- #
# Verify
# --------------------------------------------------------------------------- #


class TestVerifyChain:
    def test_empty_chain_is_ok(self):
        """No rows = nothing to verify = ok."""
        with db.SessionLocal()() as s:
            result = verify_chain(s)
        assert result.ok is True
        assert result.checked == 0

    def test_single_row_chain_is_ok(self):
        with db.SessionLocal()() as s:
            append_event(s, AuditEvent("x", "y", "z"))
            s.commit()
            result = verify_chain(s)
        assert result.ok is True
        assert result.checked == 1

    def test_long_chain_is_ok(self):
        with db.SessionLocal()() as s:
            for i in range(50):
                append_event(s, AuditEvent("x", "y", f"id-{i}"))
            s.commit()
            result = verify_chain(s)
        assert result.ok is True
        assert result.checked == 50

    def test_tampered_payload_detected(self):
        """Modifying a row's payload_json breaks the chain at that row."""
        with db.SessionLocal()() as s:
            for i in range(5):
                append_event(s, AuditEvent("x", "y", f"id-{i}"))
            s.commit()
        # Tamper with row #3's payload (the first 3 rows are still
        # valid; row 3 will fail).
        with db.SessionLocal()() as s:
            row = s.query(AuditLog).filter(AuditLog.id == 3).first()
            row.payload_json = json.dumps({"evil": "tampered"})
            s.commit()
        with db.SessionLocal()() as s:
            result = verify_chain(s)
        assert result.ok is False
        assert result.first_bad_id == 3
        assert "hash" in (result.reason or "").lower()

    def test_tampered_prev_hash_detected(self):
        """Modifying a row's prev_hash invalidates that row's own hash.

        Because the row's hash is computed from prev_hash (and the
        other fields), changing prev_hash changes the row's own hash,
        so the verifier detects the breakage at the tampered row
        itself (not the row after). The next-row check (which compares
        prev_hash to the previous row's hash) would catch a different
        class of tampering: a row whose prev_hash was set to a value
        matching the previous row's hash but whose own hash was also
        regenerated — but that requires recomputing the hash to match,
        which is what verify_chain would not detect by itself (the
        content-vs-hash check still catches content tampering).
        """
        with db.SessionLocal()() as s:
            for i in range(5):
                append_event(s, AuditEvent("x", "y", f"id-{i}"))
            s.commit()
        with db.SessionLocal()() as s:
            row = s.query(AuditLog).filter(AuditLog.id == 2).first()
            row.prev_hash = "f" * 64  # fake
            s.commit()
        with db.SessionLocal()() as s:
            result = verify_chain(s)
        assert result.ok is False
        # The tampered row itself fails its hash check first.
        assert result.first_bad_id == 2
        assert "hash" in (result.reason or "").lower()

    def test_deleted_row_detected(self):
        """Deleting a middle row breaks the chain at the row after it."""
        with db.SessionLocal()() as s:
            for i in range(5):
                append_event(s, AuditEvent("x", "y", f"id-{i}"))
            s.commit()
        with db.SessionLocal()() as s:
            row = s.query(AuditLog).filter(AuditLog.id == 3).first()
            s.delete(row)
            s.commit()
        with db.SessionLocal()() as s:
            result = verify_chain(s)
        assert result.ok is False
        # Row 3 is gone; row 4's prev_hash now points at row 2's hash,
        # which doesn't match row 4's stored prev_hash.
        assert result.first_bad_id == 4
