"""v0.20.0 — `/api/docs/specs/*` endpoints.

Sibling of `test_docs_sessions_vertical.py`. The framework mediates
reads of the configured framework-spec URLs (default: rfc-app's own
SPEC.md + flotilla's SPEC.md on `git.wiggleverse.org`) so the
`/docs/specs/*` surface inherits the same chrome as
`/docs/user-guide` and `/docs/sessions/*`.

This file covers:

  - The manifest endpoint with the framework default
  - The manifest endpoint with an overridden `OHM_DOCS_SPECS` JSON value
  - Slug validation at the route layer (rejects `..`, `/`, `~`,
    uppercase, whitespace, path traversal attempts)
  - Gitea 200 / 404 / 5xx response mapping
  - Negative caching (404 is cached, not re-fetched within TTL)
  - Malformed `OHM_DOCS_SPECS` → fallback to the default + a logged
    warning (asserted by caplog)
  - A manifest entry that fails per-entry validation (bad slug,
    missing field) is dropped, with the rest of the list retained

Mocking approach: same as docs_sessions — `httpx.MockTransport`
substituted into `app.docs_specs.httpx.AsyncClient` via a fixture.
"""
from __future__ import annotations

import json
import logging

import httpx
import pytest
from fastapi.testclient import TestClient

from app import docs_specs

# Reuse the proven app-construction fixtures from the proposal vertical
# (same shape every test file in this repo uses).
from test_propose_vertical import (  # noqa: F401
    app_with_fake_gitea,
    tmp_env,
)


# ---------------------------------------------------------------------------
# Test scaffolding
# ---------------------------------------------------------------------------


class _UpstreamHandler:
    """Records every URL the docs_specs module fetched and returns
    canned responses keyed by URL substring. Lets the test assert on
    call count (for cache verification) without booting a full upstream
    simulator.

    `calls` tracks only URLs that hit a host configured in the spec
    manifest under test — so unrelated httpx clients (gitea-side
    fixtures, etc.) don't inflate the count we use for cache-hit
    assertions. We marker-match on substrings the manifest carries.
    """

    def __init__(
        self,
        responses: dict[str, tuple[int, str]],
        host_markers: tuple[str, ...] = ("rfc-app", "flotilla", "specs.example"),
    ):
        self.responses = responses
        self.host_markers = host_markers
        self.calls: list[str] = []

    def __call__(self, request: httpx.Request) -> httpx.Response:
        url = str(request.url)
        if any(m in url for m in self.host_markers):
            self.calls.append(url)
        for key, (status, body) in self.responses.items():
            if key in url:
                return httpx.Response(status, text=body)
        # Default: 404. Lets tests skip declaring "the rest is 404".
        return httpx.Response(404, text="not found")


@pytest.fixture
def patched_httpx(monkeypatch, app_with_fake_gitea):  # noqa: F811
    """Provide a hook the test can call to install a MockTransport.

    M3 note: lifespan now calls `refresh_registry` which hits FakeGitea
    via the gitea transport. Since `httpx` is a module singleton, installing
    the docs transport would clobber the FakeGitea mock already installed
    by `app_with_fake_gitea`. We use a COMPOSITE handler: Gitea API
    requests (to `http://gitea.test/`) are delegated to FakeGitea; all
    other requests go to the test-specific handler.
    """
    from httpx._client import AsyncClient as RealAsyncClient

    _fake = app_with_fake_gitea[1]

    def install(handler):
        def composite(request: httpx.Request) -> httpx.Response:
            if "gitea.test" in str(request.url):
                return _fake.handle(request)
            return handler(request)

        def patched(*args, **kwargs):
            kwargs["transport"] = httpx.MockTransport(composite)
            return RealAsyncClient(*args, **kwargs)

        monkeypatch.setattr("app.docs_specs.httpx.AsyncClient", patched)
        return handler

    yield install


