"""Tests for the AI governance policy engine (ai_policy module)."""

from __future__ import annotations

import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

from certisigma_census.ai_policy import (
    ClassificationResult,
    Policy,
    PolicyReport,
    PolicyRule,
    _apply_most_restrictive,
    _match_rule,
    classify_manifest,
    generate_template_policy,
    load_policy,
    render_ai_report_html,
    render_ai_report_json,
    tag_classifications,
)
from certisigma_census.cli import main
from certisigma_census.manifest import Manifest, ManifestEntry


def _make_manifest(tmp_path: Path, entries: list[ManifestEntry]) -> Manifest:
    m = Manifest(root_dir=str(tmp_path))
    for e in entries:
        m.add(e)
    m.save(tmp_path / "test.db")
    m.close()
    return Manifest.load(tmp_path / "test.db")


def _write_policy(tmp_path: Path, content: str) -> Path:
    p = tmp_path / ".census-ai-policy.toml"
    p.write_text(content, encoding="utf-8")
    return p


# ── Policy parsing ─────────────────────────────────────────────────


class TestLoadPolicy:
    def test_valid_minimal(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
version = "1.0"
default_action = "exclude"
""")
        pol = load_policy(p)
        assert pol.name == "Test"
        assert pol.version == "1.0"
        assert pol.default_action == "exclude"
        assert pol.rules == []

    def test_valid_with_rules(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Full"
version = "2.0"
framework = "eu-ai-act"
default_action = "allow"

[[rules]]
name = "Exclude code"
action = "exclude"
patterns = ["*.py", "*.js"]
description = "Source code"

[[rules]]
name = "Exclude big"
action = "exclude"
min_size = 100000
""")
        pol = load_policy(p)
        assert pol.name == "Full"
        assert pol.framework == "eu-ai-act"
        assert pol.default_action == "allow"
        assert len(pol.rules) == 2
        assert pol.rules[0].patterns == ["*.py", "*.js"]
        assert pol.rules[1].min_size == 100000

    def test_missing_policy_section(self, tmp_path):
        p = _write_policy(tmp_path, "[[rules]]\nname = 'R1'\n")
        with pytest.raises(ValueError, match="Missing or invalid \\[policy\\]"):
            load_policy(p)

    def test_missing_name(self, tmp_path):
        p = _write_policy(tmp_path, "[policy]\ndefault_action = 'exclude'\n")
        with pytest.raises(ValueError, match="name is required"):
            load_policy(p)

    def test_invalid_default_action(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Bad"
default_action = "maybe"
""")
        with pytest.raises(ValueError, match="default_action"):
            load_policy(p)

    def test_invalid_rule_action(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Bad rule"
action = "unknown"
patterns = ["*.txt"]
""")
        with pytest.raises(ValueError, match="action must be one of"):
            load_policy(p)

    def test_rule_missing_name(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
action = "allow"
patterns = ["*.txt"]
""")
        with pytest.raises(ValueError, match="missing 'name'"):
            load_policy(p)

    def test_invalid_toml(self, tmp_path):
        p = _write_policy(tmp_path, "{{invalid toml")
        with pytest.raises(ValueError, match="Invalid TOML"):
            load_policy(p)

    def test_patterns_must_be_list(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Bad"
action = "allow"
patterns = "*.txt"
""")
        with pytest.raises(ValueError, match="patterns must be a list"):
            load_policy(p)

    def test_too_many_rules(self, tmp_path):
        rules = "\n".join(
            f'[[rules]]\nname = "R{i}"\naction = "allow"\npatterns = ["*.{i}"]'
            for i in range(501)
        )
        p = _write_policy(tmp_path, f"""
[policy]
name = "Too many"
default_action = "exclude"

{rules}
""")
        with pytest.raises(ValueError, match="Too many rules"):
            load_policy(p)

    def test_min_size_must_be_int(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Bad size"
action = "exclude"
min_size = "big"
""")
        with pytest.raises(ValueError, match="min_size must be an integer"):
            load_policy(p)

    def test_bool_rejected_as_size(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Bool size"
action = "exclude"
min_size = true
""")
        with pytest.raises(ValueError, match="min_size must be an integer"):
            load_policy(p)

    def test_negative_size_rejected(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Neg size"
action = "exclude"
min_size = -1
""")
        with pytest.raises(ValueError, match="non-negative"):
            load_policy(p)

    def test_min_greater_than_max_rejected(self, tmp_path):
        p = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Inverted"
action = "exclude"
min_size = 1000
max_size = 500
""")
        with pytest.raises(ValueError, match="min_size.*>.*max_size"):
            load_policy(p)

    def test_too_many_patterns_per_rule(self, tmp_path):
        patterns = ", ".join(f'"*.{i}"' for i in range(1001))
        p = _write_policy(tmp_path, f"""
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Many"
action = "allow"
patterns = [{patterns}]
""")
        with pytest.raises(ValueError, match="too many patterns"):
            load_policy(p)

    def test_pattern_too_long(self, tmp_path):
        long_pat = "a" * 513
        p = _write_policy(tmp_path, f"""
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Long"
action = "allow"
patterns = ["{long_pat}"]
""")
        with pytest.raises(ValueError, match="pattern too long"):
            load_policy(p)

    def test_policy_name_too_long(self, tmp_path):
        long_name = "x" * 257
        p = _write_policy(tmp_path, f"""
[policy]
name = "{long_name}"
default_action = "exclude"
""")
        with pytest.raises(ValueError, match="name too long"):
            load_policy(p)


