"""Vertical + unit coverage for roadmap #27 (rfc-app v0.24.0): Claude
Haiku tag suggestions on the propose-RFC modal.

Reuses the FakeGitea + session helpers from test_propose_vertical.py.
The Anthropic call is never made for real — tests monkeypatch the
`tag_suggest.haiku_provider` seam with a stub provider whose `send`
returns canned text, so the HTTP contract is exercised without a key.

Proves:
  (a) the endpoint is contributor-gated (anon → 401);
  (b) a contributor gets suggestions, filtered to the corpus tag
      universe, with invented tags dropped;
  (c) no Anthropic key bound → empty list, not an error;
  (d) an empty corpus → empty list (model is never even called);
  (e) the per-user rate limit surfaces as a 429;
plus unit coverage of the universe gather, the reply parser's tolerance,
and the suggest() orchestration short-circuits.
"""
from __future__ import annotations

import json

import pytest

from test_propose_vertical import (  # noqa: F401
    FakeGitea,
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


class StubProvider:
    """A BaseProvider stand-in whose send() returns a fixed string (or
    raises, to exercise the graceful-failure path)."""

    def __init__(self, reply: str = "[]", *, raises: bool = False):
        self.reply = reply
        self.raises = raises
        self.calls: list[tuple[str, list]] = []

    def send(self, system, history):
        self.calls.append((system, history))
        if self.raises:
            raise RuntimeError("boom")
        return self.reply


def _seed_tags(slug: str, title: str, tags: list[str], state: str = "active") -> None:
    from app import db

    db.conn().execute(
        "INSERT OR REPLACE INTO cached_rfcs (slug, title, state, tags_json) VALUES (?, ?, ?, ?)",
        (slug, title, state, json.dumps(tags)),
    )


def _use_provider(monkeypatch, provider) -> None:
    monkeypatch.setattr("app.tag_suggest.haiku_provider", lambda config: provider)


@pytest.fixture(autouse=True)
def _reset_rate_limits():
    from app import tag_suggest

    tag_suggest.reset_rate_limits()
    yield
    tag_suggest.reset_rate_limits()


# ---------------------------------------------------------------------------
# Endpoint (vertical)
# ---------------------------------------------------------------------------


def test_anonymous_cannot_suggest(app_with_fake_gitea):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        r = client.post("/api/rfcs/suggest-tags", json={"title": "X"})
        assert r.status_code == 401


def test_contributor_gets_filtered_suggestions(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        _seed_tags("ohm", "OHM", ["identity", "schema", "consent"])
        _seed_tags("other", "Other", ["identity", "governance"])

        # Model returns two real tags (one lowercased to test canonical
        # mapping is exact-set anyway), plus one invented tag that MUST
        # be dropped.
        reply = json.dumps([
            {"tag": "identity", "confidence": 0.9},
            {"tag": "consent", "confidence": 0.7},
            {"tag": "totally-invented", "confidence": 0.99},
        ])
        stub = StubProvider(reply=reply)
        _use_provider(monkeypatch, stub)

        r = client.post("/api/rfcs/suggest-tags", json={
            "title": "Consent and identity",
            "pitch": "We need a shared definition of consent tied to identity.",
        })
        assert r.status_code == 200, r.text
        tags = [s["tag"] for s in r.json()["suggestions"]]
        assert tags == ["identity", "consent"]
        # the model was actually invoked
        assert len(stub.calls) == 1
        # the universe (deduped distinct tags) was handed to the model
        user_msg = stub.calls[0][1][0]["content"]
        assert "identity" in user_msg and "governance" in user_msg


def test_no_api_key_returns_empty(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        _seed_tags("ohm", "OHM", ["identity"])

        # No key bound (test env has no ANTHROPIC_API_KEY) → provider None.
        # (Explicitly assert the seam returns None given the test config.)
        from app import tag_suggest
        assert tag_suggest.haiku_provider(app.state.config) is None

        r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
        assert r.status_code == 200, r.text
        assert r.json()["suggestions"] == []


def test_empty_corpus_returns_empty_without_calling_model(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")

        stub = StubProvider(reply=json.dumps([{"tag": "x", "confidence": 1}]))
        _use_provider(monkeypatch, stub)

        # No cached_rfcs rows → empty universe → suggest() short-circuits.
        r = client.post("/api/rfcs/suggest-tags", json={"title": "X", "pitch": "y"})
        assert r.status_code == 200, r.text
        assert r.json()["suggestions"] == []
        assert stub.calls == []  # model never invoked on an empty universe


def test_rate_limit_surfaces_429(app_with_fake_gitea, monkeypatch):
    from fastapi.testclient import TestClient

    monkeypatch.setenv("TAG_SUGGEST_RATE_MAX", "2")
    monkeypatch.setenv("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60")

    app, _fake = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="alice", role="contributor")
        sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
        _seed_tags("ohm", "OHM", ["identity"])
        _use_provider(monkeypatch, StubProvider(reply="[]"))

        body = {"title": "X", "pitch": "y"}
        assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
        assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 200
        # Third call inside the window trips the limit.
        assert client.post("/api/rfcs/suggest-tags", json=body).status_code == 429


# ---------------------------------------------------------------------------
# Units
# ---------------------------------------------------------------------------


def test_gather_tag_universe_dedupes_and_ranks(app_with_fake_gitea):
    from fastapi.testclient import TestClient
    from app import tag_suggest

    app, _fake = app_with_fake_gitea
    # The db is initialized in the app's lifespan; enter the client
    # context so cached_rfcs exists before we seed it directly.
    with TestClient(app):
        _seed_tags("a", "A", ["identity", "schema"])
        _seed_tags("b", "B", ["identity", " schema ", "consent", ""])  # whitespace + empty
        _seed_tags("c", "C", ["identity"])

        universe = tag_suggest.gather_tag_universe()
        # identity (3) > schema (2) > consent (1); whitespace trimmed/merged,
        # empties dropped.
        assert universe == ["identity", "schema", "consent"]


def test_parse_reply_tolerates_junk():
    from app import tag_suggest

    universe = ["identity", "schema", "consent"]

    # Prose around the JSON, an invented tag, a bare string, a dup, a
    # missing confidence, and a garbage confidence.
    text = (
        "Sure! Here are the tags:\n"
        '[{"tag": "identity", "confidence": 0.9}, '
        '{"tag": "invented", "confidence": 1}, '
        '"schema", '
        '{"tag": "identity", "confidence": 0.5}, '
        '{"tag": "consent"}, '
        '{"tag": "consent", "confidence": "high"}]\n'
        "Hope that helps!"
    )
    out = tag_suggest.parse_reply(text, universe, max_suggestions=6)
    tags = [s["tag"] for s in out]
    assert tags == ["identity", "schema", "consent"]  # invented dropped, deduped
    by_tag = {s["tag"]: s["confidence"] for s in out}
    assert by_tag["identity"] == 0.9
    assert by_tag["schema"] == 0.5      # bare string defaults to 0.5
    assert by_tag["consent"] == 0.5     # missing/garbage confidence → 0.5


def test_parse_reply_empty_on_unparseable():
    from app import tag_suggest

    assert tag_suggest.parse_reply("no json here", ["a"], 6) == []
    assert tag_suggest.parse_reply("", ["a"], 6) == []
    assert tag_suggest.parse_reply("[]", ["a"], 6) == []


def test_parse_reply_respects_max():
    from app import tag_suggest

    universe = ["a", "b", "c", "d", "e"]
    text = json.dumps([{"tag": t, "confidence": 0.5} for t in universe])
    out = tag_suggest.parse_reply(text, universe, max_suggestions=3)
    assert [s["tag"] for s in out] == ["a", "b", "c"]


def test_suggest_short_circuits_empty_draft():
    from app import tag_suggest

    stub = StubProvider(reply=json.dumps([{"tag": "a", "confidence": 1}]))
    draft = tag_suggest.Draft(title="  ", pitch="", use_case="")
    assert tag_suggest.suggest(stub, draft, ["a"]) == []
    assert stub.calls == []  # never called for an empty draft


def test_suggest_returns_empty_on_provider_failure():
    from app import tag_suggest

    stub = StubProvider(raises=True)
    draft = tag_suggest.Draft(title="Real title", pitch="a reason")
    assert tag_suggest.suggest(stub, draft, ["identity"]) == []
