"""An opening hours run under the robots policy: the three outcomes, the challenge, the empty page.

The hours collectors import the retail collectors' one robots implementation and fetch port; this
pins that `services/hours/base.collect` opens with a fresh robots read, refuses before any page is
fetched when a Disallow covers the page, treats 401/403 at robots.txt as a refusal and 5xx as
"try next run", judges a 2xx challenge body as a refusal on content, and calls a readable page
with no store hours NothingParsed rather than writing an empty line. No network: the fetchers are
injected and count their calls.
"""

import pathlib
from datetime import UTC, datetime

import pytest

from app.services.collectors.fetch import SourceBlocked
from app.services.collectors.robots import RobotsUnavailable
from app.services.hours import base
from app.services.hours.dublin import DublinAirportHours
from app.services.hours.heathrow import HeathrowHours
from app.services.hours.toronto import TorontoPearsonHours

FIXTURES = pathlib.Path(__file__).resolve().parent / "fixtures"
NOW = datetime(2026, 9, 11, 22, 0, tzinfo=UTC)


class Pages:
    """A fake fetcher: the bodies by URL suffix, and every URL asked for."""

    def __init__(self, bodies: dict[str, str]):
        self.bodies = bodies
        self.asked: list[str] = []

    def __call__(self, url: str) -> str:
        self.asked.append(url)
        for suffix, body in self.bodies.items():
            if url.endswith(suffix):
                return body
        raise AssertionError(f"unexpected fetch {url}")


def robots_answering(status: int, text: str = ""):
    calls = []

    def fetcher(url: str):
        calls.append(url)
        return status, text.encode()

    fetcher.calls = calls
    return fetcher


class TestOutcomes:
    def test_allowed_reads_the_page_and_returns_the_line_with_its_provenance(self):
        pages = Pages({"/world-duty-free": (FIXTURES / "hours_heathrow.html").read_text()})
        robots = robots_answering(200, "User-agent: *\nDisallow: /rewards\n")
        reading = base.collect(HeathrowHours(), "lhr", fetcher=pages, robots_fetcher=robots, now=NOW)
        assert robots.calls == ["https://www.heathrow.com/robots.txt"]
        assert pages.asked == ["https://www.heathrow.com/at-the-airport/shops-a-z/world-duty-free"]
        assert reading.iata == "LHR" and reading.observed_at == NOW
        assert reading.source_url == pages.asked[0] and reading.pages == pages.asked
        assert len(reading.stores) == 14 and reading.text.startswith("World Duty Free, 14 stores")
        detail = reading.detail()
        assert detail["operator"] == "Heathrow Airport" and detail["parser_version"] == "heathrow-hours-1"
        assert detail["stores"][0]["area"] == "Departures" and detail["pages"] == pages.asked

    def test_a_disallow_covering_the_page_refuses_before_any_page_is_fetched(self):
        pages = Pages({})
        robots = robots_answering(200, "User-agent: *\nDisallow: /at-the-airport/*\n")
        with pytest.raises(SourceBlocked):
            base.collect(HeathrowHours(), "LHR", fetcher=pages, robots_fetcher=robots)
        assert pages.asked == []

    def test_a_disallow_addressed_to_our_bot_by_name_counts_too(self):
        pages = Pages({})
        robots = robots_answering(200, "User-agent: DutyFreeProfessorBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n")
        with pytest.raises(SourceBlocked):
            base.collect(DublinAirportHours(), "DUB", fetcher=pages, robots_fetcher=robots)
        assert pages.asked == []

    def test_robots_403_is_a_refusal_and_5xx_is_try_next_run(self):
        """Athens answered 403 at robots.txt on 11 Sep: a refusal, recorded, the airport to hand."""
        pages = Pages({})
        with pytest.raises(SourceBlocked):
            base.collect(HeathrowHours(), "LHR", fetcher=pages, robots_fetcher=robots_answering(403))
        with pytest.raises(RobotsUnavailable):
            base.collect(HeathrowHours(), "LHR", fetcher=pages, robots_fetcher=robots_answering(503))
        assert pages.asked == []

    def test_an_absent_robots_file_means_no_rules(self):
        pages = Pages({"/shops-in-the-terminals": (FIXTURES / "hours_dublin.html").read_text()})
        reading = base.collect(DublinAirportHours(), "DUB", fetcher=pages, robots_fetcher=robots_answering(404), now=NOW)
        assert len(reading.stores) == 4

    def test_a_challenge_body_behind_a_2xx_is_a_refusal_on_content(self):
        """Paris, 11 Sep: HTTP 200, one Incapsula iframe, 84 characters of text."""
        pages = Pages({"/world-duty-free": '<html><body><iframe src="/_Incapsula_Resource">Request unsuccessful. Incapsula incident ID: 6</iframe></body></html>'})
        with pytest.raises(SourceBlocked):
            base.collect(HeathrowHours(), "LHR", fetcher=pages, robots_fetcher=robots_answering(200, ""))

    def test_a_readable_page_with_no_store_hours_is_nothing_parsed_not_an_empty_line(self):
        """Toronto's shop list, AENA's terminal listing and JFK's directory all read fine and
        name no hours: those airports go to hand population, and nothing is written."""
        pages = Pages({"/world-duty-free": "<html><body><h1>World Duty Free</h1><p>Shops A-Z</p></body></html>"})
        with pytest.raises(base.NothingParsed):
            base.collect(HeathrowHours(), "LHR", fetcher=pages, robots_fetcher=robots_answering(200, ""))

    def test_an_airport_the_platform_does_not_cover_is_a_value_error(self):
        with pytest.raises(ValueError):
            base.collect(HeathrowHours(), "JFK", fetcher=Pages({}), robots_fetcher=robots_answering(200, ""))


