"""Tests for SARIF v2.1.0 output generation."""

from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from certisigma_census.cli import main
from certisigma_census.compare import CompareReport, MatchResult
from certisigma_census.sarif import (
    _dir_to_file_uri,
    compare_report_to_sarif,
)


HASH_A = "aa" * 32
HASH_B = "bb" * 32


class TestCompareReportToSarif:
    def test_empty_report(self):
        report = CompareReport(total_suspect=5, matched=0, not_matched=5)
        sarif = compare_report_to_sarif(report, "1.2.0", "/data/suspect")

        assert sarif["version"] == "2.1.0"
        assert "$schema" in sarif
        assert len(sarif["runs"]) == 1
        run = sarif["runs"][0]
        assert run["tool"]["driver"]["name"] == "certisigma-census"
        assert run["tool"]["driver"]["version"] == "1.2.0"
        assert run["results"] == []
        assert "SUSPECT_DIR" in run["originalUriBaseIds"]

    def test_single_match(self):
        match = MatchResult(
            hash_hex=HASH_A,
            suspect_path="docs/secret.pdf",
            attestation_id="att_42",
            timestamp="2026-03-01T12:00:00Z",
            level="T1",
            source="inventory-hr",
            inventory_path="hr/payroll/secret.pdf",
        )
        report = CompareReport(total_suspect=1, matched=1, not_matched=0, matches=[match])
        sarif = compare_report_to_sarif(report, "1.2.0", "/mnt/evidence")

        results = sarif["runs"][0]["results"]
        assert len(results) == 1
        r = results[0]
        assert r["ruleId"] == "CENSUS001"
        assert r["level"] == "error"
        assert "docs/secret.pdf" in r["message"]["text"]
        assert HASH_A[:16] in r["message"]["text"]
        assert r["fingerprints"]["sha256"] == HASH_A

        loc = r["locations"][0]["physicalLocation"]["artifactLocation"]
        assert loc["uri"] == "docs/secret.pdf"
        assert loc["uriBaseId"] == "SUSPECT_DIR"

        props = r["properties"]
        assert props["attestation_id"] == "att_42"
        assert props["attestation_level"] == "T1"
        assert props["first_attested"] == "2026-03-01T12:00:00Z"
        assert props["source"] == "inventory-hr"
        assert props["inventory_path"] == "hr/payroll/secret.pdf"

    def test_multiple_matches(self):
        matches = [
            MatchResult(hash_hex=HASH_A, suspect_path="a.txt", attestation_id="att_1"),
            MatchResult(hash_hex=HASH_B, suspect_path="b.txt", attestation_id="att_2"),
        ]
        report = CompareReport(total_suspect=3, matched=2, not_matched=1, matches=matches)
        sarif = compare_report_to_sarif(report, "1.0.0", "/evidence")

        results = sarif["runs"][0]["results"]
        assert len(results) == 2
        assert results[0]["fingerprints"]["sha256"] == HASH_A
        assert results[1]["fingerprints"]["sha256"] == HASH_B

    def test_optional_fields_omitted_from_properties(self):
        match = MatchResult(hash_hex=HASH_A, suspect_path="leak.bin")
        report = CompareReport(total_suspect=1, matched=1, not_matched=0, matches=[match])
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")

        r = sarif["runs"][0]["results"][0]
        assert "properties" not in r

    def test_partial_optional_fields(self):
        match = MatchResult(
            hash_hex=HASH_A,
            suspect_path="x.dat",
            attestation_id="att_99",
            level="T2",
        )
        report = CompareReport(total_suspect=1, matched=1, not_matched=0, matches=[match])
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")

        props = sarif["runs"][0]["results"][0]["properties"]
        assert props["attestation_id"] == "att_99"
        assert props["attestation_level"] == "T2"
        assert "first_attested" not in props
        assert "source" not in props
        assert "inventory_path" not in props

    def test_rule_definition(self):
        report = CompareReport()
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")

        rules = sarif["runs"][0]["tool"]["driver"]["rules"]
        assert len(rules) == 1
        rule = rules[0]
        assert rule["id"] == "CENSUS001"
        assert rule["name"] == "ExfiltrationMatch"
        assert rule["defaultConfiguration"]["level"] == "error"
        assert "helpUri" in rule
        assert "help" in rule
        assert "text" in rule["help"]
        assert "markdown" in rule["help"]
        assert "tags" in rule["properties"]
        assert "security" in rule["properties"]["tags"]

    def test_invocations_present(self):
        report = CompareReport()
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")
        run = sarif["runs"][0]
        assert "invocations" in run
        assert run["invocations"][0]["executionSuccessful"] is True

    def test_rule_is_deep_copy(self):
        report = CompareReport()
        sarif1 = compare_report_to_sarif(report, "1.0.0", "/a")
        sarif1["runs"][0]["tool"]["driver"]["rules"][0]["id"] = "MUTATED"
        sarif2 = compare_report_to_sarif(report, "1.0.0", "/b")
        assert sarif2["runs"][0]["tool"]["driver"]["rules"][0]["id"] == "CENSUS001"

    def test_sarif_is_valid_json(self):
        match = MatchResult(hash_hex=HASH_A, suspect_path="f.txt", attestation_id="att_1")
        report = CompareReport(total_suspect=1, matched=1, not_matched=0, matches=[match])
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")
        serialized = json.dumps(sarif, indent=2)
        roundtripped = json.loads(serialized)
        assert roundtripped == sarif

    def test_information_uri(self):
        report = CompareReport()
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")
        driver = sarif["runs"][0]["tool"]["driver"]
        assert driver["informationUri"] == "https://certisigma.ch/census"

    def test_message_text_uses_unknown_fallbacks(self):
        match = MatchResult(hash_hex=HASH_A, suspect_path="f.txt")
        report = CompareReport(total_suspect=1, matched=1, not_matched=0, matches=[match])
        sarif = compare_report_to_sarif(report, "1.2.0", "/data")

        msg = sarif["runs"][0]["results"][0]["message"]["text"]
        assert "unknown" in msg


