"""Tests for Pattern Matcher — Phase 5 of Multi-Layer Control Architecture.

Validates:
- Pattern loading from YAML files
- Keyword index construction
- Keyword matching (Tier 1)
- Embedding matching (Tier 2) with domain bonus
- Score combination logic
- Domain affinity mapping
- Top-N matching
- PatternMatchResult serialization
- Edge cases: empty inputs, no matches, missing data
"""

from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from compliance.services.pattern_matcher import (
    DOMAIN_BONUS,
    EMBEDDING_PATTERN_THRESHOLD,
    KEYWORD_MATCH_MIN_HITS,
    ControlPattern,
    PatternMatchResult,
    PatternMatcher,
    _REGULATION_DOMAIN_AFFINITY,
    _find_patterns_dir,
)

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
PATTERNS_DIR = REPO_ROOT / "ai-compliance-sdk" / "policies" / "control_patterns"


# =============================================================================
# Tests: _find_patterns_dir
# =============================================================================


class TestFindPatternsDir:
    """Tests for locating the control_patterns directory."""

    def test_finds_patterns_dir(self):
        result = _find_patterns_dir()
        if result is not None:
            assert result.is_dir()

    def test_patterns_dir_exists_in_repo(self):
        assert PATTERNS_DIR.exists(), f"Patterns dir not found at {PATTERNS_DIR}"


# =============================================================================
# Tests: ControlPattern
# =============================================================================


class TestControlPattern:
    """Tests for the ControlPattern dataclass."""

    def test_defaults(self):
        p = ControlPattern(
            id="CP-TEST-001",
            name="test_pattern",
            name_de="Test-Muster",
            domain="SEC",
            category="testing",
            description="A test pattern",
            objective_template="Test objective",
            rationale_template="Test rationale",
        )
        assert p.id == "CP-TEST-001"
        assert p.severity_default == "medium"
        assert p.implementation_effort_default == "m"
        assert p.obligation_match_keywords == []
        assert p.tags == []
        assert p.composable_with == []

    def test_full_pattern(self):
        p = ControlPattern(
            id="CP-AUTH-001",
            name="password_policy",
            name_de="Passwortrichtlinie",
            domain="AUTH",
            category="authentication",
            description="Password requirements",
            objective_template="Ensure strong passwords",
            rationale_template="Weak passwords are risky",
            obligation_match_keywords=["passwort", "password", "credential"],
            tags=["authentication", "password"],
            composable_with=["CP-AUTH-002"],
        )
        assert len(p.obligation_match_keywords) == 3
        assert "CP-AUTH-002" in p.composable_with


# =============================================================================
# Tests: PatternMatchResult
# =============================================================================


class TestPatternMatchResult:
    """Tests for the PatternMatchResult dataclass."""

    def test_defaults(self):
        result = PatternMatchResult()
        assert result.pattern is None
        assert result.pattern_id is None
        assert result.method == "none"
        assert result.confidence == 0.0
        assert result.keyword_hits == 0
        assert result.embedding_score == 0.0
        assert result.composable_patterns == []

    def test_to_dict(self):
        result = PatternMatchResult(
            pattern_id="CP-AUTH-001",
            method="keyword",
            confidence=0.857,
            keyword_hits=6,
            total_keywords=7,
            embedding_score=0.823,
            domain_bonus_applied=True,
            composable_patterns=["CP-AUTH-002"],
        )
        d = result.to_dict()
        assert d["pattern_id"] == "CP-AUTH-001"
        assert d["method"] == "keyword"
        assert d["confidence"] == 0.857
        assert d["keyword_hits"] == 6
        assert d["total_keywords"] == 7
        assert d["embedding_score"] == 0.823
        assert d["domain_bonus_applied"] is True
        assert d["composable_patterns"] == ["CP-AUTH-002"]

    def test_to_dict_keys(self):
        result = PatternMatchResult()
        d = result.to_dict()
        expected_keys = {
            "pattern_id", "method", "confidence", "keyword_hits",
            "total_keywords", "embedding_score", "domain_bonus_applied",
            "composable_patterns",
        }
        assert set(d.keys()) == expected_keys


