"""Roadmap #27 — Claude Haiku tag suggestions for the propose-RFC modal.

A cheap, fast assist: given the partial RFC draft a user is typing, ask
Claude Haiku to recommend tags drawn ONLY from the tags already in use
across the corpus. v1 has no curated tag list — tags are free-form chip
input (§9.1) — so "the taxonomy" is the de-facto set of distinct tags
the existing RFCs already carry. The model is constrained to that set
and MUST NOT invent new tags; taxonomy extension (letting the model
propose genuinely new tags) is a deferred follow-up per the roadmap.

Why Haiku specifically: cost. Picking a few reasonable tags from a known
set is well within Haiku's range, and the modal fires this repeatedly as
the user types, so the per-call price has to stay small.

The whole surface degrades to silence rather than error: no Anthropic
key, no provider, an empty corpus, a rate-limited caller, an empty
draft, or an unparseable model reply all yield an empty suggestion list.
The propose modal hides its suggestion row on an empty list, so the
fallback is simply the existing free-form chip input with nothing extra
shown.
"""
from __future__ import annotations

import json
import logging
import os
import time
from dataclasses import dataclass

from . import db
from .providers import BaseProvider, construct_haiku

log = logging.getLogger(__name__)

# Bound the universe handed to the model so a large corpus can't blow up
# the prompt size (and the cost). The most-common tags matter most.
_UNIVERSE_CAP = 200

# How many suggestions we ever return to the modal.
DEFAULT_MAX_SUGGESTIONS = 6


@dataclass
class Draft:
    """The partial propose-RFC draft. `pitch` is the "why is this needed"
    rationale; `use_case` is the #26 optional ground-truth field."""

    title: str = ""
    pitch: str = ""
    use_case: str = ""

    def is_empty(self) -> bool:
        return not (self.title.strip() or self.pitch.strip() or self.use_case.strip())


def haiku_provider(config) -> BaseProvider | None:
    """Construct a dedicated Claude Haiku provider from the operator's
    Anthropic key, or return None when no key is configured.

    None means "suggestions unavailable" — the caller returns an empty
    list and the modal shows nothing. This is the seam tests monkeypatch
    to inject a fake provider without a real key. There is no RFC slug at
    propose time, so the §6.7 per-RFC funder path deliberately does not
    apply: tag suggestion always runs on the operator's own key.
    """
    key = getattr(config, "anthropic_api_key", "") or ""
    if not key:
        return None
    return construct_haiku(key)


def gather_tag_universe(cap: int = _UNIVERSE_CAP) -> list[str]:
    """Every distinct tag in use across the cached corpus, most-common
    first (ties broken alphabetically for determinism), capped.

    This is the universe the model is constrained to. An empty corpus
    yields an empty list, which short-circuits suggestion to silence.
    """
    rows = db.conn().execute("SELECT tags_json FROM cached_rfcs").fetchall()
    counts: dict[str, int] = {}
    for r in rows:
        try:
            tags = json.loads(r["tags_json"] or "[]")
        except (ValueError, TypeError):
            continue
        if not isinstance(tags, list):
            continue
        for t in tags:
            if not isinstance(t, str):
                continue
            tag = t.strip()
            if tag:
                counts[tag] = counts.get(tag, 0) + 1
    ranked = sorted(counts, key=lambda t: (-counts[t], t.lower()))
    return ranked[:cap]


# ---------------------------------------------------------------------------
# Rate limiting — in-process, per-user sliding window.
#
# Cost control, not security: the `require_contributor` gate already
# bounds callers to authenticated beta users, and the modal debounces.
# This is the backstop against a stuck/abusive client hammering the
# endpoint. In-memory is sufficient (single-process uvicorn on the VM)
# and resets on restart, which is fine for a cost guard.
# ---------------------------------------------------------------------------

_CALLS: dict[int, list[float]] = {}


def _rate_config() -> tuple[int, float]:
    try:
        max_calls = int(os.environ.get("TAG_SUGGEST_RATE_MAX", "30"))
    except ValueError:
        max_calls = 30
    try:
        window = float(os.environ.get("TAG_SUGGEST_RATE_WINDOW_SECONDS", "60"))
    except ValueError:
        window = 60.0
    return max_calls, window


