"""
Fuzzy Matching Module

Provides fuzzy string matching with OCR-aware similarity scoring.
Handles common OCR errors and format variations in invoice fields.
"""

import re
from typing import Optional
from dataclasses import dataclass

from .text_cleaner import TextCleaner


@dataclass
class FuzzyMatchResult:
    """Result of a fuzzy match operation."""
    matched: bool
    score: float  # 0.0 to 1.0
    ocr_value: str
    expected_value: str
    normalized_ocr: str
    normalized_expected: str
    match_type: str  # 'exact', 'normalized', 'fuzzy', 'ocr_corrected'


class FuzzyMatcher:
    """
    Fuzzy string matcher optimized for OCR text matching.

    Provides multiple matching strategies:
    1. Exact match
    2. Normalized match (case-insensitive, whitespace-normalized)
    3. OCR-corrected match (applying common OCR error corrections)
    4. Edit distance based fuzzy match
    5. Digit-sequence match (for numeric fields)
    """

    # Minimum similarity threshold for fuzzy matches
    DEFAULT_THRESHOLD = 0.85

    # Field-specific thresholds (some fields need stricter matching)
    FIELD_THRESHOLDS = {
        'InvoiceNumber': 0.90,
        'OCR': 0.95,  # OCR numbers need high precision
        'Amount': 0.95,
        'Bankgiro': 0.90,
        'Plusgiro': 0.90,
        'InvoiceDate': 0.90,
        'InvoiceDueDate': 0.90,
        'supplier_organisation_number': 0.85,
        'customer_number': 0.80,  # More lenient for customer numbers
    }

    @classmethod
    def get_threshold(cls, field_name: str) -> float:
        """Get the matching threshold for a specific field."""
        return cls.FIELD_THRESHOLDS.get(field_name, cls.DEFAULT_THRESHOLD)

    @classmethod
    def levenshtein_distance(cls, s1: str, s2: str) -> int:
        """
        Calculate Levenshtein (edit) distance between two strings.

        This is the minimum number of single-character edits
        (insertions, deletions, substitutions) needed to change s1 into s2.
        """
        if len(s1) < len(s2):
            return cls.levenshtein_distance(s2, s1)

        if len(s2) == 0:
            return len(s1)

        previous_row = range(len(s2) + 1)
        for i, c1 in enumerate(s1):
            current_row = [i + 1]
            for j, c2 in enumerate(s2):
                # Cost is 0 if characters match, 1 otherwise
                insertions = previous_row[j + 1] + 1
                deletions = current_row[j] + 1
                substitutions = previous_row[j] + (c1 != c2)
                current_row.append(min(insertions, deletions, substitutions))
            previous_row = current_row

        return previous_row[-1]

    @classmethod
    def similarity_ratio(cls, s1: str, s2: str) -> float:
        """
        Calculate similarity ratio between two strings.

        Returns a value between 0.0 (completely different) and 1.0 (identical).
        Based on Levenshtein distance normalized by the length of the longer string.
        """
        if not s1 and not s2:
            return 1.0
        if not s1 or not s2:
            return 0.0

        max_len = max(len(s1), len(s2))
        distance = cls.levenshtein_distance(s1, s2)
        return 1.0 - (distance / max_len)

    @classmethod
    def ocr_aware_similarity(cls, ocr_text: str, expected: str) -> float:
        """
        Calculate similarity with OCR error awareness.

        This method considers common OCR errors when calculating similarity,
        giving higher scores when differences are likely OCR mistakes.
        """
        if not ocr_text or not expected:
            return 0.0 if ocr_text != expected else 1.0

        # First try exact match
        if ocr_text == expected:
            return 1.0

        # Try with OCR corrections applied to ocr_text
        corrected = TextCleaner.apply_ocr_digit_corrections(ocr_text)
        if corrected == expected:
            return 0.98  # Slightly less than exact match

        # Try normalized comparison
        norm_ocr = TextCleaner.normalize_for_comparison(ocr_text)
        norm_expected = TextCleaner.normalize_for_comparison(expected)
        if norm_ocr == norm_expected:
            return 0.95

        # Calculate base similarity
        base_sim = cls.similarity_ratio(norm_ocr, norm_expected)

        # Boost score if differences are common OCR errors
        boost = cls._calculate_ocr_error_boost(ocr_text, expected)

        return min(1.0, base_sim + boost)

    @classmethod
    def _calculate_ocr_error_boost(cls, ocr_text: str, expected: str) -> float:
        """
        Calculate a score boost based on whether differences are likely OCR errors.

        Returns a value between 0.0 and 0.1.
        """
        if len(ocr_text) != len(expected):
            return 0.0

        ocr_errors = 0
        total_diffs = 0

        for oc, ec in zip(ocr_text, expected):
            if oc != ec:
                total_diffs += 1
                # Check if this is a known OCR confusion pair
                if cls._is_ocr_confusion_pair(oc, ec):
                    ocr_errors += 1

        if total_diffs == 0:
            return 0.0

        # Boost proportional to how many differences are OCR errors
        ocr_error_ratio = ocr_errors / total_diffs
        return ocr_error_ratio * 0.1

    @classmethod
    def _is_ocr_confusion_pair(cls, char1: str, char2: str) -> bool:
        """Check if two characters are commonly confused in OCR."""
        confusion_pairs = {
            ('0', 'O'), ('0', 'o'), ('0', 'D'), ('0', 'Q'),
            ('1', 'l'), ('1', 'I'), ('1', 'i'), ('1', '|'),
            ('2', 'Z'), ('2', 'z'),
            ('5', 'S'), ('5', 's'),
            ('6', 'G'), ('6', 'b'),
            ('8', 'B'),
            ('9', 'g'), ('9', 'q'),
        }

        pair = (char1, char2)
        return pair in confusion_pairs or (char2, char1) in confusion_pairs

    @classmethod
    def match_digits(cls, ocr_text: str, expected: str, threshold: float = 0.90) -> FuzzyMatchResult:
        """
        Match digit sequences with OCR error tolerance.

        Optimized for numeric fields like OCR numbers, amounts, etc.
        """
        # Extract digits
        ocr_digits = TextCleaner.extract_digits(ocr_text, apply_ocr_correction=True)
        expected_digits = TextCleaner.extract_digits(expected, apply_ocr_correction=False)

        # Exact match after extraction
        if ocr_digits == expected_digits:
            return FuzzyMatchResult(
                matched=True,
                score=1.0,
                ocr_value=ocr_text,
                expected_value=expected,
                normalized_ocr=ocr_digits,
                normalized_expected=expected_digits,
                match_type='exact'
            )

        # Calculate similarity
        score = cls.ocr_aware_similarity(ocr_digits, expected_digits)

        return FuzzyMatchResult(
            matched=score >= threshold,
            score=score,
            ocr_value=ocr_text,
            expected_value=expected,
            normalized_ocr=ocr_digits,
            normalized_expected=expected_digits,
            match_type='fuzzy' if score >= threshold else 'no_match'
        )

    @classmethod
    def match_amount(cls, ocr_text: str, expected: str, threshold: float = 0.95) -> FuzzyMatchResult:
        """
        Match monetary amounts with format tolerance.

        Handles different decimal separators (. vs ,) and thousand separators.
        """
        from .validators import FieldValidators

        # Parse both amounts
        ocr_amount = FieldValidators.parse_amount(ocr_text)
        expected_amount = FieldValidators.parse_amount(expected)

        if ocr_amount is None or expected_amount is None:
            # Can't parse, fall back to string matching
            return cls.match_string(ocr_text, expected, threshold)

        # Compare numeric values
        if abs(ocr_amount - expected_amount) < 0.01:  # Within 1 cent
            return FuzzyMatchResult(
                matched=True,
                score=1.0,
                ocr_value=ocr_text,
                expected_value=expected,
                normalized_ocr=f"{ocr_amount:.2f}",
                normalized_expected=f"{expected_amount:.2f}",
                match_type='exact'
            )

        # Calculate relative difference
        max_val = max(abs(ocr_amount), abs(expected_amount))
        if max_val > 0:
            diff_ratio = abs(ocr_amount - expected_amount) / max_val
            score = max(0.0, 1.0 - diff_ratio)
        else:
            score = 1.0 if ocr_amount == expected_amount else 0.0

        return FuzzyMatchResult(
            matched=score >= threshold,
            score=score,
            ocr_value=ocr_text,
            expected_value=expected,
            normalized_ocr=f"{ocr_amount:.2f}" if ocr_amount else ocr_text,
            normalized_expected=f"{expected_amount:.2f}" if expected_amount else expected,
            match_type='fuzzy' if score >= threshold else 'no_match'
        )

    @classmethod
    def match_date(cls, ocr_text: str, expected: str, threshold: float = 0.90) -> FuzzyMatchResult:
        """
        Match dates with format tolerance.

        Handles different date formats (ISO, European, compact, etc.)
        """
        from .validators import FieldValidators

        # Parse both dates to ISO format
        ocr_iso = FieldValidators.format_date_iso(ocr_text)
        expected_iso = FieldValidators.format_date_iso(expected)

        if ocr_iso and expected_iso:
            if ocr_iso == expected_iso:
                return FuzzyMatchResult(
                    matched=True,
                    score=1.0,
                    ocr_value=ocr_text,
                    expected_value=expected,
                    normalized_ocr=ocr_iso,
                    normalized_expected=expected_iso,
                    match_type='exact'
                )

        # Fall back to string matching on digits
        return cls.match_digits(ocr_text, expected, threshold)

    @classmethod
    def match_string(cls, ocr_text: str, expected: str, threshold: float = 0.85) -> FuzzyMatchResult:
        """
        General string matching with multiple strategies.

        Tries exact, normalized, and fuzzy matching in order.
        """
        # Clean both strings
        ocr_clean = TextCleaner.clean_text(ocr_text)
        expected_clean = TextCleaner.clean_text(expected)

        # Strategy 1: Exact match
        if ocr_clean == expected_clean:
            return FuzzyMatchResult(
                matched=True,
                score=1.0,
                ocr_value=ocr_text,
                expected_value=expected,
                normalized_ocr=ocr_clean,
                normalized_expected=expected_clean,
                match_type='exact'
            )

        # Strategy 2: Case-insensitive match
        if ocr_clean.lower() == expected_clean.lower():
            return FuzzyMatchResult(
                matched=True,
                score=0.98,
                ocr_value=ocr_text,
                expected_value=expected,
                normalized_ocr=ocr_clean,
                normalized_expected=expected_clean,
                match_type='normalized'
            )

        # Strategy 3: OCR-corrected match
        ocr_corrected = TextCleaner.apply_ocr_digit_corrections(ocr_clean)
        if ocr_corrected == expected_clean:
            return FuzzyMatchResult(
                matched=True,
                score=0.95,
                ocr_value=ocr_text,
                expected_value=expected,
                normalized_ocr=ocr_corrected,
                normalized_expected=expected_clean,
                match_type='ocr_corrected'
            )

        # Strategy 4: Fuzzy match
        score = cls.ocr_aware_similarity(ocr_clean, expected_clean)

        return FuzzyMatchResult(
            matched=score >= threshold,
            score=score,
            ocr_value=ocr_text,
            expected_value=expected,
            normalized_ocr=ocr_clean,
            normalized_expected=expected_clean,
            match_type='fuzzy' if score >= threshold else 'no_match'
        )

    @classmethod
    def match_field(
        cls,
        field_name: str,
        ocr_value: str,
        expected_value: str,
        threshold: Optional[float] = None
    ) -> FuzzyMatchResult:
        """
        Match a field value using field-appropriate strategy.

        Automatically selects the best matching strategy based on field type.
        """
        if threshold is None:
            threshold = cls.get_threshold(field_name)

        field_lower = field_name.lower()

        # Route to appropriate matcher
        if 'amount' in field_lower or 'belopp' in field_lower:
            return cls.match_amount(ocr_value, expected_value, threshold)

        if 'date' in field_lower or 'datum' in field_lower:
            return cls.match_date(ocr_value, expected_value, threshold)

        if any(x in field_lower for x in ['ocr', 'bankgiro', 'plusgiro', 'org']):
            # Numeric fields with OCR errors
            return cls.match_digits(ocr_value, expected_value, threshold)

        if 'invoice' in field_lower and 'number' in field_lower:
            # Invoice numbers can be alphanumeric
            return cls.match_string(ocr_value, expected_value, threshold)

        # Default to string matching
        return cls.match_string(ocr_value, expected_value, threshold)

    @classmethod
    def find_best_match(
        cls,
        ocr_value: str,
        candidates: list[str],
        field_name: str = '',
        threshold: Optional[float] = None
    ) -> Optional[tuple[str, FuzzyMatchResult]]:
        """
        Find the best matching candidate from a list.

        Returns (matched_value, match_result) or None if no match above threshold.
        """
        if threshold is None:
            threshold = cls.get_threshold(field_name) if field_name else cls.DEFAULT_THRESHOLD

        best_match = None
        best_result = None

        for candidate in candidates:
            result = cls.match_field(field_name, ocr_value, candidate, threshold=0.0)
            if result.score >= threshold:
                if best_result is None or result.score > best_result.score:
                    best_match = candidate
                    best_result = result

        if best_match:
            return (best_match, best_result)
        return None
