"""The shared shape of an opening hours collector, the one `collect()` they all run through, and the line.

Opening hours are a source class of their own (rian, 11 Sep 2026; AWAY-PLAN §3): read from the
AIRPORT OPERATOR's own site where its robots allow, never from a retailer, entered by hand where
they do not. Everything the retail collectors learned applies unchanged. The robots policy is
`collectors/robots.py`, re-read on every run, any matching Disallow a no, 401 or 403 a refusal;
the fetch port is `collectors/fetch.py`, the honest identity, validated on content; a refusal
raises SourceBlocked and is recorded by the run, never worked around and never retried in the
same run. One collector per operator PLATFORM, never per airport: an operator that runs several
airports on one site is one module with the airports as configuration. What a collector keeps is
facts (which store, where, when it opens); the operator's prose about its shops is never kept.

Why content is judged a second time here: on 11 Sep the Paris operator answered HTTP 200 with an
Incapsula challenge page, which the fetch port's markers did not name (an issue is filed for the
collector lane), and a page that yields no store is judged again before it is called empty.
"""

import html as htmllib
import re
from collections import Counter
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Protocol

from app.services.collectors import robots
from app.services.collectors.fetch import SourceBlocked, fetch

HTML_ACCEPT = "text/html,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.5"
#: Pages one run may read for one airport, entry pages and followed ones together.
MAX_PAGES = 12
#: The pace where a host publishes no Crawl-delay; the host's own, when slower, wins.
DEFAULT_DELAY = 1.5

# A 2xx that is not the page. The first four are what Incapsula, Cloudflare and Akamai print;
# `fetch()` names some of them already and a page they let through is judged here too.
_CHALLENGE_MARKERS = (
    "request unsuccessful",
    "incapsula",
    "just a moment",
    "attention required",
    "access denied",
    "verify you are human",
    "enable javascript and cookies to continue",
    # Toronto Pearson, 11 Sep: a 200 titled "Radware Captcha Page" after the second read of its
    # store pages; "we apologize for the inconvenience" was its H1.
    "captcha",
    "we apologize for the inconvenience",
)


class NothingParsed(RuntimeError):
    """The pages were read and none holds a store's hours: the airport is left for hand population."""


@dataclass(slots=True)
class StoreHours:
    """One store as the operator's page lists it: where it is and when it opens."""

    terminal: str | None = None  # "T2"
    area: str | None = None  # "Departures", "Gate B35", "After security, near Gate E76"
    times: str | None = None  # "05:30-22:00"
    days: str | None = None  # "daily", or the span as written ("Mon-Fri")
    #: A rule instead of a clock ("Open 3 hours before flights").
    statement: str | None = None
    name: str | None = None  # the store's name where the page gives one


@dataclass(slots=True)
class HoursReading:
    """What one run read for one airport: the line the page shows and the stores behind it."""

    iata: str
    text: str
    source_url: str
    pages: list[str]
    stores: list[StoreHours]
    observed_at: datetime
    operator: str
    parser_version: str
    warnings: list[str] = field(default_factory=list)

    def detail(self) -> dict:
        return {
            "operator": self.operator,
            "parser_version": self.parser_version,
            "pages": list(self.pages),
            "stores": [asdict(s) for s in self.stores],
        }


class HoursCollector(Protocol):
    """One operator platform. `pages()` names the entry pages for an airport, `follow()` any
    further pages an entry page points at (a sitemap's store pages), `parse()` is pure.
    `delay_seconds` is the platform's own floor between requests, declared where a host has
    shown it wants a slower pace (the render collectors' rule); the run uses the slowest of
    the host's Crawl-delay, the run's delay and this."""

    slug: str
    operator: str
    homepage: str
    airports: tuple[str, ...]
    parser_version: str
    delay_seconds: float

    def pages(self, iata: str) -> list[str]: ...

    def follow(self, body: str, url: str) -> list[str]: ...

    def parse(self, body: str, url: str) -> list[StoreHours]: ...