def rate_limit_ok(user_id: int, *, now: float | None = None) -> bool:
    """True if this call is within the per-user window; records the call.
    `now` is injectable for tests (defaults to a monotonic clock)."""
    max_calls, window = _rate_config()
    t = time.monotonic() if now is None else now
    calls = _CALLS.setdefault(user_id, [])
    cutoff = t - window
    calls[:] = [c for c in calls if c > cutoff]
    if len(calls) >= max_calls:
        return False
    calls.append(t)
    return True


def reset_rate_limits() -> None:
    """Test seam — clear the in-process window state."""
    _CALLS.clear()


# ---------------------------------------------------------------------------
# Prompt + parse.
# ---------------------------------------------------------------------------

_SYSTEM = (
    "You suggest topic tags for a draft RFC — a structured proposal "
    "document in a collection. You are given the draft's title, its "
    "rationale, an optional use case, and the exact set of tags already "
    "in use across the collection. Choose the tags from that set that "
    "best fit the draft.\n"
    "Rules:\n"
    "- Choose ONLY from the provided tag set. Never invent a tag.\n"
    "- Order best-fit first. Omit weak fits rather than padding the list.\n"
    "- Return at most {max} tags.\n"
    "Respond with ONLY a JSON array, no prose, of the form:\n"
    '[{{"tag": "<exact tag from the set>", "confidence": <number between 0 and 1>}}]\n'
    "If no tag in the set fits the draft, return []."
)


def _build_messages(draft: Draft, universe: list[str], max_suggestions: int):
    system = _SYSTEM.format(max=max_suggestions)
    parts: list[str] = []
    if draft.title.strip():
        parts.append(f"Title: {draft.title.strip()[:300]}")
    if draft.pitch.strip():
        parts.append(f"Why this RFC is needed:\n{draft.pitch.strip()[:4000]}")
    if draft.use_case.strip():
        parts.append(f"What it will be used for:\n{draft.use_case.strip()[:4000]}")
    parts.append(
        "Tags already in use (choose only from these):\n" + ", ".join(universe)
    )
    return system, [{"role": "user", "content": "\n\n".join(parts)}]


def _extract_json_array(text: str):
    start = text.find("[")
    end = text.rfind("]")
    if start == -1 or end == -1 or end < start:
        return None
    try:
        return json.loads(text[start : end + 1])
    except ValueError:
        return None


def parse_reply(text: str, universe: list[str], max_suggestions: int) -> list[dict]:
    """Parse the model reply into a clean, universe-constrained list.

    Tolerant of the model returning bare strings or objects, extra prose
    around the JSON, unknown/invented tags (dropped), duplicate tags
    (deduped), and missing/garbage confidences (defaulted + clamped).
    """
    data = _extract_json_array(text or "")
    if not isinstance(data, list):
        return []
    # Map back to the canonical spelling in the universe, case-insensitively,
    # so a model that lowercases a tag still resolves to the real one.
    canonical = {t.lower(): t for t in universe}
    out: list[dict] = []
    seen: set[str] = set()
    for item in data:
        if isinstance(item, dict):
            raw = item.get("tag")
            conf = item.get("confidence")
        elif isinstance(item, str):
            raw, conf = item, None
        else:
            continue
        if not isinstance(raw, str):
            continue
        tag = canonical.get(raw.strip().lower())
        if tag is None or tag in seen:
            continue
        try:
            c = float(conf) if conf is not None else 0.5
        except (ValueError, TypeError):
            c = 0.5
        c = max(0.0, min(1.0, c))
        out.append({"tag": tag, "confidence": round(c, 3)})
        seen.add(tag)
        if len(out) >= max_suggestions:
            break
    return out


def suggest(
    provider: BaseProvider,
    draft: Draft,
    universe: list[str],
    max_suggestions: int = DEFAULT_MAX_SUGGESTIONS,
) -> list[dict]:
    """Orchestrate one suggestion call. Returns [] for an empty draft or
    empty universe (no model call), and for any provider/parse failure."""
    if draft.is_empty() or not universe:
        return []
    system, history = _build_messages(draft, universe, max_suggestions)
    try:
        text = provider.send(system, history)
    except Exception as exc:  # provider/network failure → silent empty
        log.warning("tag-suggest provider failed: %s", exc)
        return []
    return parse_reply(text, universe, max_suggestions)
