"""Core framework for multilingual abbreviation expansion.

This module provides the infrastructure for expanding abbreviations during text normalization. It supports:
- Simple 1:1 mappings (Prof. → Professor)
- Context-aware expansions (St. → Street/Saint based on context)
- Case-insensitive matching
- Word boundary detection
- Optional numeric/context guards for tricky abbreviations (e.g., No., in.)
"""

import re
from abc import ABC, abstractmethod
from collections.abc import Collection, Iterable, Iterator
from dataclasses import dataclass
from enum import Enum
from re import Pattern
from typing import TypeAlias

from ._replacements import Replacement, apply_replacements, resolve_replacements
from .annotations import AnnotationIndex, TokenAnnotation, normalize_annotations
from .units import NUMBER_BEFORE_UNIT, iter_unit_replacements, unit_entries, unit_symbols


class AbbreviationContext(Enum):
    """Context types for disambiguating abbreviations."""

    DEFAULT = "default"  # Use default expansion
    TITLE = "title"  # Title/honorific (Dr. Smith)
    PLACE = "place"  # Place name (Main St.)
    TIME = "time"  # Time-related (3 P.M.)
    ACADEMIC = "academic"  # Academic degree (Ph.D.)
    RELIGIOUS = "religious"  # Religious context (St. Peter)


PosConstraints: TypeAlias = str | Collection[str] | None


@dataclass
class AbbreviationEntry:
    """A single abbreviation with its expansion(s).

    Attributes:
        abbreviation: The abbreviated form (e.g., "Prof.")
        expansion: Default expansion (e.g., "Professor")
        context_expansions: Optional dict of context-specific expansions
        case_sensitive: Whether matching should be case-sensitive
        description: Human-readable description of the abbreviation
        only_if_preceded_by: Optional regex that must match the text immediately
            before the abbreviation match (typically anchored with $).
        only_if_followed_by: Optional regex that must match the text immediately
            after the abbreviation match (typically anchored with ^ or using
            match()).
        only_if_pos: Optional coarse POS label or labels. POS evidence is
            evaluated only when usable source-aligned annotations are present.
        not_if_pos: Optional coarse POS label or labels that veto a match when
            they overlap the abbreviation. This guard takes precedence over
            ``only_if_pos``.
    """

    abbreviation: str
    expansion: str
    context_expansions: dict[AbbreviationContext, str] | None = None
    case_sensitive: bool = False
    description: str = ""
    only_if_preceded_by: str | Pattern[str] | None = None
    only_if_followed_by: str | Pattern[str] | None = None
    only_if_pos: PosConstraints = None
    not_if_pos: PosConstraints = None

    def __post_init__(self) -> None:
        self.only_if_pos = _normalize_pos_constraints(self.only_if_pos)
        self.not_if_pos = _normalize_pos_constraints(self.not_if_pos)

    def get_expansion(self, context: AbbreviationContext | None = None) -> str:
        """Get the appropriate expansion for the given context.

        Args:
            context: The context type, or None for default

        Returns:
            The expanded form
        """
        if context and self.context_expansions and context in self.context_expansions:
            return self.context_expansions[context]
        return self.expansion


def _normalize_pos_constraints(
    labels: PosConstraints,
) -> frozenset[str] | None:
    if labels is None:
        return None
    if isinstance(labels, str):
        labels = (labels,)
    normalized: set[str] = set()
    for label in labels:
        if not isinstance(label, str):
            raise ValueError(f"POS constraint labels must be strings, got {type(label).__name__}")
        value = label.strip().upper()
        if value:
            normalized.add(value)
    return frozenset(normalized)