# ── Rule matching ──────────────────────────────────────────────────


class TestMatchRule:
    def test_glob_pattern_match(self):
        rule = PolicyRule(name="Docs", action="allow", patterns=["*.md", "*.txt"])
        entry = ManifestEntry(hash_hex="a" * 64, path="README.md", size=100, mtime=0.0)
        assert _match_rule(entry, rule) is True

    def test_glob_pattern_no_match(self):
        rule = PolicyRule(name="Docs", action="allow", patterns=["*.md"])
        entry = ManifestEntry(hash_hex="a" * 64, path="main.py", size=100, mtime=0.0)
        assert _match_rule(entry, rule) is False

    def test_size_filter_min(self):
        rule = PolicyRule(name="Big", action="exclude", min_size=1000)
        small = ManifestEntry(hash_hex="a" * 64, path="small.bin", size=500, mtime=0.0)
        big = ManifestEntry(hash_hex="b" * 64, path="big.bin", size=2000, mtime=0.0)
        assert _match_rule(small, rule) is False
        assert _match_rule(big, rule) is True

    def test_size_filter_max(self):
        rule = PolicyRule(name="Small only", action="allow", max_size=1000)
        small = ManifestEntry(hash_hex="a" * 64, path="small.bin", size=500, mtime=0.0)
        big = ManifestEntry(hash_hex="b" * 64, path="big.bin", size=2000, mtime=0.0)
        assert _match_rule(small, rule) is True
        assert _match_rule(big, rule) is False

    def test_combined_pattern_and_size(self):
        rule = PolicyRule(name="Small docs", action="allow",
                          patterns=["*.md"], max_size=1000)
        match = ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0)
        too_big = ManifestEntry(hash_hex="b" * 64, path="huge.md", size=2000, mtime=0.0)
        wrong_ext = ManifestEntry(hash_hex="c" * 64, path="main.py", size=100, mtime=0.0)
        assert _match_rule(match, rule) is True
        assert _match_rule(too_big, rule) is False
        assert _match_rule(wrong_ext, rule) is False

    def test_no_patterns_no_size_no_match(self):
        rule = PolicyRule(name="Empty", action="allow")
        entry = ManifestEntry(hash_hex="a" * 64, path="any.txt", size=100, mtime=0.0)
        assert _match_rule(entry, rule) is False

    def test_path_with_directory(self):
        rule = PolicyRule(name="Docs", action="allow", patterns=["docs/*.md"])
        entry = ManifestEntry(hash_hex="a" * 64, path="docs/readme.md", size=100, mtime=0.0)
        assert _match_rule(entry, rule) is True