# =============================================================================
# Tests: PatternMatcher — Loading
# =============================================================================


class TestPatternMatcherLoad:
    """Tests for loading patterns from YAML."""

    def test_load_patterns(self):
        matcher = PatternMatcher()
        matcher._load_patterns()
        assert len(matcher._patterns) == 50

    def test_by_id_populated(self):
        matcher = PatternMatcher()
        matcher._load_patterns()
        assert "CP-AUTH-001" in matcher._by_id
        assert "CP-CRYP-001" in matcher._by_id

    def test_by_domain_populated(self):
        matcher = PatternMatcher()
        matcher._load_patterns()
        assert "AUTH" in matcher._by_domain
        assert "DATA" in matcher._by_domain
        assert len(matcher._by_domain["AUTH"]) >= 3

    def test_pattern_fields_valid(self):
        """Every loaded pattern should have all required fields."""
        matcher = PatternMatcher()
        matcher._load_patterns()
        for p in matcher._patterns:
            assert p.id, "Empty pattern ID"
            assert p.name, f"{p.id}: empty name"
            assert p.name_de, f"{p.id}: empty name_de"
            assert p.domain, f"{p.id}: empty domain"
            assert p.category, f"{p.id}: empty category"
            assert p.description, f"{p.id}: empty description"
            assert p.objective_template, f"{p.id}: empty objective_template"
            assert len(p.obligation_match_keywords) >= 3, (
                f"{p.id}: only {len(p.obligation_match_keywords)} keywords"
            )

    def test_no_duplicate_ids(self):
        matcher = PatternMatcher()
        matcher._load_patterns()
        ids = [p.id for p in matcher._patterns]
        assert len(ids) == len(set(ids))


# =============================================================================
# Tests: PatternMatcher — Keyword Index
# =============================================================================


