"""`scripts/route-sweep.py` expects what `access.decide()` says, and fails on a route that
answers 200 with no session.

What it cost before: `docs/RUNBOOK.md` step 18, the go-live check that the members-only and
owner routes are still closed on the public host, named `main/scripts/route-sweep.sh` for
weeks while the file did not exist. The fallback written beside it was four `curl` lines
against `/api/review/sheets`, `/api/collectors`, `/api/plan` and `/api/items`: four of the
243 keys the policy names, so a route opened by a bad merge anywhere else would have gone
to the public site unnoticed on launch day.

Two things are pinned here. The expectation for every key is read out of `access.decide()`
itself, never a table typed beside the policy, because a hand-typed copy is exactly how a
checker comes to agree with a bug. And a route that discloses itself has to change the exit
code, or a launch step cannot depend on the script.
"""

from __future__ import annotations

import importlib.util
import pathlib
import sys

import pytest

from app import main as app_main
from app.services import access

SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "route-sweep.py"


def load():
    spec = importlib.util.spec_from_file_location("route_sweep", SCRIPT)
    module = importlib.util.module_from_spec(spec)
    # Registered before it runs: the script postpones its annotations, and a dataclass in a
    # module that is not in sys.modules cannot resolve them.
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


@pytest.fixture
def sweep():
    """The script, with anything it mounts on the real app taken off again afterwards."""
    before = len(app_main.app.routes)
    roots = access.ROOT_FILES
    try:
        yield load()
    finally:
        del app_main.app.routes[before:]
        access.ROOT_FILES = roots


def anonymous(key: str, page: str | None, *, site_open: bool, live: bool) -> int | None:
    """What an anonymous GET must get, straight from the policy."""
    verdict = access.decide(
        method="GET", key=key, page=page, site_open=site_open,
        signed_in=False, active=False, must_change=False,
        holds=lambda _permission: False, read_only=False, origin_ok=True,
        wants_html=access.wants_html(key), live=live,
    )
    return None if verdict is None else verdict.status


@pytest.mark.parametrize("site_open,live", [(False, False), (True, False), (True, True)])
def test_every_expectation_is_what_decide_says(sweep, site_open, live):
    rows = sweep.plan(sweep.build_app(), site_open=site_open, live=live)
    assert len(rows) > 200, "the walk stopped seeing routes"
    for row in rows:
        if row.verdict == sweep.SKIPPED:
            continue
        key = row.key.split(" -> ")[0]
        assert row.expected == anonymous(key, row.page, site_open=site_open, live=live), row.key


def test_the_sweep_plans_reads_only(sweep):
    for row in sweep.plan(sweep.build_app(), site_open=False, live=False):
        if row.verdict == sweep.SKIPPED:
            assert not row.key.startswith(("GET ", "MOUNT ")), row.key
        else:
            assert row.key.startswith(("GET ", "MOUNT ")), row.key


def test_a_new_permission_route_is_swept_and_expected_to_refuse(sweep, monkeypatch):
    """A key the policy gains is swept without the script being edited: the plan reads the
    classes, so a route added to PERMISSION next month is covered the day it lands."""
    monkeypatch.setitem(access.PERMISSION, "GET /api/aw7-probe", "sources.manage")
    rows = {r.key: r for r in sweep.plan(sweep.build_app(), site_open=False, live=False)}
    assert rows["GET /api/aw7-probe"].cls == "permission"
    assert rows["GET /api/aw7-probe"].expected == 401


def answers(sweep, rows, *, leaking: str | None = None):
    """A server that answers exactly what is expected, except at one path if named."""
    want = {}
    for row in rows:
        if row.verdict == sweep.SKIPPED:
            continue
        want[row.path] = 200 if row.expected is None else row.expected

    def fake_fetch(base, path, timeout):
        if leaking is not None and path == leaking:
            return 200, {"Via": "1.1 Caddy", "Cache-Control": "no-store"}, ""
        status = want[path]
        headers = {"Via": "1.1 Caddy", "Cache-Control": "no-store"}
        if status == 302:
            headers["Location"] = "/login?next=%2Fx"
        return status, headers, ""

    return fake_fetch


def test_a_clean_server_exits_zero(sweep, monkeypatch, capsys):
    rows = sweep.plan(sweep.build_app(), site_open=False, live=False)
    monkeypatch.setattr(sweep, "fetch", answers(sweep, rows))
    code = sweep.main(["https://example.test", "--staging"])
    out = capsys.readouterr().out
    assert code == 0, out
    assert sweep.LEAK not in out


def test_a_permission_route_answering_200_makes_the_exit_code_non_zero(sweep, monkeypatch, capsys):
    """The finding the script exists for: an owner route reachable with no session."""
    monkeypatch.setitem(access.PERMISSION, "GET /api/aw7-probe", "sources.manage")
    rows = sweep.plan(sweep.build_app(), site_open=False, live=False)
    monkeypatch.setattr(sweep, "fetch", answers(sweep, rows, leaking="/api/aw7-probe"))
    code = sweep.main(["https://example.test", "--staging"])
    out = capsys.readouterr().out
    assert code == 2, out
    assert "LEAK     GET /api/aw7-probe" in out


def test_a_redirect_somewhere_other_than_the_sign_in_page_is_a_leak(sweep, monkeypatch, capsys):
    """A 302 is only a refusal when it goes to our own sign-in; a 302 to anywhere else is
    an internal route handing an anonymous caller a destination it should not know."""
    rows = sweep.plan(sweep.build_app(), site_open=False, live=False)
    base = answers(sweep, rows)

    def fake_fetch(_base, path, timeout):
        if path == "/api/collectors/live":
            return 302, {"Location": "/collectors#live"}, ""
        return base(_base, path, timeout)

    monkeypatch.setattr(sweep, "fetch", fake_fetch)
    code = sweep.main(["https://example.test", "--staging"])
    assert code == 2, capsys.readouterr().out


def test_the_site_gate_answering_is_reported_as_the_gate_and_not_as_a_pass(sweep, monkeypatch, capsys):
    """A 302 to the sign-on host means id-auth answered and the app's own answer is
    unknown; calling that a pass would let a gated host hide an open route."""
    rows = sweep.plan(sweep.build_app(), site_open=False, live=False)
    base = answers(sweep, rows)

    def fake_fetch(_base, path, timeout):
        if path == "/api/collectors/live":
            return 302, {"Location": "https://auth.bowden.works/bw-login?next=x"}, ""
        return base(_base, path, timeout)

    monkeypatch.setattr(sweep, "fetch", fake_fetch)
    sweep.main(["https://example.test", "--staging"])
    out = capsys.readouterr().out
    assert "gate     GET /api/collectors/live" in out
    assert "the site gate answered" in out