# ── Classification ─────────────────────────────────────────────────


class TestClassifyManifest:
    def test_first_match_wins(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[
                    PolicyRule(name="Allow docs", action="allow", patterns=["*.md"]),
                    PolicyRule(name="Exclude all", action="exclude", patterns=["*"]),
                ],
            )
            report = classify_manifest(m, pol)
            assert report.results[0].action == "allow"
            assert report.results[0].rule_name == "Allow docs"
        finally:
            m.close()

    def test_default_fallback(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="unknown.xyz", size=100, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[
                    PolicyRule(name="Docs", action="allow", patterns=["*.md"]),
                ],
            )
            report = classify_manifest(m, pol)
            assert report.results[0].action == "exclude"
            assert report.results[0].is_default is True
            assert report.classified_by_default == 1
        finally:
            m.close()

    def test_coverage_metrics(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0),
            ManifestEntry(hash_hex="b" * 64, path="main.py", size=200, mtime=0.0),
            ManifestEntry(hash_hex="c" * 64, path="data.csv", size=300, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[
                    PolicyRule(name="Docs", action="allow", patterns=["*.md", "*.csv"]),
                ],
            )
            report = classify_manifest(m, pol)
            assert report.total_files == 3
            assert report.classified_by_rule == 2
            assert report.classified_by_default == 1
            assert report.allowed == 2
            assert report.excluded == 1
        finally:
            m.close()

    def test_empty_manifest(self, tmp_path):
        m = _make_manifest(tmp_path, [])
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[],
            )
            report = classify_manifest(m, pol)
            assert report.total_files == 0
            assert report.results == []
        finally:
            m.close()

    def test_all_allowed(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="a.md", size=10, mtime=0.0),
            ManifestEntry(hash_hex="b" * 64, path="b.txt", size=20, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="allow",
                rules=[],
            )
            report = classify_manifest(m, pol)
            assert report.allowed == 2
            assert report.excluded == 0
        finally:
            m.close()

    def test_untaggable_detection(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="a.md", size=10, mtime=0.0,
                          attested=True, attestation_id="att_1"),
            ManifestEntry(hash_hex="b" * 64, path="b.md", size=20, mtime=0.0,
                          attested=False),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="allow",
                rules=[],
            )
            report = classify_manifest(m, pol)
            assert report.untaggable == 1
        finally:
            m.close()

    def test_unicode_filenames(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="café.md", size=100, mtime=0.0),
            ManifestEntry(hash_hex="b" * 64, path="日本語/data.csv", size=50, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[PolicyRule(name="Docs", action="allow", patterns=["*.md"])],
            )
            report = classify_manifest(m, pol)
            assert report.allowed == 1
            assert report.excluded == 1
            assert report.results[0].path == "café.md"
        finally:
            m.close()

    def test_size_zero_files(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="empty.txt", size=0, mtime=0.0),
            ManifestEntry(hash_hex="b" * 64, path="notempty.txt", size=1, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[PolicyRule(name="Non-empty", action="allow", min_size=1)],
            )
            report = classify_manifest(m, pol)
            assert report.allowed == 1
            assert report.excluded == 1
        finally:
            m.close()

    def test_all_excluded_by_default(self, tmp_path):
        entries = [
            ManifestEntry(hash_hex="a" * 64, path="a.py", size=100, mtime=0.0),
            ManifestEntry(hash_hex="b" * 64, path="b.js", size=200, mtime=0.0),
        ]
        m = _make_manifest(tmp_path, entries)
        try:
            pol = Policy(
                name="Test", version="1", framework="", default_action="exclude",
                rules=[],
            )
            report = classify_manifest(m, pol)
            assert report.allowed == 0
            assert report.excluded == 2
            assert report.classified_by_default == 2
        finally:
            m.close()


# ── Most-restrictive-wins ──────────────────────────────────────────