class TestDirToFileUri:
    def test_absolute_path(self):
        assert _dir_to_file_uri("/data/suspect") == "file:///data/suspect/"

    def test_trailing_slash_preserved(self):
        assert _dir_to_file_uri("/data/suspect/") == "file:///data/suspect/"

    def test_nested_path(self):
        assert _dir_to_file_uri("/mnt/evidence/case-42/files") == "file:///mnt/evidence/case-42/files/"

    def test_spaces_percent_encoded(self):
        assert _dir_to_file_uri("/data/my files") == "file:///data/my%20files/"

    def test_hash_percent_encoded(self):
        assert _dir_to_file_uri("/data/case#42") == "file:///data/case%2342/"


def _deterministic_hash(path_str):
    from pathlib import Path
    name = Path(path_str).name
    return f"{abs(hash(name)) % (16**64):064x}"


def _mock_batch_verify_response(hashes, all_exist=False, **kwargs):
    results = [
        {"hash_hex": h, "exists": all_exist, "id": f"att_{h[:8]}", "level": "T1",
         "timestamp": "2026-03-01T12:00:00Z", "source": "test-src"}
        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 TestCompareSarifCli:
    """CLI integration tests for --format sarif.

    Logging is disabled to prevent log lines from contaminating
    stdout JSON output (same pattern as TestCompareJson in test_cli.py).
    """

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_format_sarif_output(self, mock_get_client, _mock_hash, populated_dir):
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--format", "sarif"])

            assert result.exit_code == 1
            data = json.loads(result.output)
            assert data["version"] == "2.1.0"
            assert len(data["runs"]) == 1
            assert len(data["runs"][0]["results"]) == 5
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_format_sarif_no_matches(self, mock_get_client, _mock_hash, populated_dir):
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--format", "sarif"])

            assert result.exit_code == 0
            data = json.loads(result.output)
            assert data["runs"][0]["results"] == []
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_format_json_still_works(self, mock_get_client, _mock_hash, populated_dir):
        """--format json produces the same dataclass JSON as --json."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--format", "json"])

            assert result.exit_code == 1
            data = json.loads(result.output)
            assert "total_suspect" in data
            assert "matches" in data
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_json_flag_backward_compat(self, mock_get_client, _mock_hash, populated_dir):
        """--json flag continues to produce the old JSON format."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--json"])

            assert result.exit_code == 1
            data = json.loads(result.output)
            assert "total_suspect" in data
            assert "matched" in data
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_format_wins_over_json_flag(self, mock_get_client, _mock_hash, populated_dir):
        """--format sarif takes precedence over --json."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--json", "--format", "sarif"])

            assert result.exit_code == 1
            data = json.loads(result.output)
            assert data["version"] == "2.1.0"
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_format_text_default(self, mock_get_client, _mock_hash, populated_dir):
        """Default (no --format, no --json) produces human-readable text."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        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_sarif_suppresses_progress_bar(self, mock_get_client, _mock_hash, populated_dir):
        """SARIF output must not contain progress bar text."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--format", "sarif"])

            data = json.loads(result.output)
            assert data["version"] == "2.1.0"
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_sarif_output_writes_to_file(self, mock_get_client, _mock_hash, populated_dir):
        """--format sarif + --output writes SARIF to the specified file."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=False, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            out = populated_dir / "report.sarif"
            runner = CliRunner()
            result = runner.invoke(main, [
                "compare", str(populated_dir),
                "--format", "sarif", "--output", str(out),
            ])

            assert result.exit_code == 0
            assert out.exists()
            data = json.loads(out.read_text())
            assert data["version"] == "2.1.0"
            assert "saved" in result.stderr.lower()
        finally:
            logging.disable(logging.NOTSET)

    @patch("certisigma_census.compare.hash_file", side_effect=_deterministic_hash)
    @patch("certisigma_census.cli._get_client")
    def test_sarif_result_properties(self, mock_get_client, _mock_hash, populated_dir):
        """SARIF results contain correct properties from MatchResult."""
        client = MagicMock()
        client.batch_verify.side_effect = lambda hashes, **kw: _mock_batch_verify_response(hashes, all_exist=True, **kw)
        mock_get_client.return_value = client

        import logging
        logging.disable(logging.CRITICAL)
        try:
            runner = CliRunner()
            result = runner.invoke(main, ["compare", str(populated_dir), "--format", "sarif"])

            data = json.loads(result.output)
            for r in data["runs"][0]["results"]:
                assert r["ruleId"] == "CENSUS001"
                assert r["level"] == "error"
                assert "sha256" in r["fingerprints"]
                assert len(r["fingerprints"]["sha256"]) == 64
        finally:
            logging.disable(logging.NOTSET)
