from __future__ import annotations

from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping
from datetime import datetime
from typing import Any, Literal, get_type_hints

from structly import StructlyParser

from .config import (
    DEFAULT_CONFIG_FACTORY,
    DEFAULT_TLDS,
    FieldOverride,
    StructlyConfigFactory,
    build_structly_config_for_tld,
)
from .domain_inference import (
    infer_domain_from_text,
    normalise_tld,
    refresh_domain_markers,
    split_domain,
)
from .normalization import normalize_raw_text
from .records import RecordBuilder, WhoisRecord, is_rate_limited_payload

TLDS_REQUIRING_DOMAIN_HINT = frozenset({"info", "za", "jobs", "live"})

refresh_domain_markers(DEFAULT_CONFIG_FACTORY.base_fields, DEFAULT_CONFIG_FACTORY.tld_overrides)

DateParser = Callable[[str], datetime]


class WhoisParser:
    """High-level WHOIS parser built on top of Structly."""

    def __init__(
        self,
        *,
        preload_tlds: Iterable[str] | None = None,
        rayon_policy: str | None = None,
        config_factory: StructlyConfigFactory | None = None,
        extra_tld_overrides: Mapping[str, Mapping[str, FieldOverride]] | None = None,
        date_parser: DateParser | None = None,
        record_builder: RecordBuilder | None = None,
    ) -> None:
        self._config_factory = config_factory or StructlyConfigFactory()
        if extra_tld_overrides:
            for tld, overrides in extra_tld_overrides.items():
                self._config_factory.register_tld(tld, overrides, replace=False)
        base_tlds = DEFAULT_TLDS if preload_tlds is None else preload_tlds
        wanted = set(normalise_tld(tld) for tld in base_tlds)
        if extra_tld_overrides:
            wanted.update(normalise_tld(tld) for tld in extra_tld_overrides)
        self._parsers: dict[str, StructlyParser] = {}
        self._rayon_policy = rayon_policy
        self._date_parser = date_parser
        self._record_builder = record_builder or RecordBuilder()
        for tld in sorted(wanted):
            if not tld:
                continue
            self._parsers[tld] = self._build_structly_parser(tld)
        self._default = self._build_structly_parser(None)
        refresh_domain_markers(self._config_factory.base_fields, self._config_factory.tld_overrides)

    @property
    def default_date_parser(self) -> DateParser | None:
        """Return the callable used for date post-processing, if any."""
        return self._date_parser

    def supported_tlds(self) -> list[str]:
        """Return supported TLDs as a deterministic, sorted list."""
        configured = {tld for tld in self._parsers if tld}
        configured.update(self._config_factory.known_tlds)
        return sorted({tld for tld in configured if tld})

    def field_catalog(self, surface: Literal["record", "dict", "both"] = "record") -> dict[str, Any]:
        """Return a deterministic mapping of known fields and their types."""
        record_catalog = self._record_field_catalog()
        dict_catalog = self._dict_field_catalog()
        if surface == "record":
            return record_catalog
        if surface == "dict":
            return dict_catalog
        if surface == "both":
            merged: dict[str, Any] = dict(dict_catalog)
            merged.update(record_catalog)
            return dict(sorted(merged.items()))
        raise ValueError("surface must be one of: record, dict, both")

    def _record_field_catalog(self) -> dict[str, Any]:
        hints = get_type_hints(WhoisRecord, include_extras=True)
        entries: list[tuple[str, Any]] = []
        for field_name, annotation in WhoisRecord.__annotations__.items():
            if field_name == "schema_version":
                continue
            field_type = hints.get(field_name, annotation)
            entries.append((field_name, field_type))
        return dict(sorted(entries))

    def _dict_field_catalog(self) -> dict[str, Any]:
        names = set(self._config_factory.base_fields.keys())
        for overrides in self._config_factory.tld_overrides.values():
            names.update(overrides.keys())
        return {name: Any for name in sorted(names)}

    def _select_tld(self, explicit_tld: str | None, domain: str | None) -> str:
        target = normalise_tld(explicit_tld)
        if target:
            return target

        labels = split_domain(domain)
        if not labels:
            return ""

        for start in range(len(labels)):
            candidate = ".".join(labels[start:])
            if candidate in self._parsers:
                return candidate
        return labels[-1]

    def _build_structly_parser(self, tld: str | None) -> StructlyParser:
        return StructlyParser(
            build_structly_config_for_tld(tld, factory=self._config_factory),
            rayon_policy=self._rayon_policy,
        )

    def _get_parser_for_tld(self, tld: str) -> StructlyParser:
        if not tld:
            return self._default
        if tld not in self._parsers:
            self._parsers[tld] = self._build_structly_parser(tld)
        return self._parsers[tld]

    @staticmethod
    def _apply_domain_hint(
        parsed: MutableMapping[str, Any],
        *,
        domain_hint: str | None,
        target_tld: str,
    ) -> None:
        """Ensure problematic TLDs keep the user-provided domain name."""
        if target_tld not in TLDS_REQUIRING_DOMAIN_HINT or not domain_hint:
            return
        cleaned_hint = domain_hint.strip()
        if not cleaned_hint:
            return
        parsed_domain = parsed.get("domain_name")
        if isinstance(parsed_domain, str):
            normalized = parsed_domain.strip().strip(".").lower()
            if normalized and normalized != target_tld:
                return
        parsed["domain_name"] = cleaned_hint

    def register_tld(
        self,
        tld: str,
        overrides: Mapping[str, FieldOverride],
        *,
        replace: bool = False,
        preload: bool = True,
    ) -> None:
        """Register or update a TLD-specific parser override."""
        normalized = normalise_tld(tld)
        if not normalized:
            raise ValueError("TLD label cannot be empty")
        self._config_factory.register_tld(normalized, overrides, replace=replace)
        if preload:
            self._parsers[normalized] = self._build_structly_parser(normalized)
        elif normalized in self._parsers:
            del self._parsers[normalized]
        refresh_domain_markers(self._config_factory.base_fields, self._config_factory.tld_overrides)

    def refresh_default_parser(self) -> None:
        """Rebuild the default Structly parser."""
        self._default = self._build_structly_parser(None)

    def parse(
        self,
        raw_text: str,
        *,
        domain: str | None = None,
        tld: str | None = None,
    ) -> MutableMapping[str, Any]:
        """Parse a WHOIS payload into a mapping of canonical fields."""
        text = normalize_raw_text(raw_text)
        inferred_domain = domain
        default_parsed: MutableMapping[str, str] | None = None
        if not inferred_domain and not tld:
            inferred_domain = infer_domain_from_text(text)
            if not inferred_domain:
                default_parsed = self._default.parse(text)
                candidate = default_parsed.get("domain_name")
                if isinstance(candidate, str) and candidate.strip():
                    inferred_domain = candidate.strip()
        target_tld = self._select_tld(tld, inferred_domain)
        parser = self._get_parser_for_tld(target_tld)
        if target_tld == "" and default_parsed is not None:
            return default_parsed
        parsed = parser.parse(text)
        self._apply_domain_hint(parsed, domain_hint=domain, target_tld=target_tld)
        return parsed

    def parse_record(
        self,
        raw_text: str,
        *,
        domain: str | None = None,
        tld: str | None = None,
        lowercase: bool = False,
        date_parser: DateParser | None = None,
    ) -> WhoisRecord:
        """Parse a WHOIS payload and return a validated WhoisRecord."""
        if is_rate_limited_payload(raw_text):
            return self._record_builder.build(
                raw_text,
                {},
                lowercase=lowercase,
                date_parser=date_parser or self._date_parser,
            )
        parsed = self.parse(raw_text, domain=domain, tld=tld)
        return self._record_builder.build(
            raw_text,
            parsed,
            lowercase=lowercase,
            date_parser=date_parser or self._date_parser,
        )

    def parse_many(
        self,
        payloads: Iterable[str],
        *,
        domain: str | Iterable[str] | None = None,
        tld: str | None = None,
        to_records: bool = False,
        lowercase: bool = False,
        date_parser: DateParser | None = None,
    ) -> Iterable[MutableMapping[str, str]] | list[WhoisRecord]:
        domain_hints: list[str] | None = None
        domain_hint_for_selection: str | None
        if isinstance(domain, str) or domain is None:
            domain_hint_for_selection = domain
        else:
            domain_hints = list(domain)
            domain_hint_for_selection = domain_hints[0] if domain_hints else None

        target_tld = self._select_tld(tld, domain_hint_for_selection)
        parser = self._get_parser_for_tld(target_tld)
        parser_input: Iterable[str]
        if to_records:
            raw_payloads = list(payloads)
            parser_input = (normalize_raw_text(text) for text in raw_payloads)
        else:
            raw_payloads = None
            parser_input = (normalize_raw_text(text) for text in payloads)
        parsed_payloads = parser.parse_many(parser_input)
        parsed_sequence: Iterable[MutableMapping[str, str]] = parsed_payloads
        if target_tld in TLDS_REQUIRING_DOMAIN_HINT:
            if domain_hints is not None:
                parsed_list_for_hint = list(parsed_sequence)
                if len(parsed_list_for_hint) != len(domain_hints):
                    raise ValueError("domain hint count does not match payload count")
                for parsed, hint in zip(parsed_list_for_hint, domain_hints):
                    self._apply_domain_hint(parsed, domain_hint=hint, target_tld=target_tld)
                parsed_sequence = parsed_list_for_hint
            elif domain_hint_for_selection:
                parsed_list_for_hint = list(parsed_sequence)
                for parsed in parsed_list_for_hint:
                    self._apply_domain_hint(parsed, domain_hint=domain_hint_for_selection, target_tld=target_tld)
                parsed_sequence = parsed_list_for_hint
        if not to_records:
            return parsed_sequence
        records: list[WhoisRecord] = []
        if raw_payloads is None:
            return records
        parsed_list = list(parsed_sequence)
        if len(parsed_list) != len(raw_payloads):
            raise RuntimeError("Structly returned an unexpected number of results")
        for raw_text, parsed in zip(raw_payloads, parsed_list):
            records.append(
                self._record_builder.build(
                    raw_text,
                    parsed,
                    lowercase=lowercase,
                    date_parser=date_parser or self._date_parser,
                )
            )
        return records

    def parse_chunks(
        self,
        payloads: Iterable[str],
        *,
        domain: str | None = None,
        tld: str | None = None,
        chunk_size: int = 512,
    ) -> Iterator[list[MutableMapping[str, Any]]]:
        target_tld = self._select_tld(tld, domain)
        parser = self._get_parser_for_tld(target_tld)
        normalized_inputs = (normalize_raw_text(text) for text in payloads)
        chunks = parser.parse_chunks(normalized_inputs, chunk_size=chunk_size)
        if not domain or target_tld not in TLDS_REQUIRING_DOMAIN_HINT:
            return chunks

        def _apply_hint() -> Iterator[list[MutableMapping[str, Any]]]:
            for chunk in chunks:
                for parsed in chunk:
                    self._apply_domain_hint(parsed, domain_hint=domain, target_tld=target_tld)
                yield chunk

        return _apply_hint()