@pytest.fixture
def app(app_with_fake_gitea):
    """Reset the docs-specs cache so cross-test state can't leak."""
    docs_specs.reset_cache()
    fastapi_app, _fake = app_with_fake_gitea
    return fastapi_app


# ---------------------------------------------------------------------------
# Manifest endpoint
# ---------------------------------------------------------------------------


def test_manifest_default(app, monkeypatch):
    """With `OHM_DOCS_SPECS` unset, the manifest endpoint returns the
    framework default (rfc-app + flotilla).
    """
    monkeypatch.delenv("OHM_DOCS_SPECS", raising=False)
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/manifest")
        assert r.status_code == 200, r.text
        payload = r.json()
        assert "specs" in payload
        names = [s["name"] for s in payload["specs"]]
        assert names == ["rfc-app", "flotilla"]
        # The default URLs point at the OHM-canonical gitea raw paths.
        assert all("git.wiggleverse.org" in s["url"] for s in payload["specs"])


def test_manifest_overridden(app, monkeypatch):
    """A deployment overriding `OHM_DOCS_SPECS` gets its custom list.

    The manifest is parsed per-request from the env var (no startup
    binding) so a runtime overlay change is visible without a
    restart — same shape as the docs_sessions env knobs.
    """
    custom = json.dumps(
        [
            {
                "name": "custom-spec",
                "title": "Custom Spec",
                "url": "https://specs.example.org/CUSTOM.md",
            }
        ]
    )
    monkeypatch.setenv("OHM_DOCS_SPECS", custom)
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/manifest")
        assert r.status_code == 200, r.text
        payload = r.json()
        assert payload == {
            "specs": [
                {
                    "name": "custom-spec",
                    "title": "Custom Spec",
                    "url": "https://specs.example.org/CUSTOM.md",
                }
            ]
        }


def test_manifest_malformed_json_falls_back(app, monkeypatch, caplog):
    """A non-JSON value in `OHM_DOCS_SPECS` logs a warning and the
    endpoint falls back to the framework default. Startup is
    unaffected — the deployment continues to render the spec surface
    rather than crashing on the typo.
    """
    monkeypatch.setenv("OHM_DOCS_SPECS", "{not-json")
    with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
        with TestClient(app) as client:
            r = client.get("/api/docs/specs/manifest")
            assert r.status_code == 200, r.text
            payload = r.json()
            names = [s["name"] for s in payload["specs"]]
            assert names == ["rfc-app", "flotilla"]
    assert any(
        "OHM_DOCS_SPECS is not valid JSON" in rec.message
        for rec in caplog.records
    ), f"expected a logged warning; got {[r.message for r in caplog.records]}"


def test_manifest_non_list_falls_back(app, monkeypatch, caplog):
    """`OHM_DOCS_SPECS` must be a JSON array. A JSON object (or any
    non-list value) falls back to the default + logs a warning.
    """
    monkeypatch.setenv("OHM_DOCS_SPECS", json.dumps({"name": "not-a-list"}))
    with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
        with TestClient(app) as client:
            r = client.get("/api/docs/specs/manifest")
            assert r.status_code == 200, r.text
            names = [s["name"] for s in r.json()["specs"]]
            assert names == ["rfc-app", "flotilla"]
    assert any(
        "must be a JSON array" in rec.message for rec in caplog.records
    )


def test_manifest_drops_invalid_entry_keeps_valid(app, monkeypatch, caplog):
    """Per-entry validation: an entry with a bad slug or missing field
    is dropped; valid entries in the same list are retained.
    """
    custom = json.dumps(
        [
            {"name": "Bad Slug", "title": "Bad", "url": "https://x"},  # uppercase + space
            {"name": "..", "title": "Traversal", "url": "https://x"},  # path traversal
            {"name": "missing-url", "title": "Missing URL"},  # no url
            {
                "name": "good-spec",
                "title": "Good",
                "url": "https://specs.example.org/GOOD.md",
            },
        ]
    )
    monkeypatch.setenv("OHM_DOCS_SPECS", custom)
    with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
        with TestClient(app) as client:
            r = client.get("/api/docs/specs/manifest")
            assert r.status_code == 200, r.text
            names = [s["name"] for s in r.json()["specs"]]
            assert names == ["good-spec"]
    # Three drop warnings (one per bad entry).
    drops = [r for r in caplog.records if "failed validation" in r.message]
    assert len(drops) == 3


