"""Tests for TrustLedger tamper-evident audit system -- HMAC chain and exporter."""

from __future__ import annotations

import json
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING

import aiosqlite
import pytest

from llmhosts.audit.exporter import AuditExporter, ExportFilter
from llmhosts.audit.hmac_chain import HMACChain, VerificationResult

if TYPE_CHECKING:
    from pathlib import Path


# ===================================================================
# HMAC Chain
# ===================================================================


class TestHMACChain:
    """Test HMAC chain integrity."""

    def test_genesis_hmac_deterministic(self) -> None:
        """Same key produces same genesis HMAC."""
        chain = HMACChain(b"test-key")
        assert chain.genesis_hmac() == chain.genesis_hmac()

    def test_different_keys_different_hmac(self) -> None:
        """Different keys produce different genesis HMACs."""
        chain1 = HMACChain(b"key-1")
        chain2 = HMACChain(b"key-2")
        assert chain1.genesis_hmac() != chain2.genesis_hmac()

    def test_compute_hmac_deterministic(self) -> None:
        """Same inputs produce the same HMAC."""
        chain = HMACChain(b"test-key")
        h1 = chain.compute_hmac("prev", '{"data": "test"}')
        h2 = chain.compute_hmac("prev", '{"data": "test"}')
        assert h1 == h2

    def test_compute_hmac_changes_with_data(self) -> None:
        """Different data produces different HMACs."""
        chain = HMACChain(b"test-key")
        h1 = chain.compute_hmac("prev", '{"data": "test1"}')
        h2 = chain.compute_hmac("prev", '{"data": "test2"}')
        assert h1 != h2

    def test_compute_hmac_changes_with_previous(self) -> None:
        """Different previous HMAC produces different result."""
        chain = HMACChain(b"test-key")
        h1 = chain.compute_hmac("prev-a", '{"data": "test"}')
        h2 = chain.compute_hmac("prev-b", '{"data": "test"}')
        assert h1 != h2

    def test_verify_empty_chain(self) -> None:
        """Empty chain is trivially valid."""
        chain = HMACChain(b"test-key")
        result = chain.verify_chain([])
        assert result.valid
        assert result.total_entries == 0
        assert result.verified_entries == 0
        assert result.first_break_index is None

    def test_verify_single_entry(self) -> None:
        """Single correctly chained entry verifies successfully."""
        chain = HMACChain(b"test-key")
        genesis = chain.genesis_hmac()
        entry = {"request_id": "abc", "data": "test"}
        entry_json = json.dumps(entry, sort_keys=True, default=str)
        entry["hmac"] = chain.compute_hmac(genesis, entry_json)

        result = chain.verify_chain([entry])
        assert result.valid
        assert result.verified_entries == 1
        assert result.total_entries == 1

    def test_verify_chain_of_five(self) -> None:
        """Build a 5-entry chain and verify it."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        result = chain.verify_chain(entries)
        assert result.valid
        assert result.verified_entries == 5
        assert result.total_entries == 5

    def test_verify_chain_of_hundred(self) -> None:
        """Larger chain verifies correctly."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 100)

        result = chain.verify_chain(entries)
        assert result.valid
        assert result.verified_entries == 100

    def test_tampered_entry_detected(self) -> None:
        """Modify middle entry and verify chain breaks at that entry."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        # Tamper with entry 2
        entries[2]["data"] = "TAMPERED"

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 2
        assert result.first_break_request_id == "req-2"
        assert result.verified_entries == 2

    def test_tampered_first_entry_detected(self) -> None:
        """Tampering with the first entry is caught immediately."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 3)

        entries[0]["data"] = "TAMPERED"

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 0

    def test_tampered_last_entry_detected(self) -> None:
        """Tampering with the last entry is caught."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 3)

        entries[2]["data"] = "TAMPERED"

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 2

    def test_tampered_hmac_detected(self) -> None:
        """Replacing an HMAC value is detected."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        # Replace HMAC of entry 1 with a bogus value
        entries[1]["hmac"] = "0" * 64

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 1

    def test_deleted_entry_detected(self) -> None:
        """Remove an entry and verify chain breaks."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        # Remove entry 2 -- entry 3 (now at index 2) should fail
        del entries[2]

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 2

    def test_inserted_entry_detected(self) -> None:
        """Inserting a rogue entry breaks the chain."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        rogue = {"request_id": "rogue", "data": "injected", "hmac": "0" * 64}
        entries.insert(2, rogue)

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 2

    def test_reordered_entries_detected(self) -> None:
        """Swapping entry order breaks the chain."""
        chain = HMACChain(b"test-key")
        entries = _build_chain(chain, 5)

        # Swap entries 1 and 3
        entries[1], entries[3] = entries[3], entries[1]

        result = chain.verify_chain(entries)
        assert not result.valid

    def test_derive_key_deterministic(self) -> None:
        """Same master key produces same derived key."""
        key1 = HMACChain.derive_key(b"master")
        key2 = HMACChain.derive_key(b"master")
        assert key1 == key2

    def test_derive_key_different_masters(self) -> None:
        """Different master keys produce different derived keys."""
        key1 = HMACChain.derive_key(b"master1")
        key2 = HMACChain.derive_key(b"master2")
        assert key1 != key2

    def test_derive_key_length(self) -> None:
        """Derived key is 32 bytes (SHA-256 output)."""
        key = HMACChain.derive_key(b"master")
        assert len(key) == 32

    def test_hmac_is_hex_string(self) -> None:
        """HMACs are 64-character hex strings (SHA-256)."""
        chain = HMACChain(b"test-key")
        genesis = chain.genesis_hmac()
        assert len(genesis) == 64
        int(genesis, 16)  # validates hex

        computed = chain.compute_hmac(genesis, '{"test": true}')
        assert len(computed) == 64
        int(computed, 16)

    def test_verification_result_model(self) -> None:
        """VerificationResult is a proper Pydantic model."""
        result = VerificationResult(
            valid=False,
            total_entries=10,
            verified_entries=5,
            first_break_index=5,
            first_break_request_id="req-5",
            message="Chain broken",
        )
        serialized = result.model_dump()
        assert serialized["valid"] is False
        assert serialized["first_break_index"] == 5

    def test_entry_missing_hmac_field(self) -> None:
        """Entry without hmac field is detected as a break."""
        chain = HMACChain(b"test-key")
        entries = [{"request_id": "no-hmac", "data": "test"}]

        result = chain.verify_chain(entries)
        assert not result.valid
        assert result.first_break_index == 0


