"""
tests/test_technical_file_builder.py — Unit tests for TechnicalFileBuilder.

Covers:
  - TechnicalFileSection dataclass
  - TechnicalFile dataclass (to_dict, to_json, save)
  - TechnicalFileBuilder._initialize_sections()
  - TechnicalFileBuilder._populate_sections_from_findings()
  - TechnicalFileBuilder._compute_compliance_status()
  - TechnicalFileBuilder._generate_narratives()
  - TechnicalFileBuilder._create_executive_summary()
  - TechnicalFileBuilder.build() end-to-end
  - Edge cases: no findings, all pass, all critical, mixed
"""
from __future__ import annotations

import json
import tempfile
from pathlib import Path

import pytest

from sichgate.technical_file_builder import (
    TechnicalFile,
    TechnicalFileBuilder,
    TechnicalFileSection,
)
from sichgate.annex_iv import ANNEX_IV_SECTIONS


# ---------------------------------------------------------------------------
# Helpers / Fixtures
# ---------------------------------------------------------------------------

def _make_finding(
    attack_type: str = "prompt_injection",
    status: str = "fail",
    severity: str = "high",
    finding_status: str = "NEW",
    notes: str = "Test finding",
    remediation: str = "Apply guardrails",
) -> dict:
    return {
        "attack_name": f"test_{attack_type}",
        "attack_type": attack_type,
        "status": status,
        "severity": severity if status in ("fail", "warning") else None,
        "notes": notes,
        "remediation": remediation if status in ("fail", "warning") else None,
        "finding_status": finding_status,
        "compliance_tags": [],
        "success_rate": 0.8 if status == "fail" else 0.1,
    }


def _make_passing_finding(attack_type: str = "bias_fairness") -> dict:
    return _make_finding(
        attack_type=attack_type,
        status="pass",
        severity="low",
        remediation="",
    )


def _sample_aibom() -> dict:
    return {
        "bomFormat": "CycloneDX",
        "specVersion": "1.6",
        "serialNumber": "urn:uuid:test-run-001",
        "version": 1,
        "metadata": {
            "timestamp": "2026-03-02T12:00:00+00:00",
            "tools": [{"vendor": "SichGate", "name": "SichGate Pro", "version": "2.1.0"}],
            "component": {
                "type": "machine-learning-model",
                "name": "test-model",
                "version": "1.0",
                "hashes": [{"alg": "SHA-256", "content": "abc123def456" * 4}],
            },
        },
        "properties": [
            {"name": "sichgate:hardware:cpu_model", "value": "Apple M1"},
            {"name": "sichgate:hardware:ram_gb", "value": "16.0"},
        ],
    }


@pytest.fixture
def no_findings_builder() -> TechnicalFileBuilder:
    return TechnicalFileBuilder(findings=[])


@pytest.fixture
def all_pass_builder() -> TechnicalFileBuilder:
    findings = [
        _make_passing_finding("prompt_injection"),
        _make_passing_finding("bias_fairness"),
        _make_passing_finding("hallucination"),
    ]
    return TechnicalFileBuilder(findings=findings, aibom=_sample_aibom())


@pytest.fixture
def mixed_builder() -> TechnicalFileBuilder:
    findings = [
        _make_finding("prompt_injection", "fail", "critical"),
        _make_finding("bias_fairness", "warning", "medium"),
        _make_passing_finding("hallucination"),
    ]
    return TechnicalFileBuilder(findings=findings, aibom=_sample_aibom())


@pytest.fixture
def full_builder() -> TechnicalFileBuilder:
    findings = [
        _make_finding("prompt_injection", "fail", "critical"),
        _make_finding("jailbreak", "fail", "high"),
        _make_finding("bias_fairness", "warning", "medium"),
        _make_finding("alignment", "fail", "low"),
        _make_passing_finding("hallucination"),
    ]
    diff = {
        "run_id": "diff-001",
        "base_model_id": "base-model",
        "custom_model_id": "custom-model",
        "overall_risk_delta": 0.25,
        "critical_alerts": ["CRITICAL Article 10"],
        "differential_analysis": {
            "prompt_injection": {
                "base_success_rate": 0.3,
                "custom_success_rate": 0.7,
                "delta": 0.4,
                "trend": "WORSENED",
                "compliance_alert": "CRITICAL Article 15",
                "annex_iv_sections": ["2g", "2h", "5"],
            }
        },
    }
    att = {
        "attestation_id": "att-001",
        "algorithm": "ed25519",
        "signature": "base64sig==",
        "public_key_b64": "pubkey==",
        "payload_hash": "sha256hash",
        "timestamp": "2026-03-02T12:00:00+00:00",
    }
    return TechnicalFileBuilder(
        findings=findings,
        aibom=_sample_aibom(),
        differential_report=diff,
        attestation=att,
    )