class TestKeywordIndex:
    """Tests for the reverse keyword index."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()

    def test_keyword_index_populated(self):
        assert len(self.matcher._keyword_index) > 50

    def test_keyword_maps_to_patterns(self):
        """'passwort' should map to CP-AUTH-001."""
        assert "passwort" in self.matcher._keyword_index
        assert "CP-AUTH-001" in self.matcher._keyword_index["passwort"]

    def test_keyword_lowercase(self):
        """All keywords in the index should be lowercase."""
        for kw in self.matcher._keyword_index:
            assert kw == kw.lower(), f"Keyword not lowercase: {kw}"

    def test_keyword_shared_across_patterns(self):
        """Some keywords like 'verschluesselung' may appear in multiple patterns."""
        # This just verifies the structure allows multi-pattern keywords
        for kw, pattern_ids in self.matcher._keyword_index.items():
            assert len(pattern_ids) >= 1


# =============================================================================
# Tests: PatternMatcher — Tier 1 (Keyword Match)
# =============================================================================


class TestTier1KeywordMatch:
    """Tests for keyword-based pattern matching."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()

    def test_password_text_matches_auth(self):
        """Text about passwords should match CP-AUTH-001."""
        result = self.matcher._tier1_keyword(
            "Die Passwortrichtlinie muss sicherstellen dass Anmeldedaten "
            "und Credentials geschuetzt sind und authentifizierung robust ist",
            None,
        )
        assert result is not None
        assert result.pattern_id == "CP-AUTH-001"
        assert result.method == "keyword"
        assert result.keyword_hits >= KEYWORD_MATCH_MIN_HITS

    def test_encryption_text_matches_cryp(self):
        """Text about encryption should match CP-CRYP-001."""
        result = self.matcher._tier1_keyword(
            "Verschluesselung ruhender Daten muss mit AES-256 encryption erfolgen",
            None,
        )
        assert result is not None
        assert result.pattern_id == "CP-CRYP-001"
        assert result.keyword_hits >= KEYWORD_MATCH_MIN_HITS

    def test_incident_text_matches_inc(self):
        result = self.matcher._tier1_keyword(
            "Ein Vorfall-Reaktionsplan muss fuer Sicherheitsvorfaelle "
            "und incident response bereitstehen",
            None,
        )
        assert result is not None
        assert "INC" in result.pattern_id

    def test_no_match_for_unrelated_text(self):
        result = self.matcher._tier1_keyword(
            "xyzzy foobar completely unrelated text with no keywords",
            None,
        )
        assert result is None

    def test_single_keyword_below_threshold(self):
        """A single keyword hit should not be enough."""
        result = self.matcher._tier1_keyword("passwort", None)
        assert result is None  # Only 1 hit < KEYWORD_MATCH_MIN_HITS (2)

    def test_domain_bonus_applied(self):
        """Domain bonus should be added when regulation matches."""
        result_without = self.matcher._tier1_keyword(
            "Personenbezogene Daten muessen durch Datenschutz Massnahmen "
            "und datensicherheit geschuetzt werden mit datenminimierung",
            None,
        )
        result_with = self.matcher._tier1_keyword(
            "Personenbezogene Daten muessen durch Datenschutz Massnahmen "
            "und datensicherheit geschuetzt werden mit datenminimierung",
            "dsgvo",
        )
        if result_without and result_with:
            # With DSGVO regulation, DATA domain patterns should get a bonus
            if result_with.domain_bonus_applied:
                assert result_with.confidence >= result_without.confidence

    def test_keyword_scores_returns_dict(self):
        scores = self.matcher._keyword_scores(
            "Passwort authentifizierung credential zugang",
            None,
        )
        assert isinstance(scores, dict)
        assert "CP-AUTH-001" in scores
        hits, total, confidence = scores["CP-AUTH-001"]
        assert hits >= 3
        assert total > 0
        assert 0 < confidence <= 1.0


# =============================================================================
# Tests: PatternMatcher — Tier 2 (Embedding Match)
# =============================================================================