# ===================================================================
# Audit Exporter
# ===================================================================


class TestAuditExporter:
    """Test audit log export functionality."""

    @pytest.fixture()
    async def populated_db(self, tmp_path: Path) -> Path:
        """Create a test audit DB with 20 sample entries."""
        db_path = tmp_path / "test_audit.db"
        async with aiosqlite.connect(str(db_path)) as db:
            await db.execute(
                """CREATE TABLE audit_log (
                    request_id TEXT PRIMARY KEY,
                    timestamp TEXT NOT NULL,
                    source_ip TEXT NOT NULL DEFAULT '127.0.0.1',
                    method TEXT NOT NULL DEFAULT 'POST',
                    path TEXT NOT NULL DEFAULT '/v1/chat/completions',
                    dialect TEXT NOT NULL DEFAULT 'openai',
                    model_requested TEXT NOT NULL DEFAULT 'gpt-4',
                    model_used TEXT NOT NULL DEFAULT 'gpt-4',
                    backend_type TEXT NOT NULL DEFAULT 'openai',
                    routing_tier TEXT NOT NULL DEFAULT 'rule',
                    routing_reasoning TEXT NOT NULL DEFAULT '',
                    routing_confidence REAL NOT NULL DEFAULT 0.9,
                    input_tokens INTEGER NOT NULL DEFAULT 100,
                    output_tokens INTEGER NOT NULL DEFAULT 50,
                    actual_cost REAL NOT NULL DEFAULT 0.01,
                    cloud_equivalent_cost REAL NOT NULL DEFAULT 0.02,
                    cached INTEGER NOT NULL DEFAULT 0,
                    cache_tier TEXT,
                    latency_ms REAL NOT NULL DEFAULT 150.0,
                    status_code INTEGER NOT NULL DEFAULT 200,
                    error TEXT,
                    hmac TEXT
                )"""
            )
            base = datetime(2026, 2, 1, tzinfo=timezone.utc)
            for i in range(20):
                ts = (base + timedelta(hours=i)).isoformat()
                model = "gpt-4" if i % 2 == 0 else "llama-3-8b"
                backend = "openai" if "gpt" in model else "ollama"
                await db.execute(
                    "INSERT INTO audit_log "
                    "(request_id, timestamp, model_used, model_requested, backend_type, "
                    "source_ip, method, path, dialect, routing_tier) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        f"req-{i:03d}",
                        ts,
                        model,
                        model,
                        backend,
                        "127.0.0.1",
                        "POST",
                        "/v1/chat/completions",
                        "openai",
                        "rule",
                    ),
                )
            await db.commit()
        return db_path

    @pytest.fixture()
    async def empty_db(self, tmp_path: Path) -> Path:
        """Create an empty audit DB (schema only, no rows)."""
        db_path = tmp_path / "empty_audit.db"
        async with aiosqlite.connect(str(db_path)) as db:
            await db.execute(
                """CREATE TABLE audit_log (
                    request_id TEXT PRIMARY KEY,
                    timestamp TEXT NOT NULL,
                    source_ip TEXT NOT NULL DEFAULT '127.0.0.1',
                    method TEXT NOT NULL DEFAULT 'POST',
                    path TEXT NOT NULL DEFAULT '/v1/chat/completions',
                    dialect TEXT NOT NULL DEFAULT 'openai',
                    model_requested TEXT NOT NULL DEFAULT 'gpt-4',
                    model_used TEXT NOT NULL DEFAULT 'gpt-4',
                    backend_type TEXT NOT NULL DEFAULT 'openai',
                    routing_tier TEXT NOT NULL DEFAULT 'rule',
                    routing_reasoning TEXT NOT NULL DEFAULT '',
                    routing_confidence REAL NOT NULL DEFAULT 0.9,
                    input_tokens INTEGER NOT NULL DEFAULT 100,
                    output_tokens INTEGER NOT NULL DEFAULT 50,
                    actual_cost REAL NOT NULL DEFAULT 0.01,
                    cloud_equivalent_cost REAL NOT NULL DEFAULT 0.02,
                    cached INTEGER NOT NULL DEFAULT 0,
                    cache_tier TEXT,
                    latency_ms REAL NOT NULL DEFAULT 150.0,
                    status_code INTEGER NOT NULL DEFAULT 200,
                    error TEXT,
                    hmac TEXT
                )"""
            )
            await db.commit()
        return db_path

    # ---------------------------------------------------------------
    # JSONL export
    # ---------------------------------------------------------------

    async def test_export_jsonl(self, populated_db: Path, tmp_path: Path) -> None:
        """Export all entries to JSONL."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "export.jsonl"
        count = await exporter.export_jsonl(output)

        assert count == 20
        lines = output.read_text(encoding="utf-8").strip().split("\n")
        assert len(lines) == 20
        first = json.loads(lines[0])
        assert "request_id" in first
        assert "timestamp" in first

    async def test_export_jsonl_valid_json_per_line(self, populated_db: Path, tmp_path: Path) -> None:
        """Each JSONL line is valid, parseable JSON."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "export.jsonl"
        await exporter.export_jsonl(output)

        for line in output.read_text(encoding="utf-8").strip().split("\n"):
            parsed = json.loads(line)
            assert isinstance(parsed, dict)

    async def test_export_jsonl_ordered_by_timestamp(self, populated_db: Path, tmp_path: Path) -> None:
        """JSONL entries are ordered oldest-first."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "export.jsonl"
        await exporter.export_jsonl(output)

        timestamps = []
        for line in output.read_text(encoding="utf-8").strip().split("\n"):
            timestamps.append(json.loads(line)["timestamp"])
        assert timestamps == sorted(timestamps)

    # ---------------------------------------------------------------
    # CSV export
    # ---------------------------------------------------------------

    async def test_export_csv(self, populated_db: Path, tmp_path: Path) -> None:
        """Export all entries to CSV."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "export.csv"
        count = await exporter.export_csv(output)

        assert count == 20
        content = output.read_text(encoding="utf-8")
        lines = content.strip().split("\n")
        assert len(lines) == 21  # header + 20 data rows

    async def test_export_csv_has_header(self, populated_db: Path, tmp_path: Path) -> None:
        """CSV output contains a header row with column names."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "export.csv"
        await exporter.export_csv(output)

        header = output.read_text(encoding="utf-8").split("\n")[0]
        assert "request_id" in header
        assert "timestamp" in header

    async def test_export_csv_empty_db(self, empty_db: Path, tmp_path: Path) -> None:
        """CSV export on empty DB produces empty file."""
        exporter = AuditExporter(empty_db)
        output = tmp_path / "empty.csv"
        count = await exporter.export_csv(output)

        assert count == 0
        assert output.read_text(encoding="utf-8") == ""

    # ---------------------------------------------------------------
    # Filtered exports
    # ---------------------------------------------------------------

    async def test_export_with_since_filter(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter by 'since' returns only newer entries."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "filtered.jsonl"
        filters = ExportFilter(since=datetime(2026, 2, 1, 10, tzinfo=timezone.utc))
        count = await exporter.export_jsonl(output, filters)

        assert count == 10  # entries 10-19

    async def test_export_with_until_filter(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter by 'until' returns only older entries."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "until.jsonl"
        # Entry 9 is at hour 9 => 2026-02-01T09:00:00+00:00
        filters = ExportFilter(until=datetime(2026, 2, 1, 9, tzinfo=timezone.utc))
        count = await exporter.export_jsonl(output, filters)

        assert count == 10  # entries 0-9

    async def test_export_with_model_filter(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter by model returns only matching entries."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "gpt4.jsonl"
        filters = ExportFilter(model="gpt-4")
        count = await exporter.export_jsonl(output, filters)

        assert count == 10  # even-indexed entries

    async def test_export_with_status_code_filter(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter by status_code."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "status.jsonl"
        # All entries have status_code=200
        filters = ExportFilter(status_code=200)
        count = await exporter.export_jsonl(output, filters)
        assert count == 20

        output_404 = tmp_path / "status_404.jsonl"
        filters_404 = ExportFilter(status_code=404)
        count_404 = await exporter.export_jsonl(output_404, filters_404)
        assert count_404 == 0

    async def test_export_with_event_type_filter(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter by event_type (routing_tier)."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "rule.jsonl"
        filters = ExportFilter(event_type="rule")
        count = await exporter.export_jsonl(output, filters)
        assert count == 20

        output_knn = tmp_path / "knn.jsonl"
        filters_knn = ExportFilter(event_type="knn")
        count_knn = await exporter.export_jsonl(output_knn, filters_knn)
        assert count_knn == 0

    async def test_export_empty_result(self, populated_db: Path, tmp_path: Path) -> None:
        """Filter that matches nothing produces zero-line file."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "empty.jsonl"
        filters = ExportFilter(model="nonexistent-model")
        count = await exporter.export_jsonl(output, filters)

        assert count == 0
        content = output.read_text(encoding="utf-8").strip()
        assert content == ""

    async def test_export_combined_filters(self, populated_db: Path, tmp_path: Path) -> None:
        """Multiple filters are ANDed together."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "combined.jsonl"
        filters = ExportFilter(
            since=datetime(2026, 2, 1, 5, tzinfo=timezone.utc),
            until=datetime(2026, 2, 1, 15, tzinfo=timezone.utc),
            model="gpt-4",
        )
        count = await exporter.export_jsonl(output, filters)

        # Hours 5-15 inclusive = 11 entries; even-indexed among those
        # Entries at hours 6, 8, 10, 12, 14 are gpt-4 (indices 6,8,10,12,14)
        assert count > 0
        assert count < 20

    async def test_export_csv_with_filters(self, populated_db: Path, tmp_path: Path) -> None:
        """CSV export also respects filters."""
        exporter = AuditExporter(populated_db)
        output = tmp_path / "filtered.csv"
        filters = ExportFilter(model="llama-3-8b")
        count = await exporter.export_csv(output, filters)

        assert count == 10

    # ---------------------------------------------------------------
    # Statistics
    # ---------------------------------------------------------------

    async def test_get_stats(self, populated_db: Path) -> None:
        """Summary stats include expected fields."""
        exporter = AuditExporter(populated_db)
        stats = await exporter.get_stats()

        assert stats["total_entries"] == 20
        assert "date_range" in stats
        assert stats["date_range"]["earliest"] is not None
        assert stats["date_range"]["latest"] is not None
        assert "top_models" in stats
        assert isinstance(stats["top_models"], dict)
        assert "cache_hit_rate" in stats
        assert "avg_latency" in stats

    async def test_get_stats_empty_db(self, empty_db: Path) -> None:
        """Stats on empty DB return zero counts."""
        exporter = AuditExporter(empty_db)
        stats = await exporter.get_stats()

        assert stats["total_entries"] == 0
        assert stats["cache_hit_rate"] == 0.0

    async def test_get_stats_top_models(self, populated_db: Path) -> None:
        """Top models includes both models from test data."""
        exporter = AuditExporter(populated_db)
        stats = await exporter.get_stats()

        assert "gpt-4" in stats["top_models"]
        assert "llama-3-8b" in stats["top_models"]
        assert stats["top_models"]["gpt-4"] == 10
        assert stats["top_models"]["llama-3-8b"] == 10

    # ---------------------------------------------------------------
    # Stream live
    # ---------------------------------------------------------------

    async def test_stream_live_yields_new_entries(self, populated_db: Path) -> None:
        """stream_live yields entries that appear after the generator starts."""
        exporter = AuditExporter(populated_db)

        # Insert a new entry with a future timestamp
        future_ts = datetime(2099, 1, 1, tzinfo=timezone.utc).isoformat()
        async with aiosqlite.connect(str(populated_db)) as db:
            await db.execute(
                "INSERT INTO audit_log "
                "(request_id, timestamp, model_used, model_requested, backend_type, "
                "source_ip, method, path, dialect, routing_tier) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    "stream-test",
                    future_ts,
                    "gpt-4",
                    "gpt-4",
                    "openai",
                    "127.0.0.1",
                    "POST",
                    "/v1/chat/completions",
                    "openai",
                    "rule",
                ),
            )
            await db.commit()

        # Collect the first entry from the stream
        entries_received: list[dict] = []
        async for entry in exporter.stream_live():
            entries_received.append(entry)
            break  # stop after first

        assert len(entries_received) == 1
        assert entries_received[0]["request_id"] == "stream-test"


# ===================================================================
# Helpers
# ===================================================================


def _build_chain(chain: HMACChain, count: int) -> list[dict]:
    """Build a valid HMAC chain of `count` entries."""
    entries: list[dict] = []
    prev = chain.genesis_hmac()
    for i in range(count):
        entry = {"request_id": f"req-{i}", "data": f"entry-{i}"}
        entry_json = json.dumps(entry, sort_keys=True, default=str)
        entry["hmac"] = chain.compute_hmac(prev, entry_json)
        prev = entry["hmac"]
        entries.append(entry)
    return entries
