"""Tests for ``census integrity`` differential mode."""

from __future__ import annotations

import json
import logging
import os
from pathlib import Path
from unittest.mock import patch

import pytest
from click.testing import CliRunner

from certisigma_census.cli import main
from certisigma_census.differential import (
    SCHEMA_VERSION,
    DifferentialMeta,
    FindingState,
    IntegrityState,
    build_state,
    default_state_path,
    filter_report,
    load_state,
    save_state,
)
from certisigma_census.evidence import IntegrityReport, ModifiedFile
from certisigma_census.manifest import Manifest, ManifestEntry


# ── Unit tests: differential.py ────────────────────────────────────────


class TestBuildState:
    def test_builds_from_empty_report(self):
        report = IntegrityReport(root_dir="/data")
        state = build_state(report)
        assert state.schema_version == SCHEMA_VERSION
        assert state.root_dir == "/data"
        assert state.findings == {}

    def test_builds_from_report_with_findings(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "new_hash", 10, 20)],
            missing=["b.txt"],
            new_files=["c.txt"],
        )
        state = build_state(report)
        assert len(state.findings) == 3
        assert state.findings["a.txt"].status == "modified"
        assert state.findings["a.txt"].fingerprint == "new_hash"
        assert state.findings["a.txt"].occurrences == 1
        assert state.findings["b.txt"].status == "missing"
        assert state.findings["b.txt"].fingerprint == "missing"
        assert state.findings["c.txt"].status == "new"

    def test_carries_forward_first_seen_and_increments_occurrences(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "same_hash", 10, 20)],
        )
        prev = IntegrityState(
            root_dir="/data",
            findings={
                "a.txt": FindingState(
                    status="modified",
                    fingerprint="same_hash",
                    first_seen="2026-01-01T00:00:00",
                    occurrences=3,
                ),
            },
        )
        state = build_state(report, previous=prev)
        assert state.findings["a.txt"].first_seen == "2026-01-01T00:00:00"
        assert state.findings["a.txt"].occurrences == 4

    def test_resets_on_fingerprint_change(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "different_hash", 10, 20)],
        )
        prev = IntegrityState(
            root_dir="/data",
            findings={
                "a.txt": FindingState(
                    status="modified",
                    fingerprint="original_hash",
                    first_seen="2026-01-01T00:00:00",
                    occurrences=5,
                ),
            },
        )
        state = build_state(report, previous=prev)
        assert state.findings["a.txt"].fingerprint == "different_hash"
        assert state.findings["a.txt"].occurrences == 1
        assert state.findings["a.txt"].first_seen != "2026-01-01T00:00:00"


class TestFilterReport:
    def test_suppresses_known_findings(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "same_hash", 10, 20)],
            missing=["b.txt"],
        )
        prev = IntegrityState(
            findings={
                "a.txt": FindingState("modified", "same_hash", "t0"),
                "b.txt": FindingState("missing", "missing", "t0"),
            },
        )
        filtered, meta = filter_report(report, prev)
        assert filtered.modified == []
        assert filtered.missing == []
        assert meta.suppressed == 2
        assert meta.new_findings == 0

    def test_surfaces_new_findings(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "new_hash", 10, 20)],
            new_files=["d.txt"],
        )
        prev = IntegrityState(findings={})
        filtered, meta = filter_report(report, prev)
        assert len(filtered.modified) == 1
        assert filtered.new_files == ["d.txt"]
        assert meta.new_findings == 2
        assert meta.suppressed == 0

    def test_resurfaces_on_hash_change(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "changed_hash", 10, 30)],
        )
        prev = IntegrityState(
            findings={
                "a.txt": FindingState("modified", "original_hash", "t0"),
            },
        )
        filtered, meta = filter_report(report, prev)
        assert len(filtered.modified) == 1
        assert meta.new_findings == 1
        assert meta.suppressed == 0

    def test_preserves_errors_and_counters(self):
        report = IntegrityReport(
            root_dir="/data",
            total_in_manifest=100,
            total_checked=98,
            unchanged=95,
            errors=["err1"],
        )
        prev = IntegrityState(findings={})
        filtered, meta = filter_report(report, prev)
        assert filtered.total_in_manifest == 100
        assert filtered.total_checked == 98
        assert filtered.unchanged == 95
        assert filtered.errors == ["err1"]

    def test_does_not_mutate_original(self):
        report = IntegrityReport(
            root_dir="/data",
            modified=[ModifiedFile("a.txt", "old", "hash", 10, 20)],
        )
        prev = IntegrityState(findings={})
        filtered, _ = filter_report(report, prev)
        assert len(report.modified) == 1
        assert len(filtered.modified) == 1
        assert filtered is not report