class TestMostRestrictive:
    def test_shared_attestation_elevated(self):
        results = [
            ClassificationResult(
                hash_hex="a" * 64, path="a.md", action="allow",
                rule_name="Docs", attestation_id="att_1",
            ),
            ClassificationResult(
                hash_hex="b" * 64, path="b.py", action="exclude",
                rule_name="Code", attestation_id="att_1",
            ),
        ]
        _apply_most_restrictive(results)
        assert results[0].action == "exclude"
        assert "most-restrictive" in results[0].rule_name

    def test_no_change_when_all_same(self):
        results = [
            ClassificationResult(
                hash_hex="a" * 64, path="a.md", action="allow",
                rule_name="Docs", attestation_id="att_1",
            ),
            ClassificationResult(
                hash_hex="b" * 64, path="b.txt", action="allow",
                rule_name="Docs", attestation_id="att_1",
            ),
        ]
        _apply_most_restrictive(results)
        assert results[0].action == "allow"
        assert results[1].action == "allow"

    def test_different_attestation_ids_independent(self):
        results = [
            ClassificationResult(
                hash_hex="a" * 64, path="a.md", action="allow",
                rule_name="Docs", attestation_id="att_1",
            ),
            ClassificationResult(
                hash_hex="b" * 64, path="b.py", action="exclude",
                rule_name="Code", attestation_id="att_2",
            ),
        ]
        _apply_most_restrictive(results)
        assert results[0].action == "allow"
        assert results[1].action == "exclude"

    def test_none_attestation_id_skipped(self):
        results = [
            ClassificationResult(
                hash_hex="a" * 64, path="a.md", action="allow",
                rule_name="Docs", attestation_id=None,
            ),
            ClassificationResult(
                hash_hex="b" * 64, path="b.py", action="exclude",
                rule_name="Code", attestation_id=None,
            ),
        ]
        _apply_most_restrictive(results)
        assert results[0].action == "allow"
        assert results[1].action == "exclude"


# ── Report rendering ───────────────────────────────────────────────


class TestRenderReports:
    def _make_report(self):
        return PolicyReport(
            policy_name="Test Policy",
            policy_version="1.0",
            framework="eu-ai-act",
            default_action="exclude",
            generated_at="2026-03-21T00:00:00Z",
            census_version="1.9.0",
            total_files=2,
            classified_by_rule=1,
            classified_by_default=1,
            allowed=1,
            excluded=1,
            untaggable=0,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="readme.md", action="allow",
                    rule_name="Docs", attestation_id="att_1",
                ),
                ClassificationResult(
                    hash_hex="b" * 64, path="main.py", action="exclude",
                    rule_name="Code", attestation_id="att_2",
                ),
            ],
        )

    def test_html_contains_essentials(self):
        report = self._make_report()
        html_out = render_ai_report_html(report)
        assert "AI Governance Report" in html_out
        assert "Test Policy" in html_out
        assert "EU AI Act" in html_out
        assert "readme.md" in html_out
        assert "main.py" in html_out

    def test_html_escapes_dynamic_values(self):
        report = self._make_report()
        report.policy_name = '<script>alert("xss")</script>'
        report.results[0].path = '<img src=x onerror=alert(1)>'
        html_out = render_ai_report_html(report)
        assert "<script>" not in html_out
        assert "&lt;script&gt;" in html_out
        assert "<img src=" not in html_out

    def test_json_roundtrip(self):
        report = self._make_report()
        data = render_ai_report_json(report)
        assert data["policy_name"] == "Test Policy"
        assert len(data["results"]) == 2
        serialized = json.dumps(data, default=str)
        parsed = json.loads(serialized)
        assert parsed["total_files"] == 2

    def test_json_contains_all_fields(self):
        report = self._make_report()
        data = render_ai_report_json(report)
        for field in ["policy_name", "policy_version", "framework",
                       "default_action", "total_files", "classified_by_rule",
                       "classified_by_default", "allowed", "excluded",
                       "untaggable", "results"]:
            assert field in data

    def test_html_zero_files(self):
        report = PolicyReport(
            policy_name="Empty", policy_version="1", framework="",
            default_action="exclude", total_files=0,
        )
        html_out = render_ai_report_html(report)
        assert "0" in html_out
        assert "0.0%" in html_out


