"""Tests for the fuzzy matching engine.

Tests cover:
- MatchConfig validation
- Text normalization (synonyms, stopwords, accents)
- Ranker scoring (exact, fuzzy, no match, attribute penalties)
- MatchingEngine end-to-end (match, thresholds, field mappings, empty input)
- MatchResult model
"""

import pytest

from entr_adapter_core.utils.matching import (
    MatchConfig,
    MatchingEngine,
    MatchResult,
    normalize_text,
)
from entr_adapter_core.utils.matching.ranker import (
    find_best_match,
    _weighted_score,
    _extract_colours,
    _extract_dimensions,
    _extract_numeric_patterns,
)


# ── Sample data ─────────────────────────────────────────────────────────

SAMPLE_ENTITIES = [
    {"code": "A001", "name": "Blue bubble wrap 50x60"},
    {"code": "A002", "name": "Brown cardboard box 40x30"},
    {"code": "A003", "name": "Transparent stretch film 50cm"},
    {"code": "A004", "name": "Black tape 50mm"},
    {"code": "A005", "name": "Red bubble wrap 100x80"},
]

DEFAULT_CONFIG = MatchConfig(
    id_field="code",
    description_field="name",
    score_threshold=90.0,
    high_confidence_threshold=95.0,
    low_confidence_threshold=75.0,
)


# ── MatchConfig tests ───────────────────────────────────────────────────


class TestMatchConfig:
    """Validate the MatchConfig Pydantic model."""

    def test_defaults(self):
        config = MatchConfig()
        assert config.id_field == "id"
        assert config.description_field == "description"
        assert config.score_threshold == 90.0
        assert config.min_tokens_to_match == 2
        assert config.max_tokens == 9

    def test_custom_fields(self):
        config = MatchConfig(id_field="ItemCode", description_field="Description")
        assert config.id_field == "ItemCode"
        assert config.description_field == "Description"

    def test_scorer_weights_default(self):
        config = MatchConfig()
        assert "wratio" in config.scorer_weights
        assert abs(sum(config.scorer_weights.values()) - 1.0) < 0.01

    def test_score_threshold_bounds(self):
        with pytest.raises(Exception):
            MatchConfig(score_threshold=101.0)
        with pytest.raises(Exception):
            MatchConfig(score_threshold=-1.0)

    def test_synonym_and_stopwords(self):
        config = MatchConfig(
            synonym_map={"bubble": "bubbel"},
            stopwords={"de", "het"},
        )
        assert config.synonym_map["bubble"] == "bubbel"
        assert "de" in config.stopwords


# ── Normalizer tests ────────────────────────────────────────────────────


class TestNormalizer:
    """Test text normalization."""

    def test_basic_normalization(self):
        result = normalize_text("Hello World")
        assert result == "hello world"

    def test_synonym_replacement(self):
        result = normalize_text("bubble wrap", {"bubble": "bubbel"})
        assert "bubbel" in result

    def test_stopword_removal(self):
        result = normalize_text("the big blue box", stopwords={"the"})
        assert "the" not in result.split()

    def test_accent_removal(self):
        result = normalize_text("café résumé")
        assert "cafe" in result
        assert "resume" in result

    def test_token_sorting(self):
        r1 = normalize_text("blue tape 50mm")
        r2 = normalize_text("50mm tape blue")
        assert r1 == r2

    def test_non_string_input(self):
        assert normalize_text(None) == ""
        assert normalize_text(123) == ""

    def test_empty_string(self):
        assert normalize_text("") == ""

    def test_punctuation_removal(self):
        result = normalize_text("hello, world! (test)")
        assert "," not in result
        assert "!" not in result


# ── Ranker tests ────────────────────────────────────────────────────────


class TestRanker:
    """Test the fuzzy ranker."""

    def test_exact_match_high_score(self):
        result = find_best_match(
            normalized_query="blue bubble wrap",
            choices=["Blue bubble wrap", "Red tape"],
            normalized_choices=["blue bubble wrap", "red tape"],
        )
        assert result is not None
        idx, score = result
        assert idx == 0
        assert score > 95.0

    def test_fuzzy_match(self):
        result = find_best_match(
            normalized_query="bubbel folie blauw",
            choices=["Blue bubble wrap", "Brown cardboard"],
            normalized_choices=["blue bubble wrap", "brown cardboard"],
        )
        assert result is not None
        idx, score = result
        # Should pick the bubble wrap as closer match
        assert idx == 0

    def test_no_choices_returns_none(self):
        result = find_best_match(
            normalized_query="test",
            choices=[],
            normalized_choices=[],
        )
        assert result is None

    def test_empty_query_returns_none(self):
        result = find_best_match(
            normalized_query="",
            choices=["test"],
            normalized_choices=["test"],
        )
        assert result is None

    def test_weighted_score_identical(self):
        score = _weighted_score("blue tape", "blue tape", {
            "wratio": 0.30, "token_sort": 0.25, "token_set": 0.35, "partial": 0.10,
        })
        assert score == 100.0

    def test_weighted_score_empty(self):
        score = _weighted_score("", "something", {})
        assert score == 0.0

    def test_colour_extraction(self):
        colours = _extract_colours("Blue tape with red edges")
        assert "blue" in colours
        assert "red" in colours

    def test_dimension_extraction(self):
        dims = _extract_dimensions("Box 40x30x20 cm")
        assert "40x30x20" in dims

    def test_numeric_pattern_extraction(self):
        patterns = _extract_numeric_patterns("Article 12345 from 2024")
        assert "12345" in patterns
        # Year 2024 should be filtered out
        assert "2024" not in patterns