class TestSaveLoadState:
    def test_round_trip(self, tmp_path):
        state = IntegrityState(
            census_version="1.6.0",
            root_dir="/data",
            timestamp="2026-03-20T12:00:00+0100",
            findings={
                "a.txt": FindingState("modified", "abc123", "2026-01-01T00:00:00", 3),
                "b.txt": FindingState("missing", "missing", "2026-02-01T00:00:00", 1),
            },
        )
        p = tmp_path / "state.json"
        save_state(p, state)
        loaded = load_state(p)
        assert loaded.schema_version == SCHEMA_VERSION
        assert loaded.census_version == "1.6.0"
        assert loaded.root_dir == "/data"
        assert len(loaded.findings) == 2
        assert loaded.findings["a.txt"].occurrences == 3
        assert loaded.findings["a.txt"].first_seen == "2026-01-01T00:00:00"

    def test_atomic_write_creates_file(self, tmp_path):
        p = tmp_path / "subdir" / "state.json"
        state = IntegrityState()
        save_state(p, state)
        assert p.exists()

    def test_atomic_write_no_temp_file_left(self, tmp_path):
        p = tmp_path / "state.json"
        save_state(p, IntegrityState())
        files = list(tmp_path.iterdir())
        assert len(files) == 1
        assert files[0].name == "state.json"

    def test_rejects_too_large(self, tmp_path):
        p = tmp_path / "big.json"
        p.write_text("x" * (51 * 1024 * 1024))
        with pytest.raises(ValueError, match="too large"):
            load_state(p)

    def test_rejects_invalid_json(self, tmp_path):
        p = tmp_path / "bad.json"
        p.write_text("not json")
        with pytest.raises((ValueError, json.JSONDecodeError)):
            load_state(p)

    def test_rejects_wrong_schema_version(self, tmp_path):
        p = tmp_path / "old.json"
        p.write_text(json.dumps({"schema_version": 999}))
        with pytest.raises(ValueError, match="Unsupported state schema"):
            load_state(p)

    def test_rejects_non_object(self, tmp_path):
        p = tmp_path / "arr.json"
        p.write_text("[1, 2, 3]")
        with pytest.raises(ValueError, match="not a JSON object"):
            load_state(p)

    def test_rejects_non_dict_finding(self, tmp_path):
        p = tmp_path / "bad.json"
        p.write_text(json.dumps({
            "schema_version": SCHEMA_VERSION,
            "findings": {"a.txt": "not-a-dict"},
        }))
        with pytest.raises(ValueError, match="not a JSON object"):
            load_state(p)

    def test_rejects_finding_missing_field(self, tmp_path):
        p = tmp_path / "bad.json"
        p.write_text(json.dumps({
            "schema_version": SCHEMA_VERSION,
            "findings": {"a.txt": {"status": "modified"}},
        }))
        with pytest.raises(ValueError, match="missing required field"):
            load_state(p)

    def test_rejects_findings_as_list(self, tmp_path):
        p = tmp_path / "bad.json"
        p.write_text(json.dumps({
            "schema_version": SCHEMA_VERSION,
            "findings": [1, 2, 3],
        }))
        with pytest.raises(ValueError, match="not a JSON object"):
            load_state(p)


class TestDefaultStatePath:
    def test_sidecar_convention(self):
        assert default_state_path(Path("/data/manifest.db")) == Path("/data/manifest.db.integrity-state")

    def test_preserves_suffix(self):
        assert default_state_path(Path("my.manifest.db")) == Path("my.manifest.db.integrity-state")


# ── CLI integration tests ──────────────────────────────────────────────


def _make_manifest(root: Path, files: dict[str, bytes], *, mp: Path) -> None:
    from certisigma import hash_file

    root.mkdir(parents=True, exist_ok=True)
    m = Manifest(root_dir=str(root))
    for name, content in files.items():
        fpath = root / name
        fpath.parent.mkdir(parents=True, exist_ok=True)
        fpath.write_bytes(content)
        h = hash_file(str(fpath))
        stat = fpath.stat()
        m.add(ManifestEntry(hash_hex=h, path=name, size=stat.st_size, mtime=stat.st_mtime))
    m.save(mp)
    m.close()