def pace_for(collector: HoursCollector, rules: robots.Robots, delay: float) -> float:
    """Seconds between requests on one host: the slowest of theirs, the run's and the platform's own."""
    return rules.delay_for(max(float(delay), float(getattr(collector, "delay_seconds", 0.0) or 0.0)))


def looks_challenged(body: str) -> bool:
    probe = body[:6000].lower()
    return any(marker in probe for marker in _CHALLENGE_MARKERS)


def collect(
    collector: HoursCollector,
    iata: str,
    *,
    delay: float = DEFAULT_DELAY,
    fetcher=None,
    robots_fetcher=None,
    now: datetime | None = None,
) -> HoursReading:
    """Read one airport's hours from its operator, under the robots policy, or raise.

    SourceBlocked: the host refuses (robots, a 401/403, a challenge page); the run records it and
    reads nothing more from that host. RobotsUnavailable or FetchError: try next run.
    NothingParsed: every page read and no store found; the airport goes to hand population.
    `fetcher(url) -> str` and `robots_fetcher` are the test seams; the real run goes through
    `fetch()` with the honest identity and the slower of the host's delay and ours.
    """
    iata = iata.upper()
    if iata not in collector.airports:
        raise ValueError(f"{collector.slug} does not cover {iata}")
    entry = collector.pages(iata)
    rules = robots.check_allowed(collector.homepage, entry, fetcher=robots_fetcher, fresh=True)
    wait = pace_for(collector, rules, delay)
    get = fetcher or (lambda url: fetch(url, accept=HTML_ACCEPT, delay=wait).text)

    queue = list(entry)
    read: list[str] = []
    stores: list[StoreHours] = []
    warnings: list[str] = []
    while queue and len(read) < MAX_PAGES:
        url = queue.pop(0)
        if not rules.allows(url):
            raise SourceBlocked(f"{rules.host} robots.txt disallows {url}")
        body = get(url)
        read.append(url)
        if looks_challenged(body):
            raise SourceBlocked(f"{url} answered a challenge page, not the page")
        stores.extend(collector.parse(body, url))
        for more in collector.follow(body, url):
            if more not in read and more not in queue:
                queue.append(more)
    if queue:
        warnings.append(f"{len(queue)} pages not read: the run's cap is {MAX_PAGES}")
    if not stores:
        raise NothingParsed(f"{collector.slug}: no store hours on {', '.join(read)}")
    return HoursReading(
        iata=iata,
        text=summarise(stores),
        source_url=entry[0],
        pages=read,
        stores=stores,
        observed_at=now or datetime.now(UTC),
        operator=collector.operator,
        parser_version=collector.parser_version,
        warnings=warnings,
    )


# --- the pure helpers every platform shares ------------------------------------------------

_TAG = re.compile(r"<[^>]+>")
_WS = re.compile(r"\s+")
_TERMINAL = re.compile(r"^\s*(?:terminal|term\.?)\s*(\w+)\s*$", re.I)
_TIMES = re.compile(r"(\d{1,2})[:.](\d{2})\s*(?:-|–|to)\s*(\d{1,2})[:.](\d{2})")
# "4:00 am to 12:00 midnight", "6 am - 9:30 pm": the twelve-hour clock some operators print.
_MERIDIAN = r"(a\.?m\.?|p\.?m\.?|noon|midnight)"
_TIMES_12 = re.compile(
    r"(\d{1,2})(?:[:.](\d{2}))?\s*" + _MERIDIAN + r"\s*(?:-|–|to)\s*(\d{1,2})(?:[:.](\d{2}))?\s*" + _MERIDIAN,
    re.I,
)
_DAY_SPAN = re.compile(
    r"\b(mo|mon|monday|tu|tue|tuesday|we|wed|wednesday|th|thu|thursday|fr|fri|friday|sa|sat|saturday|su|sun|sunday)"
    r"\s*(?:-|–|to)\s*"
    r"(mo|mon|monday|tu|tue|tuesday|we|wed|wednesday|th|thu|thursday|fr|fri|friday|sa|sat|saturday|su|sun|sunday)\b",
    re.I,
)
_DAILY = re.compile(r"\b(daily|every day|7 days|all week)\b", re.I)
_ALL_DAY = re.compile(r"\b24\s*(?:h|hrs|hours)\b", re.I)
DEPARTURES = re.compile(r"\bdepartures?\b", re.I)