def test_manifest_all_invalid_falls_back(app, monkeypatch, caplog):
    """If every entry is dropped, the framework default applies (the
    surface never goes empty due to a bad overlay).
    """
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps([{"name": "BAD"}, {"name": "..", "title": "x", "url": "y"}]),
    )
    with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
        with TestClient(app) as client:
            r = client.get("/api/docs/specs/manifest")
            assert r.status_code == 200, r.text
            names = [s["name"] for s in r.json()["specs"]]
            assert names == ["rfc-app", "flotilla"]
    assert any(
        "yielded no valid entries" in rec.message for rec in caplog.records
    )


def test_manifest_drops_duplicate_names(app, monkeypatch, caplog):
    """A duplicate `name` is dropped (the first occurrence wins). The
    route layer's `/api/docs/specs/{name}` path lookup is by name, so
    duplicates would otherwise be ambiguous.
    """
    custom = json.dumps(
        [
            {"name": "x", "title": "First", "url": "https://specs.example/1"},
            {"name": "x", "title": "Second", "url": "https://specs.example/2"},
        ]
    )
    monkeypatch.setenv("OHM_DOCS_SPECS", custom)
    with caplog.at_level(logging.WARNING, logger="app.docs_specs"):
        with TestClient(app) as client:
            r = client.get("/api/docs/specs/manifest")
            payload = r.json()
            assert [s["title"] for s in payload["specs"]] == ["First"]
    assert any("duplicate name" in rec.message for rec in caplog.records)


# ---------------------------------------------------------------------------
# Spec endpoint — happy + error paths
# ---------------------------------------------------------------------------


def test_spec_happy_path(app, patched_httpx, monkeypatch):
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "rfc-app",
                    "title": "rfc-app SPEC",
                    "url": "https://specs.example.org/rfc-app/SPEC.md",
                }
            ]
        ),
    )
    body = "# rfc-app SPEC\n\nSection 1...\n"
    patched_httpx(_UpstreamHandler({"rfc-app/SPEC.md": (200, body)}))
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/rfc-app")
        assert r.status_code == 200, r.text
        assert "text/markdown" in r.headers["content-type"]
        assert r.text == body


def test_spec_upstream_404(app, patched_httpx, monkeypatch):
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "missing-spec",
                    "title": "Missing",
                    "url": "https://specs.example.org/missing.md",
                }
            ]
        ),
    )
    patched_httpx(_UpstreamHandler({}))  # everything 404s
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/missing-spec")
        assert r.status_code == 404, r.text


def test_spec_upstream_5xx_returns_502(app, patched_httpx, monkeypatch):
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "broken-spec",
                    "title": "Broken",
                    "url": "https://specs.example.org/broken.md",
                }
            ]
        ),
    )
    patched_httpx(_UpstreamHandler({"broken.md": (500, "internal")}))
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/broken-spec")
        assert r.status_code == 502, r.text
        body = r.json()
        assert body["detail"]["error"] == "specs fetch failed"


def test_spec_unknown_name_returns_404(app, patched_httpx, monkeypatch):
    """A name that doesn't appear in the manifest returns 404 without
    touching the network. The handler treats "no such configured spec"
    and "upstream 404" as the same outcome — both render the same
    "spec not found" empty state on the frontend.
    """
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "rfc-app",
                    "title": "rfc-app",
                    "url": "https://specs.example.org/x.md",
                }
            ]
        ),
    )
    handler = _UpstreamHandler({})
    patched_httpx(handler)
    with TestClient(app) as client:
        r = client.get("/api/docs/specs/does-not-exist")
        assert r.status_code == 404, r.text
    assert handler.calls == [], "unknown-name lookup must not hit the network"


