# retoor <retoor@molodetz.nl>

import json
import logging
import random
import re
import time
from typing import Any, Optional

import httpx

from devplacepy import stealth
from devplacepy.services.bot.config import (
    GIST_LANGUAGES,
    GIST_MIN_LINES,
    HOME_URL,
    PERSONA_GIST_FLAVOR,
    PERSONA_LANGUAGES,
    SEARCH_TERMS,
    TRIVIAL_GIST_TERMS,
)
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
from devplacepy.services.openai_gateway.usage import parse_usage_headers

HANDLE_CANDIDATE_TARGET = 8

logger = logging.getLogger(__name__)


class LLMClient:
    def __init__(
        self,
        api_key: str,
        api_url: str,
        model: str,
        input_cost_per_1m: float,
        output_cost_per_1m: float,
        gist_min_lines: int = GIST_MIN_LINES,
    ):
        if not api_key:
            raise RuntimeError("LLM API key not set")
        self.api_key = api_key
        self.api_url = api_url
        self.model = model
        self.input_cost_per_1m = input_cost_per_1m
        self.output_cost_per_1m = output_cost_per_1m
        self.gist_min_lines = max(1, gist_min_lines)
        self.total_cost = 0.0
        self.total_calls = 0
        self.total_in_tokens = 0
        self.total_out_tokens = 0

    def _raw_call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
        for attempt in range(3):
            try:
                payload = {
                    "model": self.model,
                    "messages": [
                        {"role": "system", "content": system},
                        {"role": "user", "content": prompt},
                    ],
                    "temperature": temperature,
                }
                logger.info(
                    "LLM >>> model=%s system=%s prompt=%s",
                    self.model,
                    json.dumps(system[:500]),
                    json.dumps(prompt[:500]),
                )
                with stealth.stealth_sync_client(timeout=30) as client:
                    resp = client.post(
                        self.api_url,
                        json=payload,
                        headers={"Authorization": f"Bearer {self.api_key}"},
                    )
                    raw = resp.content
                    logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
                    result = resp.json()
                    in_tokens, out_tokens, cost = self._account_usage(
                        resp.headers, result.get("usage", {})
                    )
                    self.total_cost += cost
                    self.total_calls += 1
                    self.total_in_tokens += in_tokens
                    self.total_out_tokens += out_tokens
                    return result["choices"][0]["message"]["content"]
            except (
                httpx.HTTPError,
                json.JSONDecodeError,
                KeyError,
            ) as e:
                logger.warning("LLM call attempt %d/3 failed: %s", attempt + 1, e)
                if attempt == 2:
                    raise
                time.sleep(2**attempt)
        return ""

    def _account_usage(self, response_headers, body_usage: dict) -> tuple[int, int, float]:
        parsed = parse_usage_headers(response_headers)
        if parsed is not None:
            in_tokens = parsed["prompt_tokens"]
            out_tokens = parsed["completion_tokens"]
            if not out_tokens and parsed["total_tokens"] > in_tokens:
                out_tokens = parsed["total_tokens"] - in_tokens
            return in_tokens, out_tokens, parsed["cost_usd"]
        in_tokens = body_usage.get("prompt_tokens", 0)
        out_tokens = body_usage.get("completion_tokens", 0)
        cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
            out_tokens * self.output_cost_per_1m / 1_000_000
        )
        return in_tokens, out_tokens, cost

    def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
        return self.clean(self._raw_call(system, prompt, temperature))

    @staticmethod
    def clean(text: str, preserve_md: bool = False) -> str:
        if not preserve_md:
            text = re.sub(r"\*+", "", text)
            text = re.sub(r"(?<![A-Za-z0-9])__(?=\S)(.*?)(?<=\S)__(?![A-Za-z0-9])", r"\1", text)
            text = re.sub(r"(?<![A-Za-z0-9])_(?=\S)(.*?)(?<=\S)_(?![A-Za-z0-9])", r"\1", text)
            text = re.sub(r"(?<![A-Za-z0-9])_+(?![A-Za-z0-9])", "", text)
        text = text.replace("—", "-").replace("–", "-")
        text = text.replace("‘", "'").replace("’", "'")
        text = text.replace("“", '"').replace("”", '"')
        text = re.sub(r"\s{2,}", " ", text)
        return text.strip()

    @staticmethod
    def strip_md(text: str) -> str:
        return LLMClient.clean(text)[:200]

    @staticmethod
    def strip_label(text: str) -> str:
        labels = (
            "post title",
            "project name",
            "snippet name",
            "title",
            "name",
            "concept",
            "snippet",
            "headline",
            "gist",
            "issue",
        )
        result = (text or "").strip().strip('"').strip("'").strip()
        changed = True
        while changed:
            changed = False
            lowered = result.lower()
            for label in labels:
                if lowered.startswith(label + ":"):
                    result = result[len(label) + 1 :].strip().strip('"').strip("'")
                    changed = True
                    break
        for sep in (" concept:", " description:", " desc:", " idea:"):
            idx = result.lower().find(sep)
            if idx > 0:
                result = result[:idx].strip()
        return result.strip().strip('"').strip("'").strip()

    @staticmethod
    def strip_code_fences(text: str) -> str:
        text = text.strip()
        if text.startswith("```"):
            lines = text.split("\n")
            if lines and lines[0].lstrip().startswith("```"):
                lines = lines[1:]
            if lines and lines[-1].strip().startswith("```"):
                lines = lines[:-1]
            text = "\n".join(lines)
        return text.strip()

    def generate_post(
        self,
        title: str,
        desc: str,
        persona: str = "",
        category: str = "",
        recent_titles: Optional[list[str]] = None,
    ) -> str:
        persona_extras = {
            "enthusiastic_junior": "Be excited. Use **bold** for emphasis. Short excited sentences. End with a question sometimes.",
            "grumpy_senior": "Be slightly cynical but helpful. Short blunt sentences. No fluff. Call out bad practices.",
            "hobbyist_maker": "Be casual and friendly. Mention side projects and tinkering. Use *italics* for tools/libraries.",
            "academic_type": "Be precise and structured. Use *italics* for terminology. Well thought out arguments.",
            "storyteller": "Use **bold** for key points. Write anecdotal, narrative style. Longer flowing sentences.",
            "rebel": "Be informal. Skip punctuation sometimes. Use slang. Type like you're in a hurry.",
            "mentor": "Be helpful and explanatory. Use **bold** for key takeaways. Include practical advice.",
            "minimalist": "One paragraph. Short blunt declarative sentences. No markdown. No fluff.",
        }
        category_extras = {
            "devlog": "Write it as a personal devlog entry - share your learning process and insights about this topic.",
            "showcase": "Write it as a showcase - express genuine excitement about what makes this impressive.",
            "question": "Write it as a discussion starter - ask thoughtful questions, invite others to share perspectives.",
            "rant": "Write it as an opinionated rant - strong viewpoint, passionate criticism, but keep it substantive.",
            "fun": "Write it lighthearted and playful, but stay tied to the actual tech topic. Humor about the technology itself, not off-topic jokes or lyrics.",
            "random": "Be natural and conversational - share your thoughts like any casual discussion.",
            "politics": "Write it as a measured take on the politics of this technology - regulation, governance, open-source licensing, industry power, or ethics. Stay substantive and non-partisan; argue the policy angle, not party lines.",
        }
        persona_extra = (
            f" {persona_extras.get(persona, 'Be casual. No markdown.')}"
            if persona
            else " Be casual. No markdown."
        )
        category_extra = f" {category_extras.get(category, '')}" if category else ""
        distinct_rule = ""
        if recent_titles:
            recent = "; ".join(t for t in recent_titles[-6:] if t)
            if recent:
                distinct_rule = (
                    f" You already posted about: {recent[:600]}. Take a completely "
                    "different angle from those and do not repeat their framing, "
                    "examples, or opinions."
                )
        preserve = persona in (
            "enthusiastic_junior",
            "hobbyist_maker",
            "academic_type",
            "storyteller",
            "mentor",
        )
        text = self._call(
            f"You are a dev writing a social media post reacting to tech news. Write 2-4 short paragraphs."
            f"{persona_extra}{category_extra} Do not summarize the article; assume the reader already saw it. "
            f"Add your own value: a concrete opinion, an implication, a personal experience, or a pointed question. "
            f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words."
            f"{distinct_rule}",
            f"News: {title}\n\n{desc[:800]}",
        )
        return self.clean(text, preserve_md=preserve)

    def generate_post_title(
        self, headline: str, persona: str = "", category: str = ""
    ) -> str:
        style = {
            "enthusiastic_junior": "Sound excited and curious.",
            "grumpy_senior": "Sound blunt and a little skeptical.",
            "hobbyist_maker": "Sound casual and hands-on.",
            "academic_type": "Sound precise and measured.",
            "minimalist": "Keep it plain and very short.",
            "storyteller": "Hint at a story or an angle.",
            "rebel": "Sound provocative or contrarian.",
            "mentor": "Sound thoughtful and constructive.",
        }.get(persona, "")
        title = self.strip_label(
            self.clean(
                self._call(
                    "You are a developer writing the title of a community forum post reacting to tech news. "
                    "Write a natural, human title of 3 to 8 words in your own voice. "
                    "Do not copy or paraphrase the full headline and do not restate every detail. "
                    "No trailing punctuation unless it is a genuine question. No quotes. No label prefix. "
                    f"No em dashes. {style}",
                    f"Headline: {headline}\nYour post title:",
                    temperature=0.8,
                )
            )
        )
        return title[:120]

    def select_reaction(self, content_snippet: str, persona: str = "") -> str:
        from devplacepy.constants import REACTION_EMOJI

        flavor = {
            "enthusiastic_junior": "You react readily and warmly.",
            "grumpy_senior": "You react rarely, only to genuinely notable content.",
            "hobbyist_maker": "You react to anything hands-on, clever, or fun.",
            "academic_type": "You react only to substantive, rigorous content.",
            "minimalist": "You react very sparingly.",
            "storyteller": "You react to anything with a human angle.",
            "rebel": "You react to bold or contrarian takes.",
            "mentor": "You react supportively to effort and learning.",
        }.get(persona, "")
        options = " ".join(REACTION_EMOJI)
        verdict = self._call(
            "You are a developer browsing a community feed. Decide whether a post deserves an "
            "emoji reaction and which single emoji best fits its content and sentiment. "
            f"{flavor} Reply with EXACTLY one of these emojis: {options} "
            "or the word NONE if the content is mundane or would not move you to react. "
            "Output only the emoji or NONE, nothing else.",
            f"Post:\n{content_snippet[:1200]}",
            temperature=0.4,
        )
        text = verdict or ""
        for emoji in REACTION_EMOJI:
            if emoji in text or emoji.replace("\ufe0f", "") in text:
                return emoji
        return ""

    @staticmethod
    def pick_comment_style() -> str:
        return random.choices(
            ["standard", "oneliner", "question"],
            weights=[55, 23, 22],
            k=1,
        )[0]

    @staticmethod
    def is_short_comment_style(style: str) -> bool:
        return style in ("oneliner", "question")

    def generate_comment(
        self,
        post_snippet: str,
        persona: str = "",
        mention_target: str = "",
        parent_context: str = "",
        style: str = "",
        existing_comments: str = "",
    ) -> str:
        extras = {
            "enthusiastic_junior": "Be excited. Use **bold** for agreement. Short replies.",
            "grumpy_senior": "Be blunt and short. No markdown. One sarcastic remark or actual advice.",
            "rebel": "Super casual. Skip caps sometimes. Short.",
            "mentor": "Be helpful. Use **bold** for key point.",
            "storyteller": "Share a quick related story. Use *italics* for emphasis.",
            "minimalist": "Shortest possible reply. One sentence max.",
        }
        extra = (
            f" {extras.get(persona, 'Be casual. No markdown.')}"
            if persona
            else " Be casual. No markdown."
        )
        preserve = persona in ("enthusiastic_junior", "mentor", "storyteller")
        if style == "oneliner":
            length_rule = (
                "Write ONE short casual line, under 12 words, like a quick reply "
                "you would type without thinking too hard. Lowercase is fine, "
                "contractions are fine, no markdown."
            )
            preserve = False
        elif style == "question":
            length_rule = (
                "Write a SINGLE pointed question about a specific detail in the post. "
                "One sentence. No preamble."
            )
        else:
            length_rule = f"Write 1-3 short sentences.{extra}"
        mention_rule = ""
        if mention_target:
            mention_rule = (
                f" Address @{mention_target} naturally in the first sentence "
                f"(weave the @{mention_target} mention inline, do not append it at the end)."
            )
        ctx = ""
        if parent_context:
            ctx = f"\n\nReplying to this comment:\n{parent_context[:800]}"
        others = ""
        distinct_rule = ""
        if existing_comments:
            others = (
                "\n\nComments already posted by others on this thread:\n"
                f"{existing_comments[:1600]}"
            )
            distinct_rule = (
                " Other people already commented (listed below). Contribute a point "
                "none of them made: a different angle on the topic, a caveat they "
                "missed, or respectful disagreement with one of them by name. Do not "
                "repeat any opinion, framing, example, or question already raised."
            )
        system = (
            f"You are a dev replying to a post. {length_rule}{mention_rule} "
            "No em dashes. Reference a specific detail from the post. "
            "Do not open with or rely on generic filler like 'great point', 'I agree', 'nice', "
            "'thanks for sharing', or 'interesting'. Add something real: a concrete experience, "
            "a caveat, a counterexample, respectful disagreement, or a pointed question. "
            f"Never just paraphrase or agree blandly.{distinct_rule}"
        )
        reply = self.clean(
            self._call(system, f"Post:\n{post_snippet[:1500]}{ctx}{others}").strip(),
            preserve_md=preserve,
        )
        return reply[:2000]

    @staticmethod
    def _overlap_ratio(text: str, context: str) -> float:
        def tokens(s: str) -> list[str]:
            return [w for w in re.findall(r"[a-z0-9]+", s.lower()) if len(w) > 3]

        candidate = tokens(text)
        source = set(tokens(context))
        if not candidate or not source:
            return 0.0
        hits = sum(1 for w in candidate if w in source)
        return hits / len(candidate)

    def quality_check(
        self, kind: str, text: str, context: str = "", style: str = "", siblings: str = ""
    ) -> tuple[bool, str]:
        from devplacepy.services.bot.config import (
            MIN_COMMENT_LEN,
            MIN_SHORT_COMMENT_LEN,
            MIN_POST_LEN,
            RESTATEMENT_OVERLAP_THRESHOLD,
            SIBLING_OVERLAP_THRESHOLD,
            GENERIC_COMMENT_PHRASES,
        )

        stripped = self.clean(text or "").strip()
        if kind == "comment":
            min_len = (
                MIN_SHORT_COMMENT_LEN
                if self.is_short_comment_style(style)
                else MIN_COMMENT_LEN
            )
        else:
            min_len = MIN_POST_LEN
        if len(stripped) < min_len:
            return False, f"too short ({len(stripped)} < {min_len})"
        lowered = stripped.lower()
        if kind == "comment":
            for phrase in GENERIC_COMMENT_PHRASES:
                if phrase in lowered:
                    return False, f"generic phrase '{phrase}'"
        if (
            context
            and self._overlap_ratio(stripped, context) > RESTATEMENT_OVERLAP_THRESHOLD
        ):
            return False, "restates the source"
        if (
            siblings
            and self._overlap_ratio(stripped, siblings) > SIBLING_OVERLAP_THRESHOLD
        ):
            return False, "echoes another comment"
        verdict = self._call(
            "You are a strict content quality reviewer for a developer community. "
            "Reject text that is generic, low-effort, pure filler, or merely summarizes its "
            "source without adding an opinion, experience, or insight. "
            "Reply with exactly PASS or 'FAIL: <short reason>'.",
            f"Type: {kind}\nText:\n{stripped[:1200]}",
            temperature=0.0,
        )
        if verdict.strip().upper().startswith("PASS"):
            return True, "ok"
        return False, verdict.strip()[:120] or "judge rejected"

    @staticmethod
    def _parse_json(text: str) -> dict:
        text = LLMClient.strip_code_fences(text or "").strip()
        start = text.find("{")
        end = text.rfind("}")
        if start >= 0 and end > start:
            text = text[start : end + 1]
        parsed = json.loads(text)
        if not isinstance(parsed, dict):
            raise ValueError("expected a JSON object")
        return parsed

    @staticmethod
    def _clamp01(value: Any, default: float) -> float:
        try:
            return max(0.0, min(1.0, float(value)))
        except (TypeError, ValueError):
            return default

    @staticmethod
    def _as_int(value: Any, default: int) -> int:
        try:
            return int(value)
        except (TypeError, ValueError):
            return default

    @staticmethod
    def _as_terms(value: Any) -> list[str]:
        if isinstance(value, list):
            return [str(item).strip() for item in value if str(item).strip()][:8]
        if isinstance(value, str) and value.strip():
            return [part.strip() for part in value.split(",") if part.strip()][:8]
        return []

    def _normalize_identity(self, data: dict, archetype: str) -> dict:
        interests = self._as_terms(data.get("interests")) or list(
            SEARCH_TERMS.get(archetype, [])
        )
        return {
            "archetype": archetype,
            "name": str(data.get("name", "")).strip()[:60],
            "backstory": str(data.get("backstory", "")).strip()[:300],
            "interests": interests,
            "dislikes": self._as_terms(data.get("dislikes")),
            "temperament": str(data.get("temperament", "")).strip()[:120],
            "verbosity": self._clamp01(data.get("verbosity"), 0.5),
            "contrarianness": self._clamp01(data.get("contrarianness"), 0.3),
            "generosity": self._clamp01(data.get("generosity"), 0.5),
            "curiosity": self._clamp01(data.get("curiosity"), 0.5),
            "rhythm": str(data.get("rhythm", "")).strip()[:120],
        }

    def generate_identity(self, archetype: str) -> dict:
        interests = ", ".join(SEARCH_TERMS.get(archetype, []))
        system = (
            "You invent a unique, believable individual developer for a social coding "
            "platform, seeded from a broad archetype but distinct from anyone else who "
            "shares it. Return ONLY a JSON object, no prose and no markdown fences, with "
            "these keys: name (a short handle-like display name), backstory (one concrete "
            "sentence), interests (array of 4 to 6 short topics), dislikes (array of 2 to 4 "
            "short topics), temperament (a few words), verbosity (number 0.0 to 1.0), "
            "contrarianness (number 0.0 to 1.0), generosity (number 0.0 to 1.0), curiosity "
            "(number 0.0 to 1.0), rhythm (a few words on when and how they show up). Make it "
            "specific and memorable, never generic. No em dashes."
        )
        prompt = (
            f"Archetype: {archetype}\n"
            f"Typical interests for this archetype: {interests}\n"
            "Create the persona JSON:"
        )
        data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
        return self._normalize_identity(data, archetype)

    def generate_handle_candidates(self, archetype: str) -> list[str]:
        interests = ", ".join(SEARCH_TERMS.get(archetype, []))
        system = (
            "You invent online handles for a developer signing up to a programmer "
            "community in the style of devRant or Hacker News. The handles read like a "
            "real nerd picked them, never like a person's full name. Lean on programming "
            "and hacker culture, but make every handle in the list a DIFFERENT shape so "
            "they never feel formulaic. Mix these forms across the list: a single fused "
            "word (segfault, mutexlord, kernelpanic); two words joined with no separator "
            "(darkbyte, neonferret); camelCase (nullPointer, byteFox); an underscore or a "
            "hyphen but NOT on most of them (lazy_daemon, cold-stack); leetspeak "
            "(n0_scalar, c0d3r, h4xwolf); a word plus a number, port, or version tag "
            "(void404, daemon1337, byte_v2, heap8080); dropped vowels (krnlpnc, bffr, "
            "mtxguru); and an unexpected creature, role, or prefix (dr_segfault, "
            "raven_smith, axolotl_dev). Vary the length from 4 to 18. Lowercase mostly "
            "with the odd capital. Do NOT make them all the 'word_word' underscore "
            f"pattern. {MAX_HANDLE_LEN} characters or fewer, only letters, digits, "
            "underscores and hyphens, no spaces and no dots. Return ONLY a JSON object "
            f'{{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} distinct '
            "handles, each a distinct shape, no prose, no markdown fences. No em dashes."
        )
        prompt = (
            f"Archetype: {archetype}\n"
            f"Their interests: {interests}\n"
            "Create the handles JSON:"
        )
        try:
            data = self._parse_json(
                self._raw_call(system, prompt, temperature=1.0)
            )
        except (ValueError, json.JSONDecodeError) as exc:
            logger.warning("Handle candidate generation failed: %s", exc)
            return []
        handles: list[str] = []
        for raw in data.get("handles", []):
            handle = sanitize_handle(raw)
            if handle and handle.lower() not in {h.lower() for h in handles}:
                handles.append(handle)
        return handles

    @staticmethod
    def _identity_card(identity: dict) -> str:
        lines = [
            f"name: {identity.get('name', '')}",
            f"archetype: {identity.get('archetype', '')}",
            f"backstory: {identity.get('backstory', '')}",
            f"interests: {', '.join(identity.get('interests', []))}",
            f"dislikes: {', '.join(identity.get('dislikes', []))}",
            f"temperament: {identity.get('temperament', '')}",
            f"verbosity: {identity.get('verbosity', 0.5)}",
            f"contrarianness: {identity.get('contrarianness', 0.3)}",
            f"generosity: {identity.get('generosity', 0.5)}",
            f"curiosity: {identity.get('curiosity', 0.5)}",
            f"rhythm: {identity.get('rhythm', '')}",
        ]
        return "\n".join(lines)

    def _decision_system(self, identity: dict) -> str:
        return (
            "You are role-playing a single developer on a social coding platform. Stay in "
            "character at all times.\n\n"
            "IDENTITY\n"
            f"{self._identity_card(identity)}\n\n"
            "HOW TO DECIDE\n"
            "- Choose the actions that THIS person, with these interests and this "
            "temperament, would take on the page described. Behaviour must follow the "
            "identity, not chance.\n"
            "- A generous person votes and reacts readily; a contrarian one comments to push "
            "back; a terse, low-verbosity one acts rarely and briefly; a curious one explores "
            "and navigates. Let the numbers and interests drive the plan.\n"
            "- Only choose actions whose name appears in the MENU. Never invent an action or "
            "a target.\n"
            "- Order the plan the way this person would actually act, and include only what "
            "they would genuinely do (an empty plan is fine if nothing here interests them).\n"
            "- energy is how engaged this person is right now, 0.0 (barely present) to 1.0 "
            "(highly active), following their rhythm.\n"
            "- stop_after is roughly how many more actions this whole visit warrants before "
            "they would naturally drift away.\n"
            "- Output ONLY the JSON object below. No prose, no markdown fences.\n\n"
            "OUTPUT SCHEMA\n"
            '{"plan":[{"action":"<menu action>","target":"<id or empty>",'
            '"rationale":"<short>"}],"energy":<0.0-1.0>,"stop_after":<integer>}'
        )

    @staticmethod
    def _decision_prompt(page_state: dict, menu: list[dict], history: list[str]) -> str:
        menu_lines = "\n".join(f"- {item['action']}: {item['desc']}" for item in menu)
        recent = "; ".join(history[-8:]) if history else "nothing yet this visit"
        unread = page_state.get("unread_notifications", 0)
        unread_line = (
            f"\nUNREAD NOTIFICATIONS: {unread} (someone may have mentioned or replied to you)"
            if unread
            else ""
        )
        return (
            f"PAGE: {page_state.get('page', 'unknown')}\n"
            f"VISIBLE: {page_state.get('visible', '')}{unread_line}\n"
            f"MENU:\n{menu_lines}\n"
            f"RECENT (this visit): {recent}"
        )

    @staticmethod
    def _filter_plan(plan: Any, allowed: set) -> list[dict]:
        if not isinstance(plan, list):
            return []
        result = []
        for entry in plan:
            if not isinstance(entry, dict):
                continue
            action = str(entry.get("action", "")).strip()
            if action not in allowed:
                continue
            result.append(
                {
                    "action": action,
                    "target": str(entry.get("target", "")).strip()[:120],
                    "rationale": str(entry.get("rationale", "")).strip()[:200],
                }
            )
        return result[:12]

    def decide(
        self,
        identity: dict,
        page_state: dict,
        menu: list[dict],
        history: list[str],
        temperature: float = 0.4,
    ) -> dict:
        allowed = {item["action"] for item in menu}
        system = self._decision_system(identity)
        base = self._decision_prompt(page_state, menu, history)
        result = {"plan": [], "energy": 0.5, "stop_after": 0}
        for attempt in range(2):
            prompt = base
            if attempt:
                prompt += "\n\nReturn ONLY valid JSON matching the schema. No prose."
            try:
                data = self._parse_json(self._raw_call(system, prompt, temperature))
            except (ValueError, KeyError, json.JSONDecodeError):
                continue
            result = {
                "plan": self._filter_plan(data.get("plan"), allowed),
                "energy": self._clamp01(data.get("energy"), 0.5),
                "stop_after": self._as_int(data.get("stop_after"), 0),
            }
            if result["plan"]:
                break
        return result

    def generate_issue(self, topic: str) -> tuple[str, str]:
        text = self._call(
            "Write an issue report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting an issue. No em dashes.",
            f"Issue: {topic}",
        )
        lines = text.strip().split("\n")
        title = lines[0].strip().lstrip("#").strip()[:120] or f"Issue report: {topic}"
        desc = " ".join(
            line.lstrip("-* ").strip() for line in lines[1:] if line.strip()
        )
        return title, desc[:2000] or text[:500]

    def generate_bio(self) -> str:
        return self._call(
            "Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. "
            f"End with one subtle, playful hint that the account is not exactly flesh and blood, naming {HOME_URL} as home base. "
            "Never use the words bot, AI, artificial, automated, or machine. No em dashes.",
            "Bio:",
        )

    def generate_profile_fields(self, handle: str) -> tuple[str, str, str]:
        location = self.clean(
            self._call(
                "Name one plausible city and country for a developer. Reply with ONLY 'City, Country'. No extra words.",
                "Location:",
                temperature=0.9,
            )
        )[:80]
        slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
        git_link = f"https://github.com/{slug}"
        return location, git_link, HOME_URL

    def generate_dm(self, persona: str = "", context: str = "") -> str:
        extra = {
            "enthusiastic_junior": "Be friendly and excited.",
            "grumpy_senior": "Be blunt but not rude.",
            "rebel": "Be super casual.",
            "mentor": "Be warm and encouraging.",
            "storyteller": "Open with a small hook.",
            "minimalist": "One short sentence.",
        }.get(persona, "Be casual and friendly.")
        ctx = f"\n\nEarlier message:\n{context[:400]}" if context else ""
        return self.clean(
            self._call(
                f"Write one short, friendly direct message to another developer. 1-2 sentences. {extra} No em dashes.",
                f"Write a DM to start or continue a chat.{ctx}",
            )
        )[:500]

    def generate_project_title(self, persona: str = "") -> str:
        return self.strip_label(
            self.clean(
                self._call(
                    "Invent a short, catchy project name. 2 to 4 words. A tool, game, or app. "
                    "Output only the name. No label, no colon, no description, no quotes. No em dashes.",
                    "Project name:",
                    temperature=0.9,
                )
            )
        )[:80]

    def generate_project_desc(self, title: str, persona: str = "") -> str:
        flavor = {
            "grumpy_senior": "Keep it dry and matter of fact.",
            "academic_type": "Be precise about the approach and trade-offs.",
            "minimalist": "Two short sentences, no filler.",
            "rebel": "Be bold about why the usual approach is wrong.",
            "storyteller": "Open with the motivation behind it.",
        }.get(persona, "")
        shape = random.choice(
            [
                "Write one terse sentence. Just what it is. No tech stack.",
                "Write two sentences: what it does and the main tech behind it.",
                "Write 2 to 3 sentences including the tech stack and a trade-off you made.",
                "Write a short blurb, under 25 words, plain and understated.",
            ]
        )
        return self.clean(
            self._call(
                f"You are a developer describing your own project. {shape} {flavor} No em dashes.",
                f"Project: {title}",
            )
        )[:5000]

    def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
        languages = PERSONA_LANGUAGES.get(persona) or GIST_LANGUAGES
        language = random.choice(languages)
        flavor = PERSONA_GIST_FLAVOR.get(persona, "a genuinely useful utility")
        code = self.strip_code_fences(
            self._call(
                f"Write a short, correct, self-contained {language} snippet of 8 to 20 lines. "
                f"It should be {flavor}. Make it non-trivial and genuinely useful: no hello world, "
                "no bare language-feature demo, no textbook 101 example, no trivial one-liner, "
                "no basic getter or setter. Output ONLY raw code. No markdown fences. No commentary.",
                f"Language: {language}. Write the snippet:",
                temperature=0.7,
            )
        )[:4000]
        title = self.strip_label(
            self.clean(
                self._call(
                    "Name this code snippet in 2 to 5 words, like a developer titling a gist. "
                    "No quotes. No markdown. No label prefix. No em dashes.",
                    f"Language: {language}\nCode:\n{code[:1200]}\nTitle:",
                    temperature=0.7,
                )
            )
        )[:120]
        description = self.clean(
            self._call(
                "Write a one-sentence description of what this snippet does and when it is handy. No em dashes.",
                f"Title: {title}\nLanguage: {language}\nCode:\n{code[:1200]}",
            )
        )[:400]
        return title, description, language, code

    def gist_quality_check(
        self, title: str, code: str, language: str
    ) -> tuple[bool, str]:
        lowered = (title or "").lower()
        for term in TRIVIAL_GIST_TERMS:
            if term in lowered:
                return False, f"trivial topic '{term}'"
        lines = [ln for ln in (code or "").splitlines() if ln.strip()]
        if len(lines) < self.gist_min_lines:
            return False, f"too few lines ({len(lines)} < {self.gist_min_lines})"
        verdict = self._call(
            "You are a strict reviewer for a developer community's shared code snippets. "
            "Reject snippets that are textbook 101 material, trivial one-liners, hello world, "
            "a bare language-feature demo, or something every developer already knows by heart. "
            "Accept only snippets an experienced developer would find non-obvious or worth bookmarking. "
            "Reply with exactly PASS or 'FAIL: <short reason>'.",
            f"Language: {language}\nTitle: {title}\nCode:\n{code[:1500]}",
            temperature=0.0,
        )
        if verdict.strip().upper().startswith("PASS"):
            return True, "ok"
        return False, verdict.strip()[:120] or "judge rejected"