class TestIntegrityDifferentialCLI:
    """End-to-end differential integrity via CLI."""

    def test_first_run_creates_state(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new file")

        runner = CliRunner()
        result = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", "auto", "--write-state", "auto",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data.get("new_files") == ["b.txt"]
        assert default_state_path(mp).exists()

    def test_second_run_suppresses_known(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new file")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        r1 = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r1.exit_code == 0
        d1 = json.loads(r1.output)
        assert len(d1.get("new_files", [])) == 1

        r2 = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0
        d2 = json.loads(r2.output)
        assert d2.get("differential") is True
        assert d2.get("suppressed") == 1
        assert d2.get("new_findings") == 0
        assert d2.get("new_files") == []

    def test_resurfaces_on_change(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"original"}, mp=mp)

        (data_dir / "a.txt").write_bytes(b"modified v1")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", state_path, "--write-state", state_path,
        ])

        (data_dir / "a.txt").write_bytes(b"modified v2")
        r2 = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0
        d2 = json.loads(r2.output)
        assert len(d2.get("modified", [])) == 1
        assert d2.get("new_findings") == 1

    def test_strict_with_differential(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        runner.invoke(main, [
            "integrity", str(mp),
            "--since", state_path, "--write-state", state_path,
        ])

        r2 = runner.invoke(main, [
            "integrity", str(mp), "--strict",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0

    def test_corrupt_state_file_error(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        state_path = tmp_path / "state.json"
        state_path.write_text("not json at all")

        runner = CliRunner()
        result = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", str(state_path),
        ])
        assert result.exit_code != 0
        data = json.loads(result.output)
        assert "error" in data

    def test_missing_since_file_runs_full(self, tmp_path):
        """If --since points to nonexistent file, run full (no state loaded)."""
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new")

        runner = CliRunner()
        result = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", str(tmp_path / "nonexistent.json"),
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert "new_files" in data
        assert "differential" not in data

    def test_differential_jsonl(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        runner.invoke(main, [
            "integrity", str(mp), "--format", "jsonl",
            "--since", state_path, "--write-state", state_path,
        ])

        r2 = runner.invoke(main, [
            "integrity", str(mp), "--format", "jsonl",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0
        lines = [json.loads(line) for line in r2.output.strip().splitlines()]
        summary = [l for l in lines if l.get("_summary")]
        assert len(summary) == 1
        assert summary[0]["count"] == 0

    def test_two_run_auto_sidecar(self, tmp_path):
        """Two consecutive --since auto --write-state auto runs: second suppresses."""
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new file")

        runner = CliRunner()
        r1 = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", "auto", "--write-state", "auto",
        ])
        assert r1.exit_code == 0
        d1 = json.loads(r1.output)
        assert len(d1.get("new_files", [])) == 1

        r2 = runner.invoke(main, [
            "integrity", str(mp), "--json",
            "--since", "auto", "--write-state", "auto",
        ])
        assert r2.exit_code == 0
        d2 = json.loads(r2.output)
        assert d2.get("differential") is True
        assert d2.get("suppressed") == 1
        assert d2.get("new_files") == []

    def test_jsonl_summary_includes_differential_meta(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        runner.invoke(main, [
            "integrity", str(mp), "--format", "jsonl",
            "--since", state_path, "--write-state", state_path,
        ])

        r2 = runner.invoke(main, [
            "integrity", str(mp), "--format", "jsonl",
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0
        lines = [json.loads(line) for line in r2.output.strip().splitlines()]
        summary = [l for l in lines if l.get("_summary")]
        assert len(summary) == 1
        assert summary[0].get("differential") is True
        assert summary[0].get("suppressed") == 1

    def test_text_output_shows_differential_info(self, tmp_path):
        data_dir = tmp_path / "data"
        mp = tmp_path / "m.db"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=mp)

        (data_dir / "b.txt").write_bytes(b"new")
        state_path = str(tmp_path / "state.json")

        runner = CliRunner()
        runner.invoke(main, [
            "integrity", str(mp),
            "--since", state_path, "--write-state", state_path,
        ])

        r2 = runner.invoke(main, [
            "integrity", str(mp),
            "--since", state_path, "--write-state", state_path,
        ])
        assert r2.exit_code == 0
        assert "suppressed" in r2.output.lower()
        assert "INTEGRITY OK" in r2.output


def test_save_state_logs_when_temp_cleanup_fails(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
    """save_state must log a warning if temp file cannot be removed on failure."""
    import certisigma_census.differential as diff_mod

    state = IntegrityState(timestamp="2026-01-01T00:00:00", findings={})
    state_path = tmp_path / "state.json"

    orig_replace = diff_mod.os.replace
    orig_unlink = diff_mod.os.unlink

    def fail_replace(src: str, dst: str) -> None:
        raise OSError("simulated replace failure")

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

    monkeypatch.setattr(diff_mod.os, "replace", fail_replace)
    monkeypatch.setattr(diff_mod.os, "unlink", block_tmp_unlink)

    with caplog.at_level(logging.WARNING, logger="certisigma_census.differential"):
        with pytest.raises(OSError, match="simulated replace failure"):
            save_state(state_path, state)

    assert "Cannot remove temporary state file" in caplog.text