def abbreviation_guards_match(
    entry: AbbreviationEntry,
    text: str,
    start: int,
    end: int,
    *,
    preceding_window: int = 80,
    annotations: AnnotationIndex | Iterable[TokenAnnotation] | None = None,
) -> bool:
    """Return whether an abbreviation entry's configured guards match.

    Sequence annotations are validated and normalized against the original
    source text before POS guards are evaluated. Structural and numeric-unit
    guards are authoritative; POS deny guards take precedence over POS allow
    guards, and missing lexical POS evidence fails open.

    Args:
        entry: Abbreviation definition whose guards should be checked.
        text: Complete source text containing the candidate abbreviation.
        start: Candidate start offset in ``text``.
        end: Candidate end offset in ``text``.
        preceding_window: Maximum number of preceding characters to inspect.
        annotations: Optional source-aligned annotations. Only coarse ``pos``
            labels are evaluated; ``tag`` is retained as provider metadata.

    Returns:
        ``True`` when every configured guard matches. Unguarded entries always
        return ``True``.
    """
    if not (0 <= start <= end <= len(text)):
        return False

    if entry.only_if_preceded_by:
        pattern = entry.only_if_preceded_by
        if isinstance(pattern, str):
            pattern = re.compile(pattern)
        before = text[max(0, start - preceding_window) : start]
        if not pattern.search(before):
            return False

    if entry.only_if_followed_by:
        pattern = entry.only_if_followed_by
        if isinstance(pattern, str):
            pattern = re.compile(pattern)
        if not pattern.match(text, end):
            return False

    if annotations is not None and (entry.only_if_pos or entry.not_if_pos):
        index = (
            annotations
            if isinstance(annotations, AnnotationIndex)
            else AnnotationIndex(normalize_annotations(text, annotations))
        )
        lexical_pos = tuple(
            annotation.pos
            for annotation in index.overlapping(start, end)
            if annotation.pos and annotation.pos not in {"PUNCT", "SPACE"}
        )
        if entry.not_if_pos and any(pos in entry.not_if_pos for pos in lexical_pos):
            return False
        if (
            entry.only_if_pos
            and lexical_pos
            and not any(pos in entry.only_if_pos for pos in lexical_pos)
        ):
            return False

    return True


class ContextDetector:
    """Detects context for abbreviations based on surrounding text."""

    def __init__(self) -> None:
        """Initialize context detector with pattern matchers."""
        # Patterns for detecting place names (street, avenue, etc.)
        # Match addresses like "123 Main", "100 N. Main", "50 North Elm"
        self.place_indicators = re.compile(
            # Address number, optional direction abbrev
            r"\b\d+\s+(?:[A-Z]\.\s+)?\w+(?:\s+\w+)*$",
            re.IGNORECASE,
        )

        # Patterns for detecting titles (before names)
        self.title_indicators = re.compile(
            r"^(?:\w+\s+)*[A-Z][a-z]+",  # Followed by capitalized name
        )

        # Patterns for detecting time (with numbers)
        self.time_indicators = re.compile(
            r"\b\d{1,2}(?::\d{2})?\s*$",  # Preceded by time (3:00, 5)
            re.IGNORECASE,
        )

    def detect_context(self, abbreviation: str, before: str, after: str) -> AbbreviationContext:
        """Detect the context of an abbreviation.

        Args:
            abbreviation: The abbreviation itself
            before: Text before the abbreviation
            after: Text after the abbreviation

        Returns:
            The detected context type

        Note:
            St. abbreviation uses multi-signal detection for robustness:
            - Priority 1: Saint/city name recognition (highest confidence)
            - Priority 2: House number pattern in close proximity (~30 chars)
            - Priority 3: Default to "Saint" for unknown names

            Examples:
                "123 Main St." → Street (house number pattern)
                "St. Louis" → Saint (city name recognized)
                "Visit 123 St. Louis Ave" → Saint (name wins over distant number)
                "born in 1850, St. Peter" → Saint (name + distant number)
        """
        # Check for time context (3 P.M., 5 A.M.)
        if self.time_indicators.search(before):
            return AbbreviationContext.TIME

        # Special handling for St. abbreviation (Saint vs Street)
        # Uses multi-signal approach for robust detection
        if abbreviation.lower() in ["st.", "st"]:
            # PRIORITY 1: Check for saint/city name (HIGHEST CONFIDENCE)
            if after:
                # Common saint names
                saint_names = {
                    "peter",
                    "paul",
                    "john",
                    "mary",
                    "patrick",
                    "francis",
                    "joseph",
                    "michael",
                    "george",
                    "luke",
                    "mark",
                    "matthew",
                    "thomas",
                    "james",
                    "anthony",
                    "andrew",
                }

                # Common city names that start with St.
                city_names = {
                    "louis",
                    "paul",
                    "petersburg",
                    "augustine",
                    "helena",
                    "cloud",
                    "albans",
                    "andrews",
                }

                # Get first word and clean it (remove possessives, punctuation)
                first_word = after.strip().split()[0].lower() if after.strip() else ""
                first_word = first_word.rstrip("'s.,;:!?")

                if first_word in saint_names or first_word in city_names:
                    return AbbreviationContext.RELIGIOUS

            # PRIORITY 2: Check for house number pattern (CLOSE PROXIMITY)
            # Only check last 30 characters to avoid distant numbers
            # Pattern: "[number] [optional direction] [street name]"
            # Examples: "123 Main", "456 N. Oak", "10 Park"
            proximity_limit = 30
            recent_text = before[-proximity_limit:] if len(before) > proximity_limit else before

            # Special case: Ordinal numbers directly before St. = Street
            # "5th St." "42nd St." "3rd St."
            ordinal_street_pattern = re.compile(r"\d+(?:st|nd|rd|th)\s*$", re.IGNORECASE)
            if ordinal_street_pattern.search(recent_text):
                return AbbreviationContext.PLACE

            # Match house number followed by optional direction and street
            # Catches: "123 Main St." "456 N. Oak St."
            # But NOT: "born in 1850, St." (too far away)
            house_number_pattern = re.compile(
                # Number + optional direction + capitalized word
                r"\d+\s+(?:[NSEW]\.?\s+)?[A-Z]\w*\s*$",
                re.IGNORECASE,
            )

            if house_number_pattern.search(recent_text):
                return AbbreviationContext.PLACE

            # PRIORITY 3: Default to religious (Saint)
            return AbbreviationContext.RELIGIOUS

        # Check for place context for other abbreviations (Ave., Rd., Blvd., etc.)
        if self.place_indicators.search(before):
            return AbbreviationContext.PLACE

        # Check for title context (Dr. Smith, Prof. Johnson)
        if after and self.title_indicators.match(after):
            return AbbreviationContext.TITLE

        return AbbreviationContext.DEFAULT