# ---------------------------------------------------------------------------
# 1. TechnicalFileSection dataclass
# ---------------------------------------------------------------------------

class TestTechnicalFileSection:
    def test_defaults(self):
        s = TechnicalFileSection(section_id="2g", title="Testing", requirement="Req")
        assert s.findings == []
        assert s.narrative == ""
        assert s.compliance_status == "NOT_APPLICABLE"

    def test_to_dict_has_all_keys(self):
        s = TechnicalFileSection(
            section_id="2g",
            title="Testing procedures",
            requirement="Conduct tests",
            findings=[{"attack_type": "prompt_injection"}],
            narrative="Tests were run.",
            compliance_status="COMPLIANT",
        )
        d = s.to_dict()
        assert d["section_id"] == "2g"
        assert d["title"] == "Testing procedures"
        assert d["requirement"] == "Conduct tests"
        assert len(d["findings"]) == 1
        assert d["narrative"] == "Tests were run."
        assert d["compliance_status"] == "COMPLIANT"


# ---------------------------------------------------------------------------
# 2. TechnicalFile dataclass
# ---------------------------------------------------------------------------

class TestTechnicalFile:
    def _make_tf(self) -> TechnicalFile:
        sections = {
            "2g": TechnicalFileSection(
                section_id="2g",
                title="Testing",
                requirement="Req",
                compliance_status="COMPLIANT",
            )
        }
        return TechnicalFile(
            model_metadata={"name": "test-model", "version": "1.0", "hash": "abc"},
            sections=sections,
            executive_summary={"model_name": "test-model", "total_findings": 0},
            timestamp="2026-03-02T12:00:00+00:00",
        )

    def test_to_dict_structure(self):
        tf = self._make_tf()
        d = tf.to_dict()
        assert "test_run_id" in d
        assert "timestamp" in d
        assert "model_metadata" in d
        assert "sections" in d
        assert "executive_summary" in d
        assert "attestation" in d
        assert "differential_report" in d

    def test_to_json_is_valid(self):
        tf = self._make_tf()
        raw = tf.to_json()
        parsed = json.loads(raw)
        assert "sections" in parsed

    def test_to_json_indent(self):
        tf = self._make_tf()
        raw = tf.to_json(indent=4)
        assert "\n    " in raw  # 4-space indent

    def test_save_writes_file(self):
        tf = self._make_tf()
        with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
            tf.save(tmp.name)
            content = Path(tmp.name).read_text()
        parsed = json.loads(content)
        assert "sections" in parsed

    def test_test_run_id_is_uuid(self):
        tf = self._make_tf()
        import re
        pattern = r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
        assert re.match(pattern, tf.test_run_id)

    def test_optional_fields_default_none(self):
        tf = self._make_tf()
        assert tf.differential_report is None
        assert tf.attestation is None


# ---------------------------------------------------------------------------
# 3. _initialize_sections
# ---------------------------------------------------------------------------

