"""Tests for ``census update`` — AIDE-style baseline update."""

from __future__ import annotations

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

import pytest
from click.testing import CliRunner

from certisigma_census import __version__
from certisigma_census.cli import main
from certisigma_census.manifest import Manifest, ManifestEntry
from certisigma_census.update import UpdateResult, update_manifest_from_integrity
from certisigma_census.evidence import IntegrityReport, ModifiedFile


def _make_manifest(root: Path, files: dict[str, bytes], *, mp: Path) -> None:
    """Create real files and a matching manifest (relative paths, like ``census scan``)."""
    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 TestUpdateManifestFromIntegrity:
    """Unit tests for the update module."""

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

        (data_dir / "b.txt").unlink()

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                total_in_manifest=2,
                missing=["b.txt"],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.removed == 1
            assert result.updated == 0
            assert result.added == 0
            assert manifest.total == 1
        finally:
            manifest.close()

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

        (data_dir / "a.txt").write_bytes(b"changed content")
        from certisigma import hash_file
        new_hash = hash_file(str(data_dir / "a.txt"))

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                modified=[ModifiedFile(
                    path="a.txt",
                    expected_hash="old", actual_hash=new_hash,
                    expected_size=8, actual_size=15,
                )],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.updated == 1
            entry = manifest.get(new_hash)
            assert entry is not None
            assert entry.attested is False
        finally:
            manifest.close()

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

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

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                new_files=["new.txt"],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.added == 1
            assert manifest.total == 2
        finally:
            manifest.close()

    def test_handles_combined_changes(self, tmp_path):
        data_dir = tmp_path / "data"
        _make_manifest(data_dir, {
            "keep.txt": b"keep",
            "modify.txt": b"original",
            "delete.txt": b"gone",
        }, mp=tmp_path / "m.db")

        (data_dir / "delete.txt").unlink()
        (data_dir / "modify.txt").write_bytes(b"changed")
        (data_dir / "new.txt").write_bytes(b"brand new")

        from certisigma import hash_file
        mod_hash = hash_file(str(data_dir / "modify.txt"))

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                missing=["delete.txt"],
                modified=[ModifiedFile(
                    path="modify.txt",
                    expected_hash="old", actual_hash=mod_hash,
                    expected_size=8, actual_size=7,
                )],
                new_files=["new.txt"],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.removed == 1
            assert result.updated == 1
            assert result.added == 1
            assert manifest.total == 3
        finally:
            manifest.close()

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

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(root_dir=str(data_dir))
            result = update_manifest_from_integrity(manifest, report)
            assert result.total_changes == 0
        finally:
            manifest.close()

    def test_error_on_missing_new_file(self, tmp_path):
        """If a 'new' file disappears between check and update, it's logged as an error."""
        data_dir = tmp_path / "data"
        data_dir.mkdir()
        _make_manifest(data_dir, {}, mp=tmp_path / "m.db")

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                new_files=["phantom.txt"],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.added == 0
            assert len(result.errors) == 1
        finally:
            manifest.close()

    def test_path_traversal_rejected_in_new_files(self, tmp_path):
        """Paths with ``..`` that escape root must be rejected."""
        data_dir = tmp_path / "data"
        data_dir.mkdir()
        secret = tmp_path / "secret.txt"
        secret.write_bytes(b"do not hash me")
        _make_manifest(data_dir, {}, mp=tmp_path / "m.db")

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                new_files=["../secret.txt"],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.added == 0
            assert len(result.errors) == 1
            assert "escapes root" in result.errors[0].lower() or "Path escapes root" in result.errors[0]
        finally:
            manifest.close()

    def test_path_traversal_rejected_in_modified(self, tmp_path):
        """Absolute paths outside root must be rejected in modified list."""
        data_dir = tmp_path / "data"
        data_dir.mkdir()
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=tmp_path / "m.db")

        manifest = Manifest.load(tmp_path / "m.db")
        try:
            report = IntegrityReport(
                root_dir=str(data_dir),
                modified=[ModifiedFile(
                    path="/etc/passwd",
                    expected_hash="old", actual_hash="fake",
                    expected_size=0, actual_size=0,
                )],
            )
            result = update_manifest_from_integrity(manifest, report)
            assert result.updated == 0
            assert len(result.errors) == 1
        finally:
            manifest.close()


class TestUpdateCommand:
    """CLI integration tests for ``census update``."""

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

        runner = CliRunner()
        result = runner.invoke(main, ["update", str(tmp_path / "m.db"), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "up_to_date"
        assert data["census_version"] == __version__
        assert "elapsed_seconds" in data

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

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

        runner = CliRunner()
        result = runner.invoke(main, ["update", str(tmp_path / "m.db"), "--yes", "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "updated"
        assert data["added"] >= 1
        assert data["census_version"] == __version__

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

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

        runner = CliRunner()
        result = runner.invoke(main, ["update", str(tmp_path / "m.db")], input="n\n")
        assert result.exit_code == 1

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

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

        runner = CliRunner()
        result = runner.invoke(main, ["update", str(tmp_path / "m.db")], input="y\n")
        assert result.exit_code == 0
        assert "Added:" in result.output or "added" in result.output.lower()

    def test_update_manifest_not_found(self, tmp_path):
        runner = CliRunner()
        result = runner.invoke(main, ["update", str(tmp_path / "nonexistent.db")])
        assert result.exit_code != 0

    def test_update_persists_changes(self, tmp_path):
        """After update + save, reloading the manifest reflects changes."""
        data_dir = tmp_path / "data"
        _make_manifest(data_dir, {"a.txt": b"aaa"}, mp=tmp_path / "m.db")

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

        runner = CliRunner()
        runner.invoke(main, ["update", str(tmp_path / "m.db"), "--yes"])

        reloaded = Manifest.load(tmp_path / "m.db")
        try:
            assert reloaded.total == 2
        finally:
            reloaded.close()

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

        runner = CliRunner()
        result = runner.invoke(main, ["-q", "update", str(tmp_path / "m.db"), "--json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["status"] == "up_to_date"
