"""Auth suite — walk-every-route default-deny (BW Auth Pattern B, session-based).

v1's 2026-07-02 audit found 12 unguarded /api routes because its middleware
skipped /api/* wholesale. This suite keeps that class of bug impossible: it
walks every registered route and asserts non-allowlisted DATA paths reject
unauthenticated requests. The gate is now the app's own signed session cookie
(the shim's X-Auth-Grant is retired); the agent Bearer path is preserved.
"""
from __future__ import annotations

import pytest
from fastapi.routing import APIRoute
from starlette.testclient import TestClient

from app.app_session import SESSION_COOKIE, sign_session, verify_session
from app.auth import _is_public
from app.config import get_settings
from app.main import create_app

SECRET = "test-session-secret-for-auth-suite"


@pytest.fixture()
def client(monkeypatch):
    monkeypatch.setenv("APP_SESSION_SECRET", SECRET)
    monkeypatch.setenv("BW_CLIENT_ID", "garden2-test")
    monkeypatch.setenv("BW_APP_DOMAIN", "https://garden2.test")
    monkeypatch.setenv("DOMAIN_MODE", "public")
    get_settings.cache_clear()
    app = create_app()
    with TestClient(app, raise_server_exceptions=False) as c:
        yield c
    get_settings.cache_clear()


def cookies(user: str = "rian", ttl: int = 3600, secret: str = SECRET) -> dict:
    return {SESSION_COOKIE: sign_session(user, secret, ttl)}


def test_every_data_route_denied_without_session(client):
    """Walk app.routes: every non-allowlisted /api or /notes route 401s bare.
    (The middleware default-denies BEFORE routing, so unfilled path params
    still 401 rather than 404.)"""
    app = client.app
    checked = 0
    for route in app.routes:
        if not isinstance(route, APIRoute):
            continue
        path = route.path
        if "{full_path" in path:  # the public SPA catch-all
            path = "/some/spa/route"
        if _is_public(path):
            continue
        method = next(iter(route.methods - {"HEAD", "OPTIONS"}), "GET")
        resp = client.request(method, path)
        assert resp.status_code == 401, f"{method} {path}: {resp.status_code}"
        assert resp.json()["error"]["code"] == "auth_session_missing", path
        checked += 1
    assert checked >= 3  # sanity: the walk actually walked something


def test_health_is_public(client):
    assert client.get("/api/health").status_code == 200


def test_me_public_anonymous(client):
    """/api/me is public and reports authenticated=False for an anon visitor."""
    r = client.get("/api/me")
    assert r.status_code == 200
    body = r.json()
    assert body["authenticated"] is False
    assert body["username"] is None


def test_valid_session_passes(client):
    r = client.get("/api/version", cookies=cookies())
    assert r.status_code == 200
    assert r.json()["version"]


def test_me_returns_identity_with_session(client):
    r = client.get("/api/me", cookies=cookies(user="rian"))
    assert r.status_code == 200
    body = r.json()
    assert body["authenticated"] is True
    assert body["username"] == "rian"


def test_expired_session_rejected(client):
    r = client.get("/api/version", cookies=cookies(ttl=-5))
    assert r.status_code == 401
    assert r.json()["error"]["code"] == "auth_session_missing"


def test_forged_session_rejected(client):
    r = client.get("/api/version", cookies=cookies(secret="wrong-secret"))
    assert r.status_code == 401
    assert r.json()["error"]["code"] == "auth_session_missing"


def test_malformed_sessions_rejected(client):
    for bad in ("", "just-a-string", "a|b", "user|notanumber|sig", "u||", "|1|x"):
        r = client.get("/api/version", cookies={SESSION_COOKIE: bad})
        assert r.status_code == 401, bad


def test_unconfigured_secret_hard_closes(monkeypatch):
    monkeypatch.delenv("APP_SESSION_SECRET", raising=False)
    get_settings.cache_clear()
    app = create_app()
    with TestClient(app) as c:
        r = c.get("/api/version")
    assert r.status_code == 503  # misconfig = closed, never open
    get_settings.cache_clear()


def test_agent_bearer_bypasses_session_gate(client, monkeypatch):
    """A valid agent Bearer token on an export path is NOT rejected by the
    session gate (auth is preserved for CLI/agents). Bare it still 401s."""
    monkeypatch.setenv("AGENT_API_TOKEN", "test-agent-token")
    # Bare (no token, no session) → session_missing.
    bare = client.get("/api/export/media-manifest")
    assert bare.status_code == 401
    assert bare.json()["error"]["code"] == "auth_session_missing"
    # With the token → past the gate (not a session_missing 401). It may 500
    # without a DB, but the point is the middleware admitted it.
    ok = client.get(
        "/api/export/media-manifest",
        headers={"Authorization": "Bearer test-agent-token"},
    )
    assert not (
        ok.status_code == 401
        and ok.json().get("error", {}).get("code") == "auth_session_missing"
    )


def test_session_sign_verify_unit():
    token = sign_session("rian", SECRET, 60)
    assert verify_session(token, SECRET) == "rian"
    assert verify_session(sign_session("rian", SECRET, -5), SECRET) is None  # expired
    assert verify_session(token, "other-secret") is None  # forged
    assert verify_session("garbage", SECRET) is None