def text_of(fragment: str) -> str:
    """Tags out, entities decoded, whitespace folded."""
    return _WS.sub(" ", htmllib.unescape(_TAG.sub(" ", fragment))).strip()


def terminal_label(text: str | None) -> str | None:
    """"Terminal 2" and "T2" both read as "T2"; anything else is kept as written."""
    if not text:
        return None
    cleaned = text_of(text)
    match = _TERMINAL.match(cleaned)
    if match:
        return f"T{match.group(1).upper()}"
    return cleaned or None


_DAYS = {"mo": "Mon", "tu": "Tue", "we": "Wed", "th": "Thu", "fr": "Fri", "sa": "Sat", "su": "Sun"}


def _short_day(token: str) -> str:
    return _DAYS[token[:2].lower()]


def parse_timing(text: str) -> tuple[str | None, str | None, str | None]:
    """`(times, days, statement)` from an hours string as an operator prints it.

    "Mo-Su 05:30-22:00" -> ("05:30-22:00", "daily", None); "Mon-Fri 6:00-21:00" ->
    ("06:00-21:00", "Mon-Fri", None); "24 hours" -> (None, "daily", "24 hours"); a rule with no
    clock ("Open 3 hours before flights") -> (None, None, the sentence). Nothing is guessed: a
    string with no time and no rule yields three Nones.
    """
    cleaned = text_of(text)
    if not cleaned:
        return None, None, None
    days = None
    span = _DAY_SPAN.search(cleaned)
    if span:
        first, last = _short_day(span.group(1)), _short_day(span.group(2))
        days = "daily" if (first, last) == ("Mon", "Sun") else f"{first}-{last}"
    elif _DAILY.search(cleaned):
        days = "daily"
    times = _TIMES.search(cleaned)
    if times:
        h1, m1, h2, m2 = times.groups()
        return f"{int(h1):02d}:{m1}-{int(h2):02d}:{m2}", days, None
    twelve = _TIMES_12.search(cleaned)
    if twelve:
        h1, m1, p1, h2, m2, p2 = twelve.groups()
        return f"{_to_24h(h1, m1, p1)}-{_to_24h(h2, m2, p2)}", days, None
    if _ALL_DAY.search(cleaned):
        return None, days or "daily", "24 hours"
    return None, days, cleaned


def _to_24h(hour: str, minute: str | None, meridian: str) -> str:
    """"12:00 midnight" is 00:00, "12 noon" is 12:00, "1:30 am" is 01:30, "9 pm" is 21:00."""
    h = int(hour) % 12
    word = meridian.replace(".", "").lower()
    if word in ("pm", "noon"):
        h += 12
    if word == "noon":
        h = 12
    if word == "midnight":
        h = 0
    return f"{h:02d}:{minute or '00'}"


def _when(store: StoreHours) -> str:
    return store.times or store.statement or ""


def _sentence(statement: str) -> str:
    return statement[0].lower() + statement[1:] if statement else statement


