"""CLI integration tests — exercises Click commands with mocked SDK.

Uses click.testing.CliRunner so no real API calls are made.
"""

from __future__ import annotations

import csv
import json
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from certisigma_census.cli import _parse_size, main
from certisigma_census.manifest import Manifest, ManifestEntry


DETERMINISTIC_HASH = "ab" * 32


def _deterministic_hash(path_str: str) -> str:
    """Return a hash derived from the filename for reproducibility."""
    name = Path(path_str).name
    h = f"{abs(hash(name)) % (16**64):064x}"
    return h


def _mock_batch_attest_response(hashes, source=None):
    """Build a BatchAttestResult mock from a list of hashes."""
    attestations = [
        {
            "id": f"att_{i}",
            "hash_hex": h,
            "timestamp": "2026-03-01T12:00:00Z",
            "status": "created",
        }
        for i, h in enumerate(hashes)
    ]
    return MagicMock(
        batch_id="batch_test",
        count=len(hashes),
        created=len(hashes),
        existing=0,
        attestations=attestations,
    )


def _mock_batch_verify_response(hashes, all_exist=False):
    """Build a BatchVerifyResult mock."""
    results = [
        {"hash_hex": h, "exists": all_exist, "attestation_id": f"att_{h[:8]}", "level": "T1"}
        for h in hashes
    ]
    return MagicMock(count=len(hashes), found=len(hashes) if all_exist else 0,
                     not_found=0 if all_exist else len(hashes), results=results)


class TestVersion:
    def test_cli_version(self):
        runner = CliRunner()
        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0
        assert "certisigma-census" in result.output
        assert "0.2.1" in result.output


class TestLogging:
    def test_default_text_logging(self, tmp_path):
        """Default log format is text (no JSON)."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.json"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, ["-v", "status", str(mp)])
        assert result.exit_code == 0
        assert "Total:" in result.output

    def test_json_log_format(self, tmp_path):
        """--log-format json is accepted and doesn't crash."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="f.txt", size=100, mtime=1.0))
        mp = tmp_path / "m.json"
        m.save(mp)

        runner = CliRunner()
        result = runner.invoke(main, [
            "-v", "--log-format", "json", "status", str(mp),
        ])
        assert result.exit_code == 0
        assert "Total:" in result.output

    def test_json_formatter_output(self):
        """_JsonLogFormatter produces valid JSON."""
        import logging as _logging
        from certisigma_census.cli import _JsonLogFormatter

        formatter = _JsonLogFormatter()
        record = _logging.LogRecord(
            name="certisigma_census.scanner",
            level=_logging.WARNING,
            pathname="scanner.py",
            lineno=42,
            msg="Cannot read %s: %s",
            args=("/tmp/secret.bin", "Permission denied"),
            exc_info=None,
        )
        output = formatter.format(record)
        parsed = json.loads(output)
        assert parsed["level"] == "WARNING"
        assert parsed["module"] == "scanner"
        assert "Permission denied" in parsed["message"]


class TestScan:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_dry_run(self, _mock_hash, populated_dir):
        """Dry run hashes files but makes zero API calls."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "test-manifest.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
        ])

        assert result.exit_code == 0
        assert "Dry run" in result.output
        assert Path(manifest_path).exists()

        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 5
        assert manifest.attested_count == 0

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_scan_with_attestation(self, mock_get_client, _mock_hash, populated_dir):
        """Scan with attestation calls batch_attest and writes manifest."""
        client = MagicMock()
        client.batch_attest.side_effect = _mock_batch_attest_response
        mock_get_client.return_value = client

        manifest_path = str(populated_dir / "test-manifest.json")
        runner = CliRunner()
        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--source", "test-inventory",
            "--manifest", manifest_path,
        ])

        assert result.exit_code == 0
        assert "Attested" in result.output
        client.batch_attest.assert_called_once()

        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 5

    def test_scan_nonexistent_dir(self):
        """Scanning a non-existent directory exits with error."""
        runner = CliRunner()
        result = runner.invoke(main, ["scan", "/nonexistent/path/xyz"])
        assert result.exit_code != 0


class TestCompare:
    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_with_matches(self, mock_get_client, _mock_hash, populated_dir):
        """Compare exits 1 when matches are found."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir)])

        assert result.exit_code == 1
        assert "Matched:" in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_no_matches(self, mock_get_client, _mock_hash, populated_dir):
        """Compare exits 0 when no matches found."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=False)
        mock_get_client.return_value = client

        runner = CliRunner()
        result = runner.invoke(main, ["compare", str(populated_dir)])

        assert result.exit_code == 0
        assert "Matched:        0" in result.output

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_json_output(self, mock_get_client, _mock_hash, populated_dir):
        """Compare writes a valid JSON report when --output is given."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        output_path = str(populated_dir / "report.json")
        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir),
            "--output", output_path,
        ])

        assert Path(output_path).exists()
        report = json.loads(Path(output_path).read_text())
        assert report["total_suspect"] == 5
        assert report["matched"] == 5