class TestTier2EmbeddingMatch:
    """Tests for embedding-based pattern matching."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()
        # Set up fake embeddings
        self.matcher._pattern_ids = [p.id for p in self.matcher._patterns]
        self.matcher._pattern_embeddings = []
        for i in range(len(self.matcher._patterns)):
            self.matcher._pattern_embeddings.append(
                [float(i % 10 + 1), float((i * 3) % 10 + 1), float((i * 7) % 10 + 1)]
            )

    @pytest.mark.asyncio
    async def test_embedding_match_identical_vector(self):
        """Identical vector should produce cosine = 1.0 > threshold."""
        target = self.matcher._pattern_embeddings[0]
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=target,
        ):
            result = await self.matcher._tier2_embedding("test text", None)

        assert result is not None
        assert result.method == "embedding"
        assert result.confidence >= EMBEDDING_PATTERN_THRESHOLD

    @pytest.mark.asyncio
    async def test_embedding_match_empty(self):
        """Empty embeddings should return None."""
        self.matcher._pattern_embeddings = []
        result = await self.matcher._tier2_embedding("test text", None)
        assert result is None

    @pytest.mark.asyncio
    async def test_embedding_match_failed_service(self):
        """Failed embedding service should return None."""
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await self.matcher._tier2_embedding("test", None)
        assert result is None

    @pytest.mark.asyncio
    async def test_embedding_domain_bonus(self):
        """Domain bonus should increase score for affine regulation."""
        # Set all patterns to same embedding
        for i in range(len(self.matcher._pattern_embeddings)):
            self.matcher._pattern_embeddings[i] = [1.0, 0.0, 0.0]

        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[1.0, 0.0, 0.0],
        ):
            scores = await self.matcher._embedding_scores("test", "dsgvo")

        # DATA domain patterns should have bonus applied
        data_patterns = [p.id for p in self.matcher._patterns if p.domain == "DATA"]
        if data_patterns:
            pid = data_patterns[0]
            score, bonus = scores.get(pid, (0, False))
            assert bonus is True
            assert score > 1.0  # 1.0 cosine + 0.10 bonus


# =============================================================================
# Tests: PatternMatcher — Score Combination
# =============================================================================


class TestScoreCombination:
    """Tests for combining keyword and embedding results."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.pattern = ControlPattern(
            id="CP-TEST-001", name="test", name_de="Test",
            domain="SEC", category="test", description="d",
            objective_template="o", rationale_template="r",
        )

    def test_both_none(self):
        result = self.matcher._combine_results(None, None)
        assert result.method == "none"
        assert result.confidence == 0.0

    def test_only_keyword(self):
        kw = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="keyword", confidence=0.7, keyword_hits=5,
        )
        result = self.matcher._combine_results(kw, None)
        assert result.method == "keyword"
        assert result.confidence == 0.7

    def test_only_embedding(self):
        emb = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="embedding", confidence=0.85, embedding_score=0.85,
        )
        result = self.matcher._combine_results(None, emb)
        assert result.method == "embedding"
        assert result.confidence == 0.85

    def test_same_pattern_combined(self):
        """When both tiers agree, confidence gets +0.05 boost."""
        kw = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="keyword", confidence=0.7, keyword_hits=5, total_keywords=7,
        )
        emb = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="embedding", confidence=0.8, embedding_score=0.8,
        )
        result = self.matcher._combine_results(kw, emb)
        assert result.method == "combined"
        assert abs(result.confidence - 0.85) < 1e-9  # max(0.7, 0.8) + 0.05
        assert result.keyword_hits == 5
        assert result.embedding_score == 0.8

    def test_same_pattern_combined_capped(self):
        """Combined confidence should not exceed 1.0."""
        kw = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="keyword", confidence=0.95,
        )
        emb = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="embedding", confidence=0.98, embedding_score=0.98,
        )
        result = self.matcher._combine_results(kw, emb)
        assert result.confidence <= 1.0

    def test_different_patterns_picks_higher(self):
        """When tiers disagree, pick the higher confidence."""
        p2 = ControlPattern(
            id="CP-TEST-002", name="test2", name_de="Test2",
            domain="SEC", category="test", description="d",
            objective_template="o", rationale_template="r",
        )
        kw = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="keyword", confidence=0.6,
        )
        emb = PatternMatchResult(
            pattern=p2, pattern_id="CP-TEST-002",
            method="embedding", confidence=0.9, embedding_score=0.9,
        )
        result = self.matcher._combine_results(kw, emb)
        assert result.pattern_id == "CP-TEST-002"
        assert result.confidence == 0.9

    def test_different_patterns_keyword_wins(self):
        p2 = ControlPattern(
            id="CP-TEST-002", name="test2", name_de="Test2",
            domain="SEC", category="test", description="d",
            objective_template="o", rationale_template="r",
        )
        kw = PatternMatchResult(
            pattern=self.pattern, pattern_id="CP-TEST-001",
            method="keyword", confidence=0.9,
        )
        emb = PatternMatchResult(
            pattern=p2, pattern_id="CP-TEST-002",
            method="embedding", confidence=0.6, embedding_score=0.6,
        )
        result = self.matcher._combine_results(kw, emb)
        assert result.pattern_id == "CP-TEST-001"


# =============================================================================
# Tests: PatternMatcher — Domain Affinity
# =============================================================================


