import re
from abc import ABC, abstractmethod
from datetime import datetime, time as Time
from decimal import Decimal, InvalidOperation
from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints

from pydantic import BaseModel, ConfigDict

from ..models import FieldSpec, RawEntity, RawOcrDocument, OcrField

LogEntryT = TypeVar("LogEntryT", bound="SourceTaggedEntry")

class SourceTaggedEntry(BaseModel):
    """Base model for any parsed log entry, carrying which table/section it came from."""

    source_table: str | None = None
    model_config = ConfigDict(populate_by_name=True)

class TimedLogEntry(SourceTaggedEntry):
    """Base model for WTP log entries that are keyed by a time column."""

    time: OcrField[Time] | None = None

class LogbookOCR(BaseModel):
    date: OcrField[datetime] | None = None

_TIME_COLON_PATTERN = re.compile(r"(\d{1,2}):(\d{2})")
_TIME_DOT_PATTERN = re.compile(r"(\d{1,2})\.(\d{2})")
_TIME_SPACE_PATTERN = re.compile(r"(\d{1,2})\s+(\d{2})")
_TIME_FOUR_DIGIT_PATTERN = re.compile(r"^(\d{2})(\d{2})(?=\D|$)")
_TIME_THREE_DIGIT_PATTERN = re.compile(r"^(\d)(\d{2})(?=\D|$)")  # hour, then minute
_DATE_PATTERN = re.compile(r"(\d{1,2})/(\d{1,2})/(\d{2,4})")