class TestStatus:
    def test_status_output(self, tmp_path):
        """Status command displays correct counts."""
        m = Manifest(root_dir="/data/hr")
        for i in range(10):
            m.add(ManifestEntry(hash_hex=f"{i:064x}", path=f"f_{i}.dat", size=100, mtime=1.0))
        for i in range(4):
            m.mark_attested(f"{i:064x}", f"att_{i}", "2026-01-01T00:00:00Z")

        manifest_path = tmp_path / "manifest.json"
        m.save(manifest_path)

        runner = CliRunner()
        result = runner.invoke(main, ["status", str(manifest_path)])

        assert result.exit_code == 0
        assert "Total:    10" in result.output
        assert "Attested: 4" in result.output
        assert "Pending:  6" in result.output


class TestCompareCsvOutput:
    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_compare_csv_output(self, mock_get_client, _mock_hash, populated_dir):
        """Compare writes valid CSV when --output ends with .csv."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes: _mock_batch_verify_response(hashes, all_exist=True)
        mock_get_client.return_value = client

        output_path = str(populated_dir / "report.csv")
        runner = CliRunner()
        result = runner.invoke(main, [
            "compare", str(populated_dir),
            "--output", output_path,
        ])

        assert Path(output_path).exists()
        with open(output_path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            rows = list(reader)
        assert len(rows) == 5
        assert "suspect_path" in reader.fieldnames
        assert "hash_hex" in reader.fieldnames
        assert all(row["matched"] == "true" for row in rows)


class TestExport:
    def test_export_manifest_csv(self, tmp_path):
        """census export produces valid CSV output."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        m.add(ManifestEntry(hash_hex="bb" * 32, path="b.txt", size=200, mtime=2.0))
        m.mark_attested("aa" * 32, "att_1", "2026-01-01T00:00:00Z")

        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)
        output_path = str(tmp_path / "export.csv")

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "csv",
            "--output", output_path,
        ])

        assert result.exit_code == 0
        with open(output_path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            rows = list(reader)
        assert len(rows) == 2
        assert "hash_hex" in reader.fieldnames
        assert "attested" in reader.fieldnames

    def test_export_manifest_json(self, tmp_path):
        """census export --format json produces valid JSON."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="aa" * 32, path="a.txt", size=100, mtime=1.0))
        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)
        output_path = str(tmp_path / "export.json")

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "json",
            "--output", output_path,
        ])

        assert result.exit_code == 0
        data = json.loads(Path(output_path).read_text())
        assert data["total"] == 1
        assert len(data["entries"]) == 1

    def test_export_csv_stdout(self, tmp_path):
        """census export without --output writes CSV to stdout."""
        m = Manifest(root_dir="/data")
        m.add(ManifestEntry(hash_hex="cc" * 32, path="c.txt", size=300, mtime=3.0))
        manifest_path = tmp_path / "m.json"
        m.save(manifest_path)

        runner = CliRunner()
        result = runner.invoke(main, [
            "export", str(manifest_path),
            "--format", "csv",
        ])

        assert result.exit_code == 0
        assert "hash_hex" in result.output
        assert "cc" * 32 in result.output


class TestParseSize:
    def test_bytes_plain(self):
        assert _parse_size("1024") == 1024

    def test_kilobytes(self):
        assert _parse_size("10K") == 10 * 1024
        assert _parse_size("10KB") == 10 * 1024

    def test_megabytes(self):
        assert _parse_size("5M") == 5 * 1024 ** 2
        assert _parse_size("5MB") == 5 * 1024 ** 2

    def test_gigabytes(self):
        assert _parse_size("2G") == 2 * 1024 ** 3

    def test_fractional(self):
        assert _parse_size("1.5G") == int(1.5 * 1024 ** 3)

    def test_case_insensitive(self):
        assert _parse_size("10m") == 10 * 1024 ** 2

    def test_invalid_raises(self):
        import click
        with pytest.raises(click.exceptions.BadParameter):
            _parse_size("abc")

    def test_unknown_unit_raises(self):
        import click
        with pytest.raises(click.exceptions.BadParameter):
            _parse_size("10X")


class TestScanFilters:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_include_filter(self, _mock_hash, populated_dir):
        """--include filters to only matching files."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "filtered.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
            "--include", "*.pdf",
            "--include", "*.csv",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 2
        paths = sorted(e.path for e in manifest.entries.values())
        assert paths == ["data.csv", "report.pdf"]

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_exclude_filter(self, _mock_hash, populated_dir):
        """--exclude removes matching files from scan."""
        runner = CliRunner()
        manifest_path = str(populated_dir / "filtered.json")

        result = runner.invoke(main, [
            "scan", str(populated_dir),
            "--dry-run",
            "--manifest", manifest_path,
            "--exclude", "*.png",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 4
        paths = sorted(e.path for e in manifest.entries.values())
        assert "image.png" not in paths

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_with_size_filters(self, _mock_hash, tmp_path):
        """--min-size and --max-size filter by file size."""
        (tmp_path / "tiny.txt").write_bytes(b"x")
        (tmp_path / "medium.txt").write_bytes(b"x" * 500)
        (tmp_path / "huge.txt").write_bytes(b"x" * 5000)

        runner = CliRunner()
        manifest_path = str(tmp_path / "sized.json")

        result = runner.invoke(main, [
            "scan", str(tmp_path),
            "--dry-run",
            "--manifest", manifest_path,
            "--min-size", "100",
            "--max-size", "2K",
        ])

        assert result.exit_code == 0
        manifest = Manifest.load(Path(manifest_path))
        assert manifest.total == 1
        assert list(manifest.entries.values())[0].path == "medium.txt"


class TestScanResume:
    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_resume_flag(self, _mock_hash, tmp_path):
        """--resume loads existing manifest and skips unchanged files."""
        scan_dir = tmp_path / "scan_root"
        scan_dir.mkdir()
        for name in ("a.pdf", "b.csv", "c.txt"):
            (scan_dir / name).write_bytes(f"content-{name}".encode())

        runner = CliRunner()
        manifest_path = str(tmp_path / "resume.json")

        result1 = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--manifest", manifest_path,
        ])
        assert result1.exit_code == 0
        m1 = Manifest.load(Path(manifest_path))
        assert m1.total == 3

        (scan_dir / "new_file.txt").write_bytes(b"added after first scan")

        result2 = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--resume",
            "--manifest", manifest_path,
        ])
        assert result2.exit_code == 0
        assert "Resuming" in result2.output
        m2 = Manifest.load(Path(manifest_path))
        assert m2.total == 4

    @patch("certisigma_census.scanner.hash_file", side_effect=_deterministic_hash)
    def test_scan_resume_no_manifest_proceeds_normally(self, _mock_hash, tmp_path):
        """--resume with no existing manifest behaves like a fresh scan."""
        scan_dir = tmp_path / "scan_root"
        scan_dir.mkdir()
        for name in ("a.pdf", "b.csv"):
            (scan_dir / name).write_bytes(f"content-{name}".encode())

        runner = CliRunner()
        manifest_path = str(tmp_path / "nonexistent.json")

        result = runner.invoke(main, [
            "scan", str(scan_dir),
            "--dry-run",
            "--resume",
            "--manifest", manifest_path,
        ])
        assert result.exit_code == 0
        assert "Resuming" not in result.output
        m = Manifest.load(Path(manifest_path))
        assert m.total == 2