# ── Template generation ────────────────────────────────────────────


class TestTemplateGeneration:
    def test_template_is_valid_toml(self, tmp_path):
        content = generate_template_policy()
        p = tmp_path / "test.toml"
        p.write_text(content, encoding="utf-8")
        pol = load_policy(p)
        assert pol.name == "AI Training Data Policy"
        assert pol.default_action == "exclude"
        assert len(pol.rules) >= 3

    def test_template_contains_framework(self):
        content = generate_template_policy()
        assert "eu-ai-act" in content
        assert "default_action" in content


# ── Tagging ────────────────────────────────────────────────────────


class TestTagClassifications:
    @patch("certisigma_census.tagging.set_tags")
    def test_tags_attested_files(self, mock_set):
        mock_set.return_value = MagicMock(success=True)
        report = PolicyReport(
            policy_name="T", policy_version="1", framework="",
            default_action="exclude",
            total_files=2,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="a.md", action="allow",
                    rule_name="R", attestation_id="att_1",
                ),
                ClassificationResult(
                    hash_hex="b" * 64, path="b.py", action="exclude",
                    rule_name="R", attestation_id="att_2",
                ),
            ],
        )
        tagged, errors = tag_classifications(report)
        assert tagged == 2
        assert errors == 0
        assert mock_set.call_count == 2

    @patch("certisigma_census.tagging.set_tags")
    def test_skips_unattested(self, mock_set):
        mock_set.return_value = MagicMock(success=True)
        report = PolicyReport(
            policy_name="T", policy_version="1", framework="",
            default_action="exclude",
            total_files=1,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="a.md", action="allow",
                    rule_name="R", attestation_id=None,
                ),
            ],
        )
        tagged, errors = tag_classifications(report)
        assert tagged == 0
        assert mock_set.call_count == 0

    @patch("certisigma_census.tagging.set_tags")
    def test_handles_tag_errors(self, mock_set):
        mock_set.return_value = MagicMock(success=False)
        report = PolicyReport(
            policy_name="T", policy_version="1", framework="",
            default_action="exclude",
            total_files=1,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="a.md", action="allow",
                    rule_name="R", attestation_id="att_1",
                ),
            ],
        )
        tagged, errors = tag_classifications(report)
        assert tagged == 0
        assert errors == 1

    @patch("certisigma_census.tagging.set_tags")
    def test_deduplicates_by_attestation_id(self, mock_set):
        mock_set.return_value = MagicMock(success=True)
        report = PolicyReport(
            policy_name="T", policy_version="1", framework="",
            default_action="exclude",
            total_files=2,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="a.md", action="allow",
                    rule_name="R", attestation_id="att_1",
                ),
                ClassificationResult(
                    hash_hex="b" * 64, path="b.md", action="allow",
                    rule_name="R", attestation_id="att_1",
                ),
            ],
        )
        tagged, errors = tag_classifications(report)
        assert tagged == 1
        assert mock_set.call_count == 1

    @patch("certisigma_census.tagging.set_tags")
    def test_dedup_preserves_exclude_over_allow(self, mock_set):
        mock_set.return_value = MagicMock(success=True)
        report = PolicyReport(
            policy_name="T", policy_version="1", framework="",
            default_action="exclude",
            total_files=2,
            results=[
                ClassificationResult(
                    hash_hex="a" * 64, path="a.md", action="allow",
                    rule_name="R1", attestation_id="att_1",
                ),
                ClassificationResult(
                    hash_hex="b" * 64, path="b.py", action="exclude",
                    rule_name="R2", attestation_id="att_1",
                ),
            ],
        )
        tagged, errors = tag_classifications(report)
        assert tagged == 1
        call_args = mock_set.call_args
        assert call_args[0][1] == [("ai_training", "excluded")]


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