class TestDomainAffinity:
    """Tests for regulation-to-domain affinity mapping."""

    def test_dsgvo_affine_with_data(self):
        assert PatternMatcher._domain_matches("DATA", "dsgvo")

    def test_dsgvo_affine_with_comp(self):
        assert PatternMatcher._domain_matches("COMP", "dsgvo")

    def test_ai_act_affine_with_ai(self):
        assert PatternMatcher._domain_matches("AI", "ai_act")

    def test_nis2_affine_with_sec(self):
        assert PatternMatcher._domain_matches("SEC", "nis2")

    def test_nis2_affine_with_inc(self):
        assert PatternMatcher._domain_matches("INC", "nis2")

    def test_dora_affine_with_fin(self):
        assert PatternMatcher._domain_matches("FIN", "dora")

    def test_no_affinity_auth_dsgvo(self):
        """AUTH is not in DSGVO's affinity list."""
        assert not PatternMatcher._domain_matches("AUTH", "dsgvo")

    def test_unknown_regulation(self):
        assert not PatternMatcher._domain_matches("DATA", "unknown_reg")

    def test_all_regulations_have_affinity(self):
        """All 9 regulations should have at least one affine domain."""
        expected_regs = [
            "dsgvo", "bdsg", "ttdsg", "ai_act", "nis2",
            "dsa", "data_act", "eu_machinery", "dora",
        ]
        for reg in expected_regs:
            assert reg in _REGULATION_DOMAIN_AFFINITY, f"{reg} missing from affinity map"
            assert len(_REGULATION_DOMAIN_AFFINITY[reg]) >= 1


# =============================================================================
# Tests: PatternMatcher — Full match()
# =============================================================================


class TestMatchFull:
    """Tests for the full match() method."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()
        self.matcher._initialized = True
        # Empty embeddings — Tier 2 returns None
        self.matcher._pattern_embeddings = []
        self.matcher._pattern_ids = []

    @pytest.mark.asyncio
    async def test_match_password_text(self):
        """Password text should match CP-AUTH-001 via keywords."""
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await self.matcher.match(
                obligation_text=(
                    "Passwortrichtlinie muss sicherstellen dass Anmeldedaten "
                    "und credential geschuetzt sind und authentifizierung robust ist"
                ),
                regulation_id="nis2",
            )
        assert result.pattern_id == "CP-AUTH-001"
        assert result.confidence > 0

    @pytest.mark.asyncio
    async def test_match_encryption_text(self):
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await self.matcher.match(
                obligation_text=(
                    "Verschluesselung ruhender Daten muss mit AES-256 encryption "
                    "und schluesselmanagement kryptographie erfolgen"
                ),
            )
        assert result.pattern_id == "CP-CRYP-001"

    @pytest.mark.asyncio
    async def test_match_empty_text(self):
        result = await self.matcher.match(obligation_text="")
        assert result.method == "none"
        assert result.confidence == 0.0

    @pytest.mark.asyncio
    async def test_match_no_patterns(self):
        """When no patterns loaded, should return empty result."""
        matcher = PatternMatcher()
        matcher._initialized = True
        result = await matcher.match(obligation_text="test")
        assert result.method == "none"

    @pytest.mark.asyncio
    async def test_match_composable_patterns(self):
        """Result should include composable_with references."""
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await self.matcher.match(
                obligation_text=(
                    "Passwortrichtlinie muss sicherstellen dass Anmeldedaten "
                    "und credential geschuetzt sind und authentifizierung robust ist"
                ),
            )
        if result.pattern and result.pattern.composable_with:
            assert len(result.composable_patterns) >= 1

    @pytest.mark.asyncio
    async def test_match_with_domain_bonus(self):
        """DSGVO obligation with DATA keywords should get domain bonus."""
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            result = await self.matcher.match(
                obligation_text=(
                    "Personenbezogene Daten muessen durch Datenschutz und "
                    "datensicherheit geschuetzt werden mit datenminimierung "
                    "und speicherbegrenzung und loeschung"
                ),
                regulation_id="dsgvo",
            )
        # Should match a DATA-domain pattern
        if result.pattern and result.pattern.domain == "DATA":
            assert result.domain_bonus_applied is True


# =============================================================================
# Tests: PatternMatcher — match_top_n()
# =============================================================================


class TestMatchTopN:
    """Tests for top-N matching."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()
        self.matcher._initialized = True
        self.matcher._pattern_embeddings = []
        self.matcher._pattern_ids = []

    @pytest.mark.asyncio
    async def test_top_n_returns_list(self):
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            results = await self.matcher.match_top_n(
                obligation_text=(
                    "Passwortrichtlinie muss sicherstellen dass Anmeldedaten "
                    "und credential geschuetzt sind und authentifizierung robust ist"
                ),
                n=3,
            )
        assert isinstance(results, list)
        assert len(results) >= 1

    @pytest.mark.asyncio
    async def test_top_n_sorted_by_confidence(self):
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            results = await self.matcher.match_top_n(
                obligation_text=(
                    "Verschluesselung und kryptographie und schluesselmanagement "
                    "und authentifizierung und password und zugriffskontrolle"
                ),
                n=5,
            )
        if len(results) >= 2:
            for i in range(len(results) - 1):
                assert results[i].confidence >= results[i + 1].confidence

    @pytest.mark.asyncio
    async def test_top_n_empty_text(self):
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            results = await self.matcher.match_top_n(obligation_text="", n=3)
        assert results == []

    @pytest.mark.asyncio
    async def test_top_n_respects_limit(self):
        with patch(
            "compliance.services.pattern_matcher._get_embedding",
            new_callable=AsyncMock,
            return_value=[],
        ):
            results = await self.matcher.match_top_n(
                obligation_text=(
                    "Verschluesselung und kryptographie und schluesselmanagement "
                    "und authentifizierung und password und zugriffskontrolle"
                ),
                n=2,
            )
        assert len(results) <= 2