class AbbreviationExpander(ABC):
    """Abstract base class for language-specific abbreviation expanders."""

    def __init__(
        self,
        enable_context_detection: bool = True,
    ) -> None:
        """Initialize the abbreviation expander.

        Args:
            enable_context_detection: Whether to use context-aware expansion
        """
        self.entries: dict[str, AbbreviationEntry] = {}
        language = getattr(self, "UNIT_LANGUAGE", "en")
        self.unit_entries = unit_entries(language)
        self._unit_symbols = unit_symbols(language)
        self.enable_context_detection = enable_context_detection
        self.context_detector = ContextDetector() if enable_context_detection else None
        self._initialize_abbreviations()

    @abstractmethod
    def _initialize_abbreviations(self) -> None:
        """Initialize language-specific abbreviations.

        Subclasses must implement this to populate self.entries.
        """

    def add_abbreviation(self, entry: AbbreviationEntry) -> None:
        """Add an abbreviation entry.

        Args:
            entry: The abbreviation entry to add
        """
        if entry.abbreviation in self._unit_symbols:
            entry.case_sensitive = True
            entry.only_if_preceded_by = entry.only_if_preceded_by or NUMBER_BEFORE_UNIT
        key = entry.abbreviation if entry.case_sensitive else entry.abbreviation.lower()
        self.entries[key] = entry

    def add_custom_abbreviation(
        self,
        abbreviation: str,
        expansion: str | dict[str, str],
        description: str = "",
        case_sensitive: bool = False,
        only_if_preceded_by: str | Pattern[str] | None = None,
        only_if_followed_by: str | Pattern[str] | None = None,
        only_if_pos: PosConstraints = None,
        not_if_pos: PosConstraints = None,
    ) -> None:
        """Add or replace an entry using string context names and POS guards.

        A single POS string is treated as one label, while a collection can
        express several accepted or denied labels. Labels are normalized by
        :class:`AbbreviationEntry`.
        """
        context_expansions: dict[AbbreviationContext, str] | None = None
        default_expansion = expansion
        if isinstance(expansion, dict):
            context_expansions = {}
            for key, value in expansion.items():
                try:
                    context = AbbreviationContext(key.lower())
                except ValueError:
                    if key.lower() == AbbreviationContext.DEFAULT.value:
                        default_expansion = value
                        continue
                    raise ValueError(
                        f"Unknown context '{key}'. Valid contexts are: "
                        "default, title, place, time, academic, religious"
                    ) from None
                if context is AbbreviationContext.DEFAULT:
                    default_expansion = value
                    continue
                context_expansions[context] = value
            if AbbreviationContext.DEFAULT.value not in {key.lower() for key in expansion}:
                default_expansion = next(iter(expansion.values()))

        self.add_abbreviation(
            AbbreviationEntry(
                abbreviation=abbreviation,
                expansion=str(default_expansion),
                context_expansions=context_expansions,
                case_sensitive=case_sensitive,
                description=description,
                only_if_preceded_by=only_if_preceded_by,
                only_if_followed_by=only_if_followed_by,
                only_if_pos=only_if_pos,
                not_if_pos=not_if_pos,
            )
        )

    def remove_abbreviation(self, abbreviation: str, case_sensitive: bool = False) -> bool:
        """Remove an abbreviation entry.

        Args:
            abbreviation: The abbreviation to remove (e.g., "Dr.")
            case_sensitive: Whether to match case-sensitively

        Returns:
            True if the abbreviation was found and removed, False otherwise
        """
        key = abbreviation if case_sensitive else abbreviation.lower()
        if key in self.entries:
            del self.entries[key]
            return True
        return False

    def has_abbreviation(self, abbreviation: str, case_sensitive: bool = False) -> bool:
        """Check if an abbreviation exists.

        Args:
            abbreviation: The abbreviation to check (e.g., "Dr.")
            case_sensitive: Whether to match case-sensitively

        Returns:
            True if the abbreviation exists, False otherwise
        """
        key = abbreviation if case_sensitive else abbreviation.lower()
        return key in self.entries

    def get_abbreviation(
        self, abbreviation: str, case_sensitive: bool = False
    ) -> AbbreviationEntry | None:
        """Get an abbreviation entry.

        Args:
            abbreviation: The abbreviation to retrieve (e.g., "Dr.")
            case_sensitive: Whether to match case-sensitively

        Returns:
            The abbreviation entry if found, None otherwise
        """
        key = abbreviation if case_sensitive else abbreviation.lower()
        return self.entries.get(key)

    def expand(
        self,
        text: str,
        *,
        annotations: Iterable[TokenAnnotation] | None = None,
    ) -> str:
        """Expand all abbreviations in the text.

        Args:
            text: Input text containing abbreviations
            annotations: Optional provider-neutral annotations aligned to the
                original source offsets. Incomplete lexical POS evidence does
                not suppress a structurally valid match.

        Returns:
            Text with abbreviations expanded
        """
        normalized_annotations = normalize_annotations(text, annotations)
        annotation_index = (
            AnnotationIndex(normalized_annotations) if annotations is not None else None
        )
        candidates: list[Replacement] = list(
            iter_unit_replacements(text, getattr(self, "UNIT_LANGUAGE", "en"))
        )

        # Process all abbreviations against the original source. The resolver
        # preserves longest-first behavior while giving reviewed units priority.
        for entry in self.entries.values():
            if entry.abbreviation in self._unit_symbols:
                continue
            candidates.extend(self._iter_entry_replacements(text, entry, annotation_index))

        return apply_replacements(text, resolve_replacements(candidates))

    def _iter_entry_replacements(
        self,
        text: str,
        entry: AbbreviationEntry,
        annotation_index: AnnotationIndex | None,
    ) -> Iterator[Replacement]:
        """Yield abbreviation candidates using original source offsets.

        Args:
            text: Input text
            entry: The abbreviation entry to expand

        Returns:
            Replacement candidates for this abbreviation
        """
        # Build regex pattern with word boundaries
        # For abbrevs with '.', match at word end or before punctuation
        abbrev = re.escape(entry.abbreviation)
        if entry.abbreviation.endswith("."):
            # Reject only a following word character. This permits closers,
            # quotes, dashes, and other non-word delimiters while avoiding
            # false positives such as ``Dr.foo``.
            pattern = rf"\b{abbrev}(?!\w)"
        else:
            # Standard word boundary matching
            pattern = rf"\b{abbrev}\b"

        flags = 0 if entry.case_sensitive else re.IGNORECASE

        for match in re.finditer(pattern, text, flags=flags):
            start, end = match.span()

            # A dotted abbreviation embedded after another period is usually a
            # fragment of a longer initialism (for example ``B.S.`` in
            # ``A.B.S.``). Leave it intact unless the complete longer entry
            # was matched during the longest-first pass.
            if start > 0 and text[start - 1] == "." and "." in entry.abbreviation:
                continue

            if not abbreviation_guards_match(
                entry,
                text,
                start,
                end,
                annotations=annotation_index,
            ):
                continue

            if not self.enable_context_detection or not self.context_detector:
                expansion = entry.expansion
            else:
                # Get surrounding context from the original source.
                before = text[:start].strip()
                after = text[end:].strip()
                context = self.context_detector.detect_context(match.group(), before, after)
                expansion = entry.get_expansion(context)

            yield Replacement(
                start=start,
                end=end,
                text=expansion,
                priority=100,
                source=f"abbr:{entry.abbreviation}",
            )

    def get_abbreviations_list(self) -> list[str]:
        """Get a list of all supported abbreviations.

        Returns:
            List of abbreviation strings
        """
        return [entry.abbreviation for entry in self.entries.values()]

    def __repr__(self) -> str:
        """Return string representation."""
        return f"{self.__class__.__name__}(abbreviations={len(self.entries)})"