class TestCLI:
    def test_init_creates_file(self, tmp_path):
        runner = CliRunner()
        out = tmp_path / "policy.toml"
        result = runner.invoke(main, ["ai-policy", "init", "-o", str(out)])
        assert result.exit_code == 0
        assert out.exists()
        pol = load_policy(out)
        assert pol.name == "AI Training Data Policy"

    def test_init_refuses_overwrite(self, tmp_path):
        out = tmp_path / "policy.toml"
        out.write_text("existing", encoding="utf-8")
        runner = CliRunner()
        result = runner.invoke(main, ["ai-policy", "init", "-o", str(out)])
        assert result.exit_code != 0
        assert "already exists" in result.output or "already exists" in (result.stderr or "")

    def test_apply_dry_run(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0))
        m.save(tmp_path / "m.db")
        m.close()

        pol = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Docs"
action = "allow"
patterns = ["*.md"]
""")
        runner = CliRunner()
        result = runner.invoke(main, [
            "ai-policy", "apply", str(tmp_path / "m.db"),
            "-p", str(pol), "--dry-run",
        ])
        assert result.exit_code == 0
        assert "Allowed" in result.output

    def test_apply_json(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0))
        m.save(tmp_path / "m.db")
        m.close()

        pol = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Docs"
action = "allow"
patterns = ["*.md"]
""")
        runner = CliRunner()
        result = runner.invoke(main, [
            "ai-policy", "apply", str(tmp_path / "m.db"),
            "-p", str(pol), "--dry-run", "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total_files"] == 1
        assert data["allowed"] == 1

    def test_report_json(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0))
        m.add(ManifestEntry(hash_hex="b" * 64, path="main.py", size=200, mtime=0.0))
        m.save(tmp_path / "m.db")
        m.close()

        pol = _write_policy(tmp_path, """
[policy]
name = "Test"
framework = "eu-ai-act"
default_action = "exclude"

[[rules]]
name = "Docs"
action = "allow"
patterns = ["*.md"]
""")
        runner = CliRunner()
        result = runner.invoke(main, [
            "ai-policy", "report", str(tmp_path / "m.db"),
            "-p", str(pol), "--json",
        ])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["total_files"] == 2
        assert data["framework"] == "eu-ai-act"

    def test_report_html_output(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="a" * 64, path="readme.md", size=100, mtime=0.0))
        m.save(tmp_path / "m.db")
        m.close()

        pol = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"

[[rules]]
name = "Docs"
action = "allow"
patterns = ["*.md"]
""")
        out = tmp_path / "report.html"
        runner = CliRunner()
        result = runner.invoke(main, [
            "ai-policy", "report", str(tmp_path / "m.db"),
            "-p", str(pol), "-o", str(out),
        ])
        assert result.exit_code == 0
        assert out.exists()
        content = out.read_text(encoding="utf-8")
        assert "AI Governance Report" in content

    def test_apply_requires_api_key_for_tagging(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.add(ManifestEntry(hash_hex="a" * 64, path="a.md", size=10, mtime=0.0))
        m.save(tmp_path / "m.db")
        m.close()

        pol = _write_policy(tmp_path, """
[policy]
name = "Test"
default_action = "exclude"
""")
        env = {k: v for k, v in os.environ.items() if k != "CERTISIGMA_API_KEY"}
        runner = CliRunner(env=env)
        result = runner.invoke(main, [
            "ai-policy", "apply", str(tmp_path / "m.db"),
            "-p", str(pol),
        ])
        assert result.exit_code != 0

    def test_invalid_policy_path(self, tmp_path):
        m = Manifest(root_dir=str(tmp_path))
        m.save(tmp_path / "m.db")
        m.close()

        runner = CliRunner()
        result = runner.invoke(main, [
            "ai-policy", "report", str(tmp_path / "m.db"),
            "-p", str(tmp_path / "nonexistent.toml"),
        ])
        assert result.exit_code != 0