# ── MatchingEngine end-to-end tests ─────────────────────────────────────


class TestMatchingEngine:
    """Test the full matching pipeline."""

    def test_exact_match(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("Blue bubble wrap 50x60", SAMPLE_ENTITIES)
        assert result is not None
        assert result.entity_id == "A001"
        assert result.score > 90.0
        assert result.confidence in ("very_high", "high")

    def test_fuzzy_match(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("bubble wrap blue", SAMPLE_ENTITIES)
        assert result is not None
        assert result.entity_id == "A001"
        assert result.score > 50.0

    def test_no_match_empty_entities(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("something", [])
        assert result is None

    def test_no_match_empty_query(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("", SAMPLE_ENTITIES)
        assert result is None

    def test_single_token_query(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("tape", SAMPLE_ENTITIES)
        # Should find A004 (Black tape 50mm) as closest
        assert result is not None
        assert result.entity_id == "A004"

    def test_custom_thresholds(self):
        config = MatchConfig(
            id_field="code",
            description_field="name",
            score_threshold=99.0,
            high_confidence_threshold=99.5,
            low_confidence_threshold=98.0,
        )
        engine = MatchingEngine(config)
        result = engine.match("bubble wrap", SAMPLE_ENTITIES)
        # With extreme thresholds, even good matches are "low" confidence
        assert result is not None
        assert result.confidence in ("low", "medium")

    def test_custom_field_names(self):
        entities = [
            {"ItemCode": "X1", "Description": "Widget A"},
            {"ItemCode": "X2", "Description": "Widget B"},
        ]
        config = MatchConfig(id_field="ItemCode", description_field="Description")
        engine = MatchingEngine(config)
        result = engine.match("Widget A", entities)
        assert result is not None
        assert result.entity_id == "X1"

    def test_with_synonyms(self):
        config = MatchConfig(
            id_field="code",
            description_field="name",
            synonym_map={"bubble": "bubbel", "wrap": "folie"},
        )
        engine = MatchingEngine(config)
        result = engine.match("bubbel folie", SAMPLE_ENTITIES)
        assert result is not None
        # Should still find bubble wrap entries

    def test_include_candidates(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("bubble wrap", SAMPLE_ENTITIES, include_candidates=True)
        assert result is not None
        assert len(result.all_candidates) > 0

    def test_exclude_candidates_by_default(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("bubble wrap", SAMPLE_ENTITIES)
        assert result is not None
        assert len(result.all_candidates) == 0

    def test_candidate_count(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        result = engine.match("bubble wrap", SAMPLE_ENTITIES)
        assert result is not None
        assert result.candidate_count > 0

    def test_confidence_levels(self):
        engine = MatchingEngine(DEFAULT_CONFIG)
        # Very high confidence (exact match)
        result = engine.match("Blue bubble wrap 50x60", SAMPLE_ENTITIES)
        assert result is not None
        assert result.confidence in ("very_high", "high")


# ── MatchResult tests ───────────────────────────────────────────────────


class TestMatchResult:
    """Test the MatchResult Pydantic model."""

    def test_valid_result(self):
        result = MatchResult(
            entity_id="A001",
            score=95.0,
            confidence="very_high",
            matched_entity={"code": "A001", "name": "Test"},
            candidate_count=5,
        )
        assert result.entity_id == "A001"
        assert result.score == 95.0

    def test_score_bounds(self):
        with pytest.raises(Exception):
            MatchResult(
                entity_id="X",
                score=101.0,
                confidence="high",
                matched_entity={},
                candidate_count=1,
            )

    def test_all_candidates_default_empty(self):
        result = MatchResult(
            entity_id="A001",
            score=80.0,
            confidence="medium",
            matched_entity={"code": "A001"},
            candidate_count=1,
        )
        assert result.all_candidates == []