# =============================================================================
# Tests: PatternMatcher — Public Helpers
# =============================================================================


class TestPublicHelpers:
    """Tests for get_pattern, get_patterns_by_domain, stats."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()

    def test_get_pattern_existing(self):
        p = self.matcher.get_pattern("CP-AUTH-001")
        assert p is not None
        assert p.id == "CP-AUTH-001"

    def test_get_pattern_case_insensitive(self):
        p = self.matcher.get_pattern("cp-auth-001")
        assert p is not None

    def test_get_pattern_nonexistent(self):
        p = self.matcher.get_pattern("CP-FAKE-999")
        assert p is None

    def test_get_patterns_by_domain(self):
        patterns = self.matcher.get_patterns_by_domain("AUTH")
        assert len(patterns) >= 3

    def test_get_patterns_by_domain_case_insensitive(self):
        patterns = self.matcher.get_patterns_by_domain("auth")
        assert len(patterns) >= 3

    def test_get_patterns_by_domain_unknown(self):
        patterns = self.matcher.get_patterns_by_domain("NOPE")
        assert patterns == []

    def test_stats(self):
        stats = self.matcher.stats()
        assert stats["total_patterns"] == 50
        assert len(stats["domains"]) >= 5
        assert stats["keywords"] > 50
        assert stats["initialized"] is False


# =============================================================================
# Tests: PatternMatcher — auto initialize
# =============================================================================


class TestAutoInitialize:
    """Tests for auto-initialization on first match call."""

    @pytest.mark.asyncio
    async def test_auto_init_on_match(self):
        matcher = PatternMatcher()
        assert not matcher._initialized

        with patch.object(
            matcher, "initialize", new_callable=AsyncMock
        ) as mock_init:
            async def side_effect():
                matcher._initialized = True
                matcher._load_patterns()
                matcher._build_keyword_index()
                matcher._pattern_embeddings = []
                matcher._pattern_ids = []

            mock_init.side_effect = side_effect

            with patch(
                "compliance.services.pattern_matcher._get_embedding",
                new_callable=AsyncMock,
                return_value=[],
            ):
                await matcher.match(obligation_text="test text")

            mock_init.assert_called_once()

    @pytest.mark.asyncio
    async def test_no_double_init(self):
        matcher = PatternMatcher()
        matcher._initialized = True
        matcher._patterns = []

        with patch.object(
            matcher, "initialize", new_callable=AsyncMock
        ) as mock_init:
            await matcher.match(obligation_text="test text")
            mock_init.assert_not_called()


# =============================================================================
# Tests: Constants
# =============================================================================


class TestConstants:
    """Tests for module-level constants."""

    def test_keyword_min_hits(self):
        assert KEYWORD_MATCH_MIN_HITS >= 1

    def test_embedding_threshold_range(self):
        assert 0 < EMBEDDING_PATTERN_THRESHOLD <= 1.0

    def test_domain_bonus_range(self):
        assert 0 < DOMAIN_BONUS <= 0.20

    def test_domain_bonus_is_010(self):
        assert DOMAIN_BONUS == 0.10

    def test_embedding_threshold_is_075(self):
        assert EMBEDDING_PATTERN_THRESHOLD == 0.75


# =============================================================================
# Tests: Integration — Real keyword matching scenarios
# =============================================================================


class TestRealKeywordScenarios:
    """Integration tests with realistic obligation texts."""

    def setup_method(self):
        self.matcher = PatternMatcher()
        self.matcher._load_patterns()
        self.matcher._build_keyword_index()

    def test_dsgvo_consent_obligation(self):
        """DSGVO consent obligation should match data protection patterns."""
        scores = self.matcher._keyword_scores(
            "Die Einwilligung der betroffenen Person muss freiwillig und "
            "informiert erfolgen. Eine Verarbeitung personenbezogener Daten "
            "ist nur mit gültiger Einwilligung zulaessig. Datenschutz.",
            "dsgvo",
        )
        # Should have matches in DATA domain patterns
        data_matches = [pid for pid in scores if pid.startswith("CP-DATA")]
        assert len(data_matches) >= 1

    def test_ai_act_risk_assessment(self):
        """AI Act risk assessment should match AI patterns."""
        scores = self.matcher._keyword_scores(
            "KI-Systeme mit hohem Risiko muessen einer Konformitaetsbewertung "
            "unterzogen werden. Transparenz und Erklaerbarkeit sind Pflicht.",
            "ai_act",
        )
        ai_matches = [pid for pid in scores if pid.startswith("CP-AI")]
        assert len(ai_matches) >= 1

    def test_nis2_incident_response(self):
        """NIS2 incident text should match INC patterns."""
        scores = self.matcher._keyword_scores(
            "Sicherheitsvorfaelle muessen innerhalb von 24 Stunden gemeldet "
            "werden. Ein incident response plan und Eskalationsverfahren "
            "sind zu etablieren fuer Vorfall und Wiederherstellung.",
            "nis2",
        )
        inc_matches = [pid for pid in scores if pid.startswith("CP-INC")]
        assert len(inc_matches) >= 1

    def test_audit_logging_obligation(self):
        """Audit logging obligation should match LOG patterns."""
        scores = self.matcher._keyword_scores(
            "Alle sicherheitsrelevanten Ereignisse muessen protokolliert werden. "
            "Audit-Trail und Monitoring der Zugriffe sind Pflicht. "
            "Protokollierung muss manipulationssicher sein.",
            None,
        )
        log_matches = [pid for pid in scores if pid.startswith("CP-LOG")]
        assert len(log_matches) >= 1

    def test_access_control_obligation(self):
        """Access control text should match ACC patterns."""
        scores = self.matcher._keyword_scores(
            "Zugriffskontrolle nach dem Least-Privilege-Prinzip. "
            "Rollenbasierte Autorisierung und Berechtigung fuer alle Systeme.",
            None,
        )
        acc_matches = [pid for pid in scores if pid.startswith("CP-ACC")]
        assert len(acc_matches) >= 1