class OcrParser(ABC):
    def parse_time(self, time_str: str) -> Time | None:
        time_str = time_str.strip()
        if not time_str:
            return None

        try:
            return datetime.strptime(time_str, "%H:%M").time()
        except ValueError:
            pass

        for pattern in (
            _TIME_COLON_PATTERN,
            _TIME_DOT_PATTERN,
            _TIME_SPACE_PATTERN,
        ):
            match = pattern.search(time_str)
            if match is not None:
                parsed = self._time_from_match(match)
                if parsed is not None:
                    return parsed

        four_digit_match = _TIME_FOUR_DIGIT_PATTERN.match(time_str)
        if four_digit_match is not None:
            parsed = self._time_from_match(four_digit_match)
            if parsed is not None:
                return parsed

        three_digit_match = _TIME_THREE_DIGIT_PATTERN.match(time_str)
        if three_digit_match is not None:
            parsed = self._time_from_match(three_digit_match)
            if parsed is not None:
                return parsed

        return None

    @staticmethod
    def _time_from_match(match: re.Match[str]) -> Time | None:
        hour = int(match.group(1))
        minute = int(match.group(2))
        return OcrParser._time_from_parts(hour, minute)

    @staticmethod
    def _time_from_parts(hour: int, minute: int) -> Time | None:
        if 0 <= hour <= 23 and 0 <= minute <= 59:
            return Time(hour=hour, minute=minute)
        return None

    def parse_date(self, date_str: str) -> datetime | None:
        date_str = date_str.strip()
        if not date_str:
            return None

        match = _DATE_PATTERN.search(date_str)
        if match is None:
            return None

        for date_format in ("%d/%m/%Y", "%d/%m/%y"):
            try:
                return datetime.strptime(match.group(0), date_format)
            except ValueError:
                pass

        return None

    def get_ocr_date(self, raw_ocr_document: RawOcrDocument) -> OcrField[datetime] | None:
        for entity in raw_ocr_document.entities:
            if entity.type != "date":
                continue
            raw_date = entity.mention_text
            normalized_date = self.get_normalized_text(entity)
            date: datetime | None = None
            if normalized_date is not None:
                try:
                    date = datetime.strptime(normalized_date, "%Y-%m-%d")
                except ValueError:
                    date = self.parse_date(entity.mention_text)
            else:
                date = self.parse_date(entity.mention_text)
            if date is None:
                return None
            return OcrField(
                raw_text=raw_date,
                value=date,
                confidence=entity.confidence,
                entity_id=entity.id,
                normalized_text=normalized_date,
            )
        return None

    def parse_entry(
        self,
        entity: RawEntity,
        entry_model: type[LogEntryT],
        source_table: str | None = None,
    ) -> LogEntryT:
        field_lookup = self.get_field_lookup(entry_model)
        entry_values: dict[str, Any] = {}

        for prop in entity.properties:
            field_name = field_lookup.get(prop.type)
            if field_name is None:
                continue

            field_type = self.get_field_value_type(entry_model, field_name)
            field_spec = self.get_field_spec(entry_model, field_name)
            entry_values[field_name] = self.parse_ocr_field(prop, field_type, field_spec)

        self.apply_required_fields(entry_values, entry_model)
        if "source_table" in entry_model.model_fields:
            entry_values["source_table"] = source_table

        return entry_model(**entry_values)

    @staticmethod
    def apply_required_fields(
        entry_values: dict[str, Any], entry_model: type[BaseModel]
    ) -> None:
        """For any field declared `required` via FieldSpec that has no value at all (the OCR
        provider emitted no entity/property for that cell), synthesize a flagged placeholder
        OcrField instead of leaving it as a bare `None` with no signal a reviewer can act on.

        Mutates `entry_values` in place; callers don't need the return value."""
        for field_name in entry_model.model_fields:
            if entry_values.get(field_name) is not None:
                continue

            field_spec = OcrParser.get_field_spec(entry_model, field_name)
            if field_spec is None or not field_spec.required:
                continue

            entry_values[field_name] = OcrField(
                raw_text="",
                value=None,
                flag=True,
                ocr_spec=field_spec.ocr_spec,
                ocr_default=field_spec.ocr_default,
            )

    @staticmethod
    def get_field_spec(entry_model: type[TimedLogEntry], field_name: str) -> FieldSpec | None:
        try:
            hints = get_type_hints(entry_model, include_extras=True)
        except Exception:
            return None
        hint = hints.get(field_name)
        if hint is None or get_origin(hint) is not Annotated:
            return None
        for meta in get_args(hint)[1:]:
            if isinstance(meta, FieldSpec):
                return meta
        return None

    @staticmethod
    def get_field_lookup(entry_model: type[TimedLogEntry]) -> dict[str, str]:
        lookup: dict[str, str] = {}
        for field_name, field_info in entry_model.model_fields.items():
            lookup[field_name] = field_name
            if field_info.alias is not None:
                lookup[field_info.alias] = field_name
        return lookup

    @staticmethod
    def get_field_value_type(entry_model: type[TimedLogEntry], field_name: str) -> Any:
        annotation = entry_model.model_fields[field_name].annotation
        if annotation is None:
            return str

        for union_arg in get_args(annotation) or (annotation,):
            generic_metadata = getattr(union_arg, "__pydantic_generic_metadata__", None)
            if generic_metadata is not None and generic_metadata.get("origin") is OcrField:
                field_args = generic_metadata.get("args", ())
                if field_args:
                    return field_args[0]

            origin_args = get_args(union_arg)
            if origin_args:
                return origin_args[0]

        return str

    def parse_ocr_field(
        self, entity: RawEntity, value_type: Any, field_spec: FieldSpec | None = None
    ) -> OcrField[Any]:
        value = self.parse_field_value(entity, value_type)
        return OcrField(
            raw_text=entity.mention_text,
            value=value,
            confidence=entity.confidence,
            entity_id=entity.id,
            normalized_text=str(value),
            ocr_spec=field_spec.ocr_spec if field_spec is not None else None,
            ocr_default=field_spec.ocr_default if field_spec is not None else None,
        )

    def parse_field_value(self, entity: RawEntity, value_type: Any) -> Any:
        if value_type is Time:
            return self.parse_time(entity.mention_text)
        if value_type is Decimal:
            return self.parse_decimal(entity)
        if value_type is str:
            return entity.mention_text
        return entity.mention_text

    @staticmethod
    def parse_decimal(entity: RawEntity) -> Decimal | None:
        normalized_value = entity.normalized_value
        if normalized_value is not None:
            if normalized_value.integer_value is not None:
                return Decimal(normalized_value.integer_value)
            if normalized_value.float_value is not None:
                return Decimal(str(normalized_value.float_value))
            if normalized_value.text:
                parsed = OcrParser.decimal_from_text(normalized_value.text)
                if parsed is not None:
                    return parsed

        return OcrParser.decimal_from_text(entity.mention_text)

    @staticmethod
    def decimal_from_text(text: str) -> Decimal | None:
        normalized_text = text.strip().replace(",", "").replace("-", ".")
        if not normalized_text:
            return None

        try:
            return Decimal(normalized_text)
        except InvalidOperation:
            return None

    @staticmethod
    def get_normalized_text(entity: RawEntity) -> str | None:
        nv = entity.normalized_value
        if nv is None:
            return None
        if nv.text:
            return nv.text
        if nv.integer_value is not None:
            return str(nv.integer_value)
        if nv.float_value is not None:
            return str(nv.float_value)
        return None

    @abstractmethod
    def parse(self, raw_ocr_document: RawOcrDocument) -> LogbookOCR:
        pass