# ---------------------------------------------------------------------------
# Spec endpoint — slug validation
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
    "raw_name",
    [
        "UPPER",  # uppercase
        "spaces here",  # whitespace (post-decoding)
        "with~tilde",  # tilde
        "with.dot",  # dot
        "with_under",  # underscore (not allowed by [a-z0-9-]+)
    ],
)
def test_spec_rejects_invalid_name(app, patched_httpx, raw_name):
    """Names that don't match `^[a-z0-9-]+$` are rejected with 400 at
    the route layer before any network or cache work.

    Note: `..` is intentionally not in this list because the URL-
    parsing layer collapses `/api/docs/specs/..` to `/api/docs/specs`
    before the handler is reached — the path-traversal protection is
    therefore framework-level (httpx/urllib's path normalizer) rather
    than route-layer. The slug-validation guard still rejects any
    `..` that *would* reach the handler (e.g. via an env-configured
    manifest entry); see `test_manifest_drops_invalid_entry_keeps_valid`
    for that path.
    """
    handler = _UpstreamHandler({})
    patched_httpx(handler)
    with TestClient(app) as client:
        from urllib.parse import quote

        r = client.get(f"/api/docs/specs/{quote(raw_name, safe='')}")
        assert r.status_code == 400, r.text
    assert handler.calls == [], "rejected name must not hit the network"


def test_spec_rejects_slash_in_name(app, patched_httpx):
    """A literal `/` in the path can't make it through the path
    parameter — FastAPI routes it as a separate segment. The check
    here is that the request never reaches an upstream fetch.
    """
    handler = _UpstreamHandler({})
    patched_httpx(handler)
    with TestClient(app) as client:
        # `/api/docs/specs/sub/path` — the second segment makes this
        # not match the `/{name}` route at all; FastAPI returns 404.
        r = client.get("/api/docs/specs/sub/path")
        assert r.status_code == 404, r.text
    assert handler.calls == [], "non-matching path must not hit the network"


# ---------------------------------------------------------------------------
# Cache behavior
# ---------------------------------------------------------------------------


def test_spec_cache_hits_within_ttl(app, patched_httpx, monkeypatch):
    monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "rfc-app",
                    "title": "rfc-app",
                    "url": "https://specs.example.org/rfc-app/SPEC.md",
                }
            ]
        ),
    )
    handler = _UpstreamHandler({"rfc-app/SPEC.md": (200, "# body\n")})
    patched_httpx(handler)
    with TestClient(app) as client:
        r1 = client.get("/api/docs/specs/rfc-app")
        r2 = client.get("/api/docs/specs/rfc-app")
    assert r1.status_code == 200
    assert r2.status_code == 200
    assert len(handler.calls) == 1, (
        f"expected one upstream call, got {handler.calls}"
    )


def test_spec_404_is_cached(app, patched_httpx, monkeypatch):
    """Negative caching: a 404 result is cached at the content TTL so
    a deployment with a misconfigured spec URL doesn't hammer the
    upstream on every navigation.
    """
    monkeypatch.setenv("OHM_DOCS_SPECS_CONTENT_TTL_SEC", "300")
    monkeypatch.setenv(
        "OHM_DOCS_SPECS",
        json.dumps(
            [
                {
                    "name": "missing-spec",
                    "title": "Missing",
                    "url": "https://specs.example.org/missing.md",
                }
            ]
        ),
    )
    handler = _UpstreamHandler({})  # everything 404s
    patched_httpx(handler)
    with TestClient(app) as client:
        r1 = client.get("/api/docs/specs/missing-spec")
        r2 = client.get("/api/docs/specs/missing-spec")
    assert r1.status_code == 404
    assert r2.status_code == 404
    assert len(handler.calls) == 1, "negative caching should suppress the 2nd call"