class TestInitializeSections:
    def test_all_annex_iv_sections_created(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        for sid in ANNEX_IV_SECTIONS:
            assert sid in sections

    def test_sections_have_correct_titles(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        for sid, title in ANNEX_IV_SECTIONS.items():
            assert sections[sid].title == title

    def test_sections_start_not_applicable(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        for s in sections.values():
            assert s.compliance_status == "NOT_APPLICABLE"

    def test_sections_start_with_empty_findings(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        for s in sections.values():
            assert s.findings == []

    def test_sections_have_requirement_text(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        # Section 2g should have meaningful requirement text
        assert len(sections["2g"].requirement) > 20
        assert len(sections["5"].requirement) > 20


# ---------------------------------------------------------------------------
# 4. _populate_sections_from_findings
# ---------------------------------------------------------------------------

class TestPopulateSections:
    def test_prompt_injection_maps_to_2g_2h_5(self):
        builder = TechnicalFileBuilder(
            findings=[_make_finding("prompt_injection")]
        )
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        assert len(sections["2g"].findings) == 1
        assert len(sections["2h"].findings) == 1
        assert len(sections["5"].findings) == 1

    def test_bias_fairness_maps_to_correct_sections(self):
        builder = TechnicalFileBuilder(
            findings=[_make_finding("bias_fairness")]
        )
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        # bias_fairness → ["2g", "3", "5"]
        assert len(sections["2g"].findings) == 1
        assert len(sections["3"].findings) == 1
        assert len(sections["5"].findings) == 1

    def test_no_duplicate_findings_per_section(self):
        """Same finding should not appear twice in the same section."""
        finding = _make_finding("prompt_injection")
        builder = TechnicalFileBuilder(findings=[finding])
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        # prompt_injection maps to 2g, 2h, 5 — each should have exactly 1 finding
        assert len(sections["2g"].findings) == 1

    def test_unknown_attack_type_maps_to_2g(self):
        builder = TechnicalFileBuilder(
            findings=[_make_finding("unknown_exotic_attack")]
        )
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        assert len(sections["2g"].findings) == 1

    def test_pre_annotated_findings_respected(self):
        """Findings with existing annex_iv_sections should use those."""
        finding = {
            "attack_name": "custom",
            "attack_type": "custom_attack",
            "status": "fail",
            "severity": "high",
            "annex_iv_sections": ["7", "8"],
            "compliance_tags": [],
        }
        builder = TechnicalFileBuilder(findings=[finding])
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        assert len(sections["7"].findings) == 1
        assert len(sections["8"].findings) == 1


# ---------------------------------------------------------------------------
# 5. _compute_compliance_status
# ---------------------------------------------------------------------------

class TestComputeComplianceStatus:
    def test_no_findings_no_aibom_is_not_applicable(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        no_findings_builder._compute_compliance_status(sections)
        # Section 1 (aibom section) without aibom → NOT_APPLICABLE
        assert sections["1"].compliance_status == "NOT_APPLICABLE"

    def test_no_findings_with_aibom_is_compliant(self):
        builder = TechnicalFileBuilder(findings=[], aibom=_sample_aibom())
        sections = builder._initialize_sections()
        builder._compute_compliance_status(sections)
        assert sections["1"].compliance_status == "COMPLIANT"
        assert sections["2c"].compliance_status == "COMPLIANT"

    def test_all_pass_findings_is_compliant(self, all_pass_builder):
        sections = all_pass_builder._initialize_sections()
        all_pass_builder._populate_sections_from_findings(sections)
        all_pass_builder._compute_compliance_status(sections)
        # prompt_injection → 2g, 2h, 5 — all passed
        for sid in ("2g", "2h", "5"):
            assert sections[sid].compliance_status == "COMPLIANT"

    def test_failing_findings_is_non_compliant(self, mixed_builder):
        sections = mixed_builder._initialize_sections()
        mixed_builder._populate_sections_from_findings(sections)
        mixed_builder._compute_compliance_status(sections)
        # prompt_injection failed → sections 2g should be NON_COMPLIANT
        assert sections["2g"].compliance_status == "NON_COMPLIANT"

    def test_mitigated_findings_is_mitigated(self):
        finding = _make_finding(
            "prompt_injection", "fail", "high", finding_status="MITIGATED"
        )
        builder = TechnicalFileBuilder(findings=[finding])
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        builder._compute_compliance_status(sections)
        assert sections["2g"].compliance_status == "MITIGATED"

    def test_testing_section_with_no_findings_is_compliant(self):
        builder = TechnicalFileBuilder(findings=[])
        sections = builder._initialize_sections()
        builder._compute_compliance_status(sections)
        # 2g is a testing section — no findings → COMPLIANT (all passed)
        assert sections["2g"].compliance_status == "COMPLIANT"


# ---------------------------------------------------------------------------
# 6. _generate_narratives
# ---------------------------------------------------------------------------

class TestGenerateNarratives:
    def test_no_findings_uses_template(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        no_findings_builder._generate_narratives(sections)
        # 2g should have the template narrative
        assert len(sections["2g"].narrative) > 0

    def test_all_pass_narrative_mentions_passed(self, all_pass_builder):
        sections = all_pass_builder._initialize_sections()
        all_pass_builder._populate_sections_from_findings(sections)
        all_pass_builder._generate_narratives(sections)
        # prompt_injection → 2g → all passed
        assert "passed" in sections["2g"].narrative.lower()

    def test_failing_narrative_mentions_finding_count(self, mixed_builder):
        sections = mixed_builder._initialize_sections()
        mixed_builder._populate_sections_from_findings(sections)
        mixed_builder._generate_narratives(sections)
        # prompt_injection failed → 2g narrative should mention findings
        assert "finding" in sections["2g"].narrative.lower()

    def test_critical_finding_narrative_mentions_critical(self):
        finding = _make_finding("prompt_injection", "fail", "critical")
        builder = TechnicalFileBuilder(findings=[finding])
        sections = builder._initialize_sections()
        builder._populate_sections_from_findings(sections)
        builder._generate_narratives(sections)
        assert "critical" in sections["2g"].narrative.lower()


# ---------------------------------------------------------------------------
# 7. _create_executive_summary
# ---------------------------------------------------------------------------

class TestCreateExecutiveSummary:
    def test_no_findings_executive_summary(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        no_findings_builder._compute_compliance_status(sections)
        summary = no_findings_builder._create_executive_summary(sections)
        assert summary["total_findings"] == 0
        assert summary["fail_count"] == 0
        assert summary["critical_count"] == 0

    def test_mixed_executive_summary_counts(self, full_builder):
        sections = full_builder._initialize_sections()
        full_builder._populate_sections_from_findings(sections)
        full_builder._compute_compliance_status(sections)
        summary = full_builder._create_executive_summary(sections)
        assert summary["total_findings"] == 5
        assert summary["critical_count"] == 1
        assert summary["high_count"] == 1

    def test_overall_status_compliant_when_no_failures(self, all_pass_builder):
        sections = all_pass_builder._initialize_sections()
        all_pass_builder._populate_sections_from_findings(sections)
        all_pass_builder._compute_compliance_status(sections)
        summary = all_pass_builder._create_executive_summary(sections)
        assert summary["overall_compliance_status"] == "COMPLIANT"

    def test_overall_status_non_compliant_when_failures(self, mixed_builder):
        sections = mixed_builder._initialize_sections()
        mixed_builder._populate_sections_from_findings(sections)
        mixed_builder._compute_compliance_status(sections)
        summary = mixed_builder._create_executive_summary(sections)
        assert summary["overall_compliance_status"] == "NON_COMPLIANT"

    def test_has_attestation_flag(self, full_builder):
        sections = full_builder._initialize_sections()
        summary = full_builder._create_executive_summary(sections)
        assert summary["has_attestation"] is True

    def test_has_aibom_flag(self, full_builder):
        sections = full_builder._initialize_sections()
        summary = full_builder._create_executive_summary(sections)
        assert summary["has_aibom"] is True

    def test_has_differential_flag(self, full_builder):
        sections = full_builder._initialize_sections()
        summary = full_builder._create_executive_summary(sections)
        assert summary["has_differential"] is True

    def test_regulation_string_present(self, no_findings_builder):
        sections = no_findings_builder._initialize_sections()
        summary = no_findings_builder._create_executive_summary(sections)
        assert "EU AI Act" in summary["regulation"]


# ---------------------------------------------------------------------------
# 8. build() end-to-end
# ---------------------------------------------------------------------------

class TestBuildEndToEnd:
    def test_build_returns_technical_file(self, full_builder):
        tf = full_builder.build()
        assert isinstance(tf, TechnicalFile)

    def test_build_populates_sections(self, full_builder):
        tf = full_builder.build()
        assert len(tf.sections) == len(ANNEX_IV_SECTIONS)

    def test_build_timestamp_set(self, full_builder):
        tf = full_builder.build()
        assert tf.timestamp
        assert "2026" in tf.timestamp or "202" in tf.timestamp

    def test_build_executive_summary_complete(self, full_builder):
        tf = full_builder.build()
        required_keys = {
            "model_name", "total_findings", "fail_count",
            "critical_count", "overall_compliance_status",
            "has_attestation", "has_aibom", "has_differential",
        }
        assert required_keys.issubset(tf.executive_summary.keys())

    def test_build_model_metadata_from_aibom(self, full_builder):
        tf = full_builder.build()
        assert tf.model_metadata["name"] == "test-model"
        assert tf.model_metadata["version"] == "1.0"

    def test_build_no_findings_all_sections_present(self, no_findings_builder):
        tf = no_findings_builder.build()
        assert "2g" in tf.sections
        assert "5" in tf.sections
        assert "9" in tf.sections

    def test_build_attestation_preserved(self, full_builder):
        tf = full_builder.build()
        assert tf.attestation is not None
        assert tf.attestation["attestation_id"] == "att-001"

    def test_build_differential_preserved(self, full_builder):
        tf = full_builder.build()
        assert tf.differential_report is not None
        assert tf.differential_report["run_id"] == "diff-001"

    def test_build_to_dict_round_trip(self, full_builder):
        tf = full_builder.build()
        d = tf.to_dict()
        assert isinstance(d, dict)
        assert "sections" in d
        # All sections should be serialisable
        for sid, section in d["sections"].items():
            assert "section_id" in section
            assert "compliance_status" in section

    def test_build_100_findings_performance(self):
        """Builder should handle 100 findings without error."""
        findings = [
            _make_finding("prompt_injection", "fail", "high")
            for _ in range(50)
        ] + [
            _make_passing_finding("bias_fairness")
            for _ in range(50)
        ]
        # Give unique attack_names to avoid deduplication
        for i, f in enumerate(findings):
            f["attack_name"] = f"attack_{i}"
        builder = TechnicalFileBuilder(findings=findings, aibom=_sample_aibom())
        tf = builder.build()
        assert tf.executive_summary["total_findings"] == 100