class TestFollowing:
    def test_the_sitemap_is_read_then_each_store_page_under_the_cap(self):
        collector = TorontoPearsonHours()
        store_page = (FIXTURES / "hours_toronto.html").read_text()
        pages = Pages({
            "/sitemap_yyz_en.xml": (FIXTURES / "hours_toronto_sitemap.xml").read_text(),
            "-e76": store_page,
            "-b22": store_page.replace("Terminal 1", "Terminal 3").replace("Gate E76", "Gate B22"),
            "-f61": store_page.replace("Duty Free by Dufry", "Duty Free Liquor by Dufry"),
        })
        reading = base.collect(collector, "YYZ", fetcher=pages, robots_fetcher=robots_answering(200, "User-agent: *\nDisallow: /upload/\n"), now=NOW)
        assert pages.asked[0].endswith("/sitemap_yyz_en.xml") and len(pages.asked) == 4
        assert all("/archived-" not in url for url in pages.asked)
        assert [s.terminal for s in reading.stores] == ["T1", "T1", "T3"]
        assert reading.text == (
            "3 duty free stores across 2 terminals. "
            "Duty Free by Dufry: open 3 hours before flights. Closed with gaps of more than 3 hours between flights. "
            "Duty Free Liquor by Dufry: open 3 hours before flights. Closed with gaps of more than 3 hours between flights."
        )
        assert reading.warnings == []

    def test_a_followed_page_a_disallow_covers_refuses_the_run(self):
        """Permission is per path: the sitemap may be open while the store pages are not."""
        collector = TorontoPearsonHours()
        pages = Pages({"/sitemap_yyz_en.xml": (FIXTURES / "hours_toronto_sitemap.xml").read_text()})
        robots = robots_answering(200, "User-agent: *\nDisallow: /en/while-you-are-here/\n")
        with pytest.raises(SourceBlocked):
            base.collect(collector, "YYZ", fetcher=pages, robots_fetcher=robots)
        assert len(pages.asked) == 1

    def test_the_page_cap_bounds_a_run_and_says_so(self, monkeypatch):
        monkeypatch.setattr(base, "MAX_PAGES", 2)
        collector = TorontoPearsonHours()
        store_page = (FIXTURES / "hours_toronto.html").read_text()
        pages = Pages({"/sitemap_yyz_en.xml": (FIXTURES / "hours_toronto_sitemap.xml").read_text(), "-f61": store_page, "-e76": store_page, "-b22": store_page})
        reading = base.collect(collector, "YYZ", fetcher=pages, robots_fetcher=robots_answering(200, ""), now=NOW)
        assert len(pages.asked) == 2 and len(reading.stores) == 1
        assert reading.warnings == ["2 pages not read: the run's cap is 2"]