def summarise(stores: list[StoreHours]) -> str:
    """The one line an airport page prints, from the stores a run read.

    The store's own name leads (the retailer as the operator's page names it), then how many
    stores across how many terminals, then one clock per terminal: the departures store where
    the terminal has one, else its first store, and the other stores as a range. A rule the
    operator gives instead of a clock ("open 3 hours before flights") is printed as written,
    once, when every store shares it. When the page names several stores differently (a
    WHSmith duty free beside the main store at Dublin; boutiques under the duty free banner at
    Toronto) each name gets its own sentence. Deterministic, so the same reading always makes
    the same line and a changed line means the page changed.
    """
    if not stores:
        return ""
    n = len(stores)
    terminals = list(dict.fromkeys(s.terminal for s in stores if s.terminal))
    k = len(terminals)
    names = list(dict.fromkeys(s.name for s in stores if s.name))
    clocked = [s for s in stores if s.times]
    daily = bool(clocked) and all(s.days == "daily" for s in clocked)

    def spread(count: int) -> str:
        if k > 1:
            return f" across {k} terminals"
        if k == 1 and count > 1:
            return f" in {terminals[0]}"
        return ""

    if len(names) <= 1:
        head = f"{names[0] if names else 'Duty free'}, {n} store{'s' if n != 1 else ''}{spread(n)}"
        if daily:
            head += ", every day"
        if n == 1:
            s = stores[0]
            where = " ".join(b for b in (s.terminal, s.area) if b)
            when = _sentence(_when(s)) if s.statement else _when(s)
            return f"{head}{', ' + where if where else ''}: {when}".rstrip(".") + "."
        statements = [s.statement for s in stores]
        shared = all(statements) and len(set(statements)) == 1
        return f"{head}{': ' if shared else '. '}{_group_line(stores)}".rstrip(".") + "."

    head = f"{n} duty free stores{spread(n)}"
    if daily:
        head += ", every day"
    groups = [[s for s in stores if s.name == name] for name in names]
    loose = [s for s in stores if not s.name]
    if loose:
        groups.append(loose)
    groups.sort(key=lambda g: -len(g))  # the biggest name first; a tie keeps page order
    sentences = [f"{group[0].name or 'Other stores'}: {_group_line(group)}".rstrip(".") + "." for group in groups]
    return f"{head}. " + " ".join(sentences)


def _group_line(stores: list[StoreHours]) -> str:
    """The clocks of one name's stores: a shared rule once, else one main store per terminal
    and the rest as a range."""
    statements = [s.statement for s in stores]
    if all(statements) and len(set(statements)) == 1:
        return _sentence(statements[0])
    if len(stores) == 1:
        s = stores[0]
        where = " ".join(b for b in (s.terminal, s.area) if b)
        return f"{where + ' ' if where else ''}{_when(s)}".strip()
    terminals = list(dict.fromkeys(s.terminal for s in stores if s.terminal))
    groups: list[list[StoreHours]] = [[s for s in stores if s.terminal == t] for t in terminals]
    loose = [s for s in stores if not s.terminal]
    if loose:
        groups.append(loose)
    mains: list[StoreHours] = []
    others: list[StoreHours] = []
    for group in groups:
        main = next((s for s in group if s.area and DEPARTURES.search(s.area)), group[0])
        mains.append(main)
        others.extend(s for s in group if s is not main)
    all_departures = all(m.area and DEPARTURES.search(m.area) for m in mains)

    def label(s: StoreHours) -> str:
        bits = [s.terminal] if s.terminal else []
        if s.area and not (all_departures and DEPARTURES.search(s.area)):
            bits.append(s.area)
        return " ".join(bits) or "store"

    main_line = ", ".join(f"{label(s)} {_when(s)}".strip() for s in mains)
    if all_departures and others:
        main_line = "Departures stores " + main_line
    parts = [main_line]
    if others:
        spans = [(_TIMES.search(s.times or ""), s) for s in others]
        if all(m for m, _ in spans):
            opens = sorted({f"{int(m.group(1)):02d}:{m.group(2)}" for m, _ in spans})
            closes = sorted({f"{int(m.group(3)):02d}:{m.group(4)}" for m, _ in spans})
            other_line = (
                f"the other {len(others)} store{'s' if len(others) != 1 else ''} open "
                + (opens[0] if len(opens) == 1 else f"{opens[0]} to {opens[-1]}")
                + ", closing "
                + (closes[0] if len(closes) == 1 else f"{closes[0]} to {closes[-1]}")
            )
        else:
            other_line = ", ".join(f"{label(s)} {_when(s)}".strip() for s in others)
        parts.append(other_line)
    return "; ".join(parts)
