import json
import logging
import os
import random

from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from models.schemas import ChatRequest, ChatResponse, MessageEditRequest, RegenerateRequest
from services.llm import LLMError, send_message, stream_message
from services.memory import (
    get_history,
    add_message,
    clear_history,
    get_or_create_session,
    get_session,
    update_session_title,
    get_message_count,
    get_last_assistant_message_id,
    update_message_image,
    update_session_facts,
    update_session_status_quo,
    update_session_genre,
    update_session_plot_arc,
    get_quests,
    narrator_message_content,
    parse_narrator_message,
    add_action_resolution,
    get_message,
    update_message_content,
    delete_messages_after,
    delete_message,
    delete_message_and_following,
    update_message_choices,
    clear_choices_for_session,
    upsert_static_system_message,
    save_state_snapshot,
    get_last_message_id,
)
from services.context_budget import compute_payload_usage, context_warning_line
from services.rpg_state import (
    apply_narrator_post,
    apply_narrator_post_with_story,
    parse_scene_json,
    parse_stats_json,
    scene_prompt_block,
    affinity_prompt_block,
    stats_prompt_block,
    format_narrator_outcome_for_llm,
    format_user_message_for_llm,
)
from services.personas import get_persona
from services.chat_prompt import get_system_prompt, DEFAULT_PROMPT
from services.session_identity import resolve_session_persona
from services.sd_prompt import generate_sd_prompt, strip_image_prompt_tag, extract_image_prompt_tag
from services.rp_sanitize import RP_OUTPUT_REMINDER, strip_ooc_from_reply
from services.sd_images import run_sd_for_message
from services.character_card import get_character
from services import sdbackend as sd_service
from services.rpg_facts import extract_facts, merge_facts_persist, facts_to_prompt, rp_day_from_scene
from services.rpg_context import format_narrator_context, format_arc_summary_for_runtime
from services.rpg_plot import (
    generate_plot_arc,
    reconcile_plot_arc,
    choices_from_step,
    choices_from_narrator,
)
from services.rpg_story import (
    normalize_story_arc,
    get_current_step,
    format_step_guidance_for_character,
    format_step_hint_for_character,
    format_new_arc_opening,
    should_show_step_injection,
    mark_injection_shown,
    reconcile_story_arc,
    sync_quest_to_current_step,
    is_arc_completed,
    is_new_arc_request,
    normalize_new_arc_first,
    append_new_arc_roll_choice,
    roll_next_arc,
    step_progress,
)
from services.rpg_narrator import narrator_pre, narrator_post
from services.opening import ensure_plot_arc_and_quests, resolve_greeting, process_opening

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chat", tags=["chat"])

SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes")

DEFAULT_RPG_SETTINGS = {
    "dice": True,
    "narrator": True,
    "quests": True,
    "affinity": True,
    "choices": True,
    "stats": False,
}


def get_rpg_settings(session: dict) -> dict:
    try:
        return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")}
    except Exception:
        return DEFAULT_RPG_SETTINGS


def build_rpg_runtime_suffix(session: dict, rpg_settings: dict, facts_block: str = "") -> str:
    runtime_suffix = ""
    if facts_block:
        runtime_suffix += "\n\n" + facts_block
    try:
        arc = json.loads(session.get("plot_arc_json") or "{}")
    except Exception:
        arc = {}
    if arc:
        summary = format_arc_summary_for_runtime(arc)
        if summary:
            runtime_suffix += "\n\n--- Story arc ---\n" + summary + "\n---"
    status_quo = (session.get("status_quo") or "").strip()
    if status_quo:
        from services.rp_sanitize import status_quo_prompt_block

        runtime_suffix += status_quo_prompt_block(status_quo)
    scene = parse_scene_json(session.get("scene_json"))
    block = scene_prompt_block(scene)
    if block:
        runtime_suffix += block
    if rpg_settings.get("affinity", True):
        runtime_suffix += affinity_prompt_block(int(session.get("affinity") or 0))
    if rpg_settings.get("stats", False):
        stats = parse_stats_json(session.get("narrative_stats_json"))
        runtime_suffix += stats_prompt_block(stats)
    return runtime_suffix


def messages_for_llm(
    history: list, llm_system_content: str, *, rp_lang: str = "ru"
) -> list[dict]:
    """Build LLM payload: one system message (static + runtime), no duplicate system rows."""
    out: list[dict] = []
    system_used = False
    for m in history:
        if m["role"] == "system":
            if not system_used:
                out.append({"role": "system", "content": llm_system_content})
                system_used = True
        elif m["role"] == "narrator":
            data = parse_narrator_message(m.get("content") or "")
            if data:
                out.append({
                    "role": "user",
                    "content": format_narrator_outcome_for_llm(data, lang=rp_lang),
                })
        elif m["role"] == "user":
            has_res = bool(m.get("action_resolution"))
            out.append({
                "role": "user",
                "content": format_user_message_for_llm(
                    m["content"], has_dice_resolution=has_res
                ),
            })
        else:
            out.append({"role": m["role"], "content": m["content"]})
    if not system_used:
        out.insert(0, {"role": "system", "content": llm_system_content})
    return out


@router.get("/history/{session_id}")
async def get_chat_history(session_id: str):
    return await get_history(session_id)


@router.get("/system/{session_id}")
async def get_system_blob(session_id: str):
    history = await get_history(session_id)
    session = await get_session(session_id)
    if session and session.get("rpg_enabled"):
        persona_id_pre = (session.get("persona_id") or "default")
        persona_pre = await get_persona(persona_id_pre) or {}
        await reconcile_plot_arc(
            session_id,
            persona_name=persona_pre.get("name", persona_id_pre),
            recent_context=(session.get("status_quo") or "")[:2000],
            genre=session.get("genre") or "adventure",
        )
        session = await get_session(session_id) or session
    persona_id = (session.get("persona_id") if session else None) or "default"
    persona = await get_persona(persona_id) or {}
    system_msg = next((m for m in history if m.get("role") == "system"), None)
    stored = system_msg.get("content") if system_msg else ""
    live_static = await get_system_prompt(persona_id, history, "")
    system_prompt = live_static if live_static else stored
    quests = await get_quests(session_id)
    rpg_settings = get_rpg_settings(session) if session else DEFAULT_RPG_SETTINGS
    facts_block = facts_to_prompt(session.get("facts_json", "[]")) if session else ""
    runtime_suffix = ""
    if session and session.get("rpg_enabled"):
        runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block)
    llm_system = system_prompt + runtime_suffix
    context_usage = compute_payload_usage(history, llm_system)
    return {
        "persona_id": persona_id,
        "persona_name": persona.get("name", persona_id),
        "system_prompt": system_prompt,
        "status_quo": session.get("status_quo") if session else "",
        "global_plot": session.get("global_plot") if session else "",
        "facts_json": session.get("facts_json") if session else "[]",
        "plot_arc_json": session.get("plot_arc_json") if session else "{}",
        "outfit_json": session.get("outfit_json") if session else "[]",
        "scene_json": session.get("scene_json") if session else "{}",
        "narrative_stats_json": session.get("narrative_stats_json") if session else "{}",
        "affinity": session.get("affinity", 0) if session else 0,
        "genre": session.get("genre", "") if session else "",
        "rpg_settings_json": session.get("rpg_settings_json") if session else "{}",
        "rpg_enabled": bool(session.get("rpg_enabled")) if session else False,
        "quests": quests,
        "context_usage": context_usage,
    }


@router.post("/init")
async def init_chat(request: ChatRequest):
    await get_or_create_session(
        request.session_id,
        request.persona_id or "default",
    )
    persona_id = await resolve_session_persona(
        request.session_id,
        request.persona_id,
        create_persona=request.persona_id,
    )
    history = await get_history(request.session_id)
    if history:
        return {"first_mes": None}

    system_prompt = await get_system_prompt(persona_id, [], "")
    await upsert_static_system_message(request.session_id, system_prompt, [])

    first_mes = None
    if request.first_mes_override and request.first_mes_override.strip():
        first_mes = request.first_mes_override.strip()
        await add_message(request.session_id, "assistant", first_mes)
    else:
        persona = await get_persona(persona_id)
        if persona and persona.get("first_mes"):
            first_mes = persona["first_mes"]
            await add_message(request.session_id, "assistant", first_mes)
        elif persona_id.startswith("card_"):
            card = await get_character(persona_id[5:])
            if card and card.get("first_mes"):
                first_mes = card["first_mes"]
                await add_message(request.session_id, "assistant", first_mes)

    return {"first_mes": first_mes}


class RpgBootstrapRequest(BaseModel):
    session_id: str
    persona_id: str = "default"
    genre: str = "adventure"


class OpeningProcessRequest(BaseModel):
    session_id: str
    persona_id: str = "default"
    rpg: bool = False


@router.post("/opening/process")
async def opening_process(req: OpeningProcessRequest):
    await get_or_create_session(req.session_id, req.persona_id)
    persona_id = await resolve_session_persona(req.session_id, req.persona_id)
    try:
        return await process_opening(req.session_id, persona_id, rpg=req.rpg)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e))


@router.post("/rpg/bootstrap")
async def rpg_bootstrap(req: RpgBootstrapRequest):
    await get_or_create_session(req.session_id, req.persona_id)
    persona_id = await resolve_session_persona(req.session_id, req.persona_id)
    await update_session_genre(req.session_id, req.genre)
    persona = await get_persona(persona_id) or {}
    greeting = await resolve_greeting(req.session_id, persona)
    arc = await ensure_plot_arc_and_quests(req.session_id, persona, greeting, req.genre)
    session = await get_session(req.session_id) or {}
    rpg_settings = get_rpg_settings(session)
    if rpg_settings.get("narrator", True) and greeting:
        from services.rpg_locale import infer_rp_language

        arc_json = json.dumps(arc, ensure_ascii=False) if arc else ""
        facts_block = facts_to_prompt(session.get("facts_json", "[]"))
        b_lang = infer_rp_language([{"role": "assistant", "content": greeting}])
        post = await narrator_post(
            persona.get("name", persona_id),
            f"assistant: {greeting}",
            arc_json,
            facts_block,
            is_opening=True,
            lang=b_lang,
        )
        await apply_narrator_post(req.session_id, post, rpg_settings, session)
    quests = await get_quests(req.session_id)
    updated = await get_session(req.session_id) or {}
    return {
        "plot_arc": arc,
        "quests": quests,
        "affinity": updated.get("affinity", 0),
        "scene_json": updated.get("scene_json", "{}"),
        "narrative_stats_json": updated.get("narrative_stats_json", "{}"),
    }


@router.post("/stream")
async def chat_stream(request: ChatRequest):
    await get_or_create_session(request.session_id, request.persona_id)
    persona_id = await resolve_session_persona(
        request.session_id,
        request.persona_id,
        create_persona=request.persona_id,
    )

    history = await get_history(request.session_id)
    session = await get_session(request.session_id)
    static_prompt = await get_system_prompt(persona_id, history, request.message)
    runtime_suffix = ""

    arc = {}
    roll = None
    outcome = None
    resolution_text = ""
    narrator_msg = None  # shown as narrator bubble before assistant reply
    rpg_settings = {}
    facts_block = ""

    narrator_extra = ""
    pre = {}
    directives: list = []
    pre_ok = False
    needs_check = False
    rp_lang = "ru"
    story_arc_meta: dict = {}
    new_arc_first = normalize_new_arc_first(request.new_arc_first)
    skip_character_reply = False
    new_arc_injection_text = ""
    if session and session.get("rpg_enabled"):
        from services.rpg_locale import infer_rp_language

        rpg_settings = get_rpg_settings(session)
        facts_block = facts_to_prompt(session.get("facts_json", "[]"))
        rp_lang = infer_rp_language(history)
        try:
            arc = json.loads(session.get("plot_arc_json") or "{}")
        except Exception:
            arc = {}
        arc = normalize_story_arc(arc, genre=session.get("genre") or "adventure")

        wants_new_arc_roll = is_arc_completed(arc) and (
            new_arc_first or is_new_arc_request(request.message)
        )
        if wants_new_arc_roll:
            if not new_arc_first:
                new_arc_first = "character"
            persona = await get_persona(persona_id) or {}
            recent_roll = "\n".join(
                f"{m['role']}: {m['content']}" for m in history[-8:]
                if m.get("role") in ("user", "assistant")
            )
            rolled = await roll_next_arc(
                request.session_id,
                persona,
                request.message,
                session.get("genre") or "adventure",
                lang=rp_lang,
                recent_context=recent_roll,
                facts_block=facts_block,
            )
            if rolled and not is_arc_completed(rolled):
                arc = rolled
                story_arc_meta["new_arc_rolled"] = True
                story_arc_meta["new_arc_first"] = new_arc_first
                skip_character_reply = new_arc_first == "user"
                step0 = get_current_step(arc)
                if step0:
                    new_arc_injection_text = format_new_arc_opening(arc, step0, lang=rp_lang)
                    mark_injection_shown(arc)
                    await update_session_plot_arc(
                        request.session_id, json.dumps(arc, ensure_ascii=False)
                    )

        arc, _ = await reconcile_story_arc(
            request.session_id,
            persona_name=(await get_persona(persona_id) or {}).get("name", persona_id),
            genre=session.get("genre") or "adventure",
        )
        session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)

        quests_list = await get_quests(request.session_id)
        narr_ctx = format_narrator_context(
            arc, quests_list, session.get("status_quo") or ""
        )

        if rpg_settings.get("narrator", True) and not story_arc_meta.get("new_arc_rolled"):
            persona = await get_persona(persona_id) or {}
            recent_txt = "\n".join(
                f"{m['role']}: {m['content']}" for m in history[-8:]
                if m.get("role") in ("user", "assistant")
            )

            # Phase 1: ask narrator if check is needed (no roll yet)
            pre = await narrator_pre(
                persona.get("name", persona_id),
                recent_txt,
                json.dumps(arc, ensure_ascii=False) if arc else "",
                facts_block,
                request.message,
                extra_context=narr_ctx,
                lang=rp_lang,
            )
            pre_ok = bool(pre.get("_ok"))

            needs_check = pre.get("needs_check", False) and rpg_settings.get("dice", True)

            if needs_check:
                # Phase 2: roll and get resolution
                roll = random.randint(1, 20)
                if roll == 1:
                    outcome = "critical failure"
                elif roll <= 8:
                    outcome = "failure"
                elif roll >= 20:
                    outcome = "critical success"
                else:
                    outcome = "success"

                pre2 = await narrator_pre(
                    persona.get("name", persona_id),
                    recent_txt,
                    json.dumps(arc, ensure_ascii=False) if arc else "",
                    facts_block,
                    request.message,
                    roll=roll,
                    outcome=outcome,
                    extra_context=narr_ctx,
                    lang=rp_lang,
                )
                resolution_text = (pre2.get("resolution_text") or "").strip()
                directives = pre2.get("directives") or []
                pre_sq = (pre2.get("status_quo_update") or "").strip()
            else:
                directives = pre.get("directives") or []
                pre_sq = (pre.get("status_quo_update") or "").strip()

            if directives:
                narrator_extra += (
                    "\n\n--- Narrator directives ---\n"
                    + "\n".join(f"- {d}" for d in directives)
                    + "\n---"
                )
            if pre_sq:
                await update_session_status_quo(request.session_id, pre_sq)
                session["status_quo"] = pre_sq

            pre_for_scene = pre2 if needs_check else pre
            scene_up = pre_for_scene.get("scene_update")
            if isinstance(scene_up, dict) and scene_up:
                from services.rpg_state import merge_scene
                from services.memory import update_session_scene

                merged = merge_scene(
                    parse_scene_json(session.get("scene_json")), scene_up
                )
                scene_str = json.dumps(merged, ensure_ascii=False)
                await update_session_scene(request.session_id, scene_str)
                session["scene_json"] = scene_str

            if resolution_text:
                narrator_msg = {
                    "roll": roll,
                    "outcome": outcome,
                    "text": resolution_text,
                    "original_intent": request.message,
                }

            if roll is not None and resolution_text:
                narrator_extra += (
                    f"\n\n--- Mechanics (this turn) ---\n"
                    f"Roll d20={roll}. Outcome: {outcome}.\n"
                    f"Narrator resolution: {resolution_text}\n"
                    "The character's next reply MUST match the narrator ruling in the message history "
                    "(immediately after the player's intent). Do NOT re-enact the attempt as full success on failure.\n"
                    "---"
                )

        step = get_current_step(arc)
        if step:
            narrator_extra += format_step_guidance_for_character(step, arc, lang=rp_lang)
            if (
                not story_arc_meta.get("new_arc_rolled")
                and should_show_step_injection(arc)
            ):
                inj = (step.get("injection") or "").strip()
                if inj:
                    narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
                mark_injection_shown(arc)
                await update_session_plot_arc(
                    request.session_id, json.dumps(arc, ensure_ascii=False)
                )
                session["plot_arc_json"] = json.dumps(arc, ensure_ascii=False)
            elif story_arc_meta.get("new_arc_rolled") and new_arc_first == "character":
                inj = (step.get("injection") or "").strip()
                if inj:
                    narrator_extra += format_step_hint_for_character(inj, lang=rp_lang)
            cur, total = step_progress(arc)
            story_arc_meta["story_step"] = f"{cur}/{total}"

        runtime_suffix = build_rpg_runtime_suffix(session, rpg_settings, facts_block) + narrator_extra

    llm_system = static_prompt + runtime_suffix
    if persona_id != "default" or (session and session.get("rpg_enabled")):
        llm_system += RP_OUTPUT_REMINDER

    user_message_content = request.message
    if request.is_narrator_choice and new_arc_first:
        first_who = "игрок" if new_arc_first == "user" else "персонаж"
        user_message_content = (
            f"[Player chose: Начать новую арку — первый ход: {first_who}]"
        )
    elif request.is_narrator_choice:
        user_message_content = f"[Player chose: {request.message}]"

    await upsert_static_system_message(request.session_id, static_prompt, history)

    user_msg_id = None
    if not request.skip_user_add:
        await clear_choices_for_session(request.session_id)
        user_msg_id = await add_message(request.session_id, "user", user_message_content)
        if user_msg_id and session and session.get("rpg_enabled"):
            await save_state_snapshot(request.session_id, user_msg_id)
    if narrator_msg and narrator_msg.get("roll") is not None and user_msg_id:
        await add_action_resolution(
            request.session_id,
            intent_text=request.message,
            roll=narrator_msg["roll"],
            outcome=narrator_msg["outcome"],
            resolution_text=narrator_msg["text"],
            message_id=user_msg_id,
        )
        narrator_msg["user_message_id"] = user_msg_id
    if narrator_msg and (narrator_msg.get("text") or "").strip():
        narr_id = await add_message(
            request.session_id,
            "narrator",
            narrator_message_content(narrator_msg),
        )
        if narr_id and session and session.get("rpg_enabled"):
            await save_state_snapshot(request.session_id, narr_id)
    messages = await get_history(request.session_id)
    usage = compute_payload_usage(messages, llm_system)
    warn = context_warning_line(usage.get("percent", 0))
    if warn:
        llm_system += warn
    llm_messages = messages_for_llm(messages, llm_system, rp_lang=rp_lang)

    full_reply = []

    async def generate():
        nonlocal arc

        if new_arc_injection_text:
            new_arc_narrator = {"text": new_arc_injection_text}
            narr_inj_id = await add_message(
                request.session_id,
                "narrator",
                narrator_message_content(new_arc_narrator),
            )
            if narr_inj_id and session and session.get("rpg_enabled"):
                await save_state_snapshot(request.session_id, narr_inj_id)
            yield f"data: {json.dumps({'narrator': new_arc_narrator})}\n\n"

        if skip_character_reply:
            choices = []
            step = get_current_step(arc)
            if step and rpg_settings.get("choices", True):
                choices += choices_from_step(step)
            quests_updated = await get_quests(request.session_id)
            updated_session = await get_session(request.session_id) or session
            narrator_meta = {
                "new_arc_rolled": True,
                "new_arc_first": new_arc_first,
                "story_step": story_arc_meta.get("story_step", ""),
                "rp_language": rp_lang,
            }
            done_payload = {
                "done": True,
                "assistant_message_id": None,
                "assistant_content": "",
                "choices": choices,
                "debug": [],
                "affinity": updated_session.get("affinity", 0) if updated_session else 0,
                "quests": quests_updated if session and session.get("rpg_enabled") else [],
                "story_arc": arc if session and session.get("rpg_enabled") else None,
                "narrator_meta": narrator_meta,
            }
            if rpg_settings.get("stats") and updated_session:
                done_payload["narrative_stats"] = parse_stats_json(
                    updated_session.get("narrative_stats_json")
                )
            if session and session.get("rpg_enabled"):
                last_id = await get_last_message_id(request.session_id)
                if last_id:
                    await save_state_snapshot(request.session_id, last_id)
            yield f"data: {json.dumps(done_payload)}\n\n"
            return

        if narrator_msg:
            yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n"

        try:
            async for chunk in stream_message(llm_messages):
                full_reply.append(chunk)
                yield f"data: {json.dumps({'chunk': chunk})}\n\n"
        except Exception as e:
            logger.error("stream_message failed: %s", e)
            yield f"data: {json.dumps({'error': str(e)})}\n\n"
            return

        complete = "".join(full_reply)
        raw_display = strip_image_prompt_tag(complete)
        display_text = strip_ooc_from_reply(raw_display)

        if (display_text or raw_display).strip():
            await add_message(request.session_id, "assistant", display_text or raw_display)

        choices = []
        debug_blocks = []
        quests_updated = []
        narrator_meta = {}

        if session and session.get("rpg_enabled"):
            try:
                if not arc or not arc.get("steps"):
                    persona = await get_persona(persona_id) or {}
                    gen_ctx = "\n".join(
                        f"{m['role']}: {m['content']}"
                        for m in (await get_history(request.session_id))[-6:]
                        if m.get("role") in ("user", "assistant")
                    )
                    arc = await generate_plot_arc(
                        persona.get("name", persona_id),
                        persona.get("description", ""),
                        persona.get("scenario", ""),
                        persona.get("first_mes", ""),
                        facts_block=facts_to_prompt(session.get("facts_json", "[]")),
                        genre=session.get("genre") or "adventure",
                        lang=rp_lang,
                        recent_context=gen_ctx,
                    )
                    if arc:
                        await update_session_plot_arc(
                            request.session_id, json.dumps(arc, ensure_ascii=False)
                        )
                        debug_blocks.append({
                            "type": "plot_arc",
                            "text": json.dumps(arc, ensure_ascii=False, indent=2),
                        })
                        if rpg_settings.get("quests", True):
                            await sync_quest_to_current_step(request.session_id, arc)

                arc = normalize_story_arc(
                    arc, genre=session.get("genre") or "adventure"
                )
                cur, total = step_progress(arc)
                narrator_meta["story_step"] = f"{cur}/{total}"
                narrator_meta["rp_language"] = rp_lang
                if story_arc_meta.get("new_arc_rolled"):
                    narrator_meta["new_arc_rolled"] = True

                ctx = [
                    m for m in (await get_history(request.session_id))
                    if m["role"] in ("user", "assistant")
                ][-10:]
                new_facts = await extract_facts(
                    ctx,
                    rp_day_hint=rp_day_from_scene(session.get("scene_json")),
                    existing_json=session.get("facts_json", "[]"),
                )
                if new_facts:
                    merged = await merge_facts_persist(
                        session.get("facts_json", "[]"),
                        new_facts,
                        rp_day_default=rp_day_from_scene(session.get("scene_json")),
                        scene_context=json.dumps(
                            parse_scene_json(session.get("scene_json")),
                            ensure_ascii=False,
                        ),
                        status_quo=session.get("status_quo") or "",
                    )
                    await update_session_facts(request.session_id, merged)
                    session["facts_json"] = merged

                persona = await get_persona(persona_id) or {}
                ctx_txt = "\n".join(
                    f"{m['role']}: {m['content']}"
                    for m in ctx[-8:]
                    if m.get("role") in ("user", "assistant")
                )
                narr_ctx_post = format_narrator_context(
                    arc, await get_quests(request.session_id), session.get("status_quo") or ""
                )
                post = await narrator_post(
                    persona.get("name", persona_id),
                    ctx_txt,
                    json.dumps(arc, ensure_ascii=False) if arc else "",
                    facts_to_prompt(session.get("facts_json", "[]")),
                    extra_context=narr_ctx_post,
                    lang=rp_lang,
                )

                sq = (post.get("status_quo_update") or "").strip()
                if sq:
                    debug_blocks.append({"type": "status_quo", "text": sq})

                if rpg_settings.get("choices", True):
                    choices += choices_from_narrator(post.get("choices") or [])

                applied = await apply_narrator_post_with_story(
                    request.session_id, post, rpg_settings, session, arc=arc
                )
                if applied.get("arc"):
                    arc = applied["arc"]
                narrator_meta = {
                    "pre_ok": pre_ok,
                    "post_ok": bool(post.get("_ok")),
                    "choices_count": len(choices),
                    "directives_count": len(directives),
                    "dice": roll is not None,
                    **applied,
                }

                if applied.get("step_advanced"):
                    # Drop stale narrator/plot choices from the step we just left;
                    # injection is shown once in the plot choice panel (beat_injection).
                    choices = [c for c in choices if c.get("type") == "new_arc_roll"]
                    new_step = get_current_step(arc)
                    if new_step and rpg_settings.get("choices", True):
                        choices += choices_from_step(new_step)
                    debug_blocks.append({
                        "type": "plot_arc",
                        "text": f"Step advanced: «{applied.get('new_step_title', '')}»",
                    })
                    narrator_meta["choices_count"] = len(choices)

                if applied.get("arc_completed"):
                    debug_blocks.append({
                        "type": "plot_arc",
                        "text": "Story arc completed — new arc available",
                    })

                if is_arc_completed(arc) and rpg_settings.get("choices", True):
                    choices = append_new_arc_roll_choice(choices, lang=rp_lang)
                outfit_update = post.get("outfit_update")
                if isinstance(outfit_update, list) and outfit_update:
                    from services.outfit_tags import outfit_list_to_json

                    session["outfit_json"] = outfit_list_to_json(outfit_update)
                quests_updated = await get_quests(request.session_id)
            except LLMError as e:
                logger.warning("RPG post-process skipped after reply: %s", e)
            except Exception as e:
                logger.exception("RPG post-process failed after reply: %s", e)

        count = await get_message_count(request.session_id)
        if count == 2 and not request.skip_user_add:
            persona = await get_persona(persona_id) or {}
            preview = request.message[:40] + ("…" if len(request.message) > 40 else "")
            if (session or {}).get("title", "Новый чат") in ("", "Новый чат"):
                await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}")

        updated_session = await get_session(request.session_id) or session
        hist = await get_history(request.session_id)
        bundle = await generate_sd_prompt(
            hist,
            persona_id,
            outfit_json=updated_session.get("outfit_json", "[]") if updated_session else "[]",
            scene_json=updated_session.get("scene_json", "{}") if updated_session else "{}",
        )
        prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(complete)
        msg_id = await get_last_assistant_message_id(request.session_id)
        if msg_id and choices:
            await update_message_choices(
                msg_id, json.dumps(choices, ensure_ascii=False)
            )

        sd_out: dict = {}
        if bundle:
            yield f"data: {json.dumps({
                'image_generating': True,
                'image_prompt': bundle.tag_full,
                'image_prompt_alt': bundle.desc_full,
            })}\n\n"
            sd_out = await run_sd_for_message(bundle, msg_id)
        elif prompt_str and SD_AUTO_GENERATE:
            yield f"data: {json.dumps({'image_generating': True, 'image_prompt': prompt_str})}\n\n"
            rel, err = await sd_service.generate_from_full_prompt(prompt_str)
            if rel:
                sd_out["image_path"] = f"/static/{rel}"
                if msg_id:
                    await update_message_image(msg_id, rel)
            else:
                sd_out["image_error"] = err
            sd_out["image_prompt"] = prompt_str

        affinity = updated_session.get("affinity", 0) if updated_session else 0
        done_payload = {
            "done": True,
            "assistant_message_id": msg_id,
            "assistant_content": display_text or raw_display,
            "image_prompt": sd_out.get("image_prompt") or prompt_str,
            "image_prompt_alt": sd_out.get("image_prompt_alt"),
            "image_path": sd_out.get("image_path"),
            "image_path_alt": sd_out.get("image_path_alt"),
            "image_error": sd_out.get("image_error"),
            "image_error_alt": sd_out.get("image_error_alt"),
            "choices": choices,
            "debug": debug_blocks,
            "affinity": affinity,
            "quests": quests_updated if session and session.get("rpg_enabled") else [],
            "story_arc": arc if session and session.get("rpg_enabled") else None,
            "narrator_meta": narrator_meta,
        }
        if rpg_settings.get("stats") and updated_session:
            done_payload["narrative_stats"] = parse_stats_json(
                updated_session.get("narrative_stats_json")
            )

        if session and session.get("rpg_enabled"):
            snap_id = msg_id or await get_last_message_id(request.session_id)
            if snap_id:
                await save_state_snapshot(request.session_id, snap_id)

        yield f"data: {json.dumps(done_payload)}\n\n"

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


@router.post("/", response_model=ChatResponse)
async def chat(request: ChatRequest):
    await get_or_create_session(request.session_id, request.persona_id)
    persona_id = await resolve_session_persona(
        request.session_id,
        request.persona_id,
        create_persona=request.persona_id,
    )

    history = await get_history(request.session_id)
    static_prompt = await get_system_prompt(persona_id, history, request.message)
    await upsert_static_system_message(request.session_id, static_prompt, history)

    await add_message(request.session_id, "user", request.message)
    messages = await get_history(request.session_id)
    session = await get_session(request.session_id)
    llm_system = static_prompt
    if session and session.get("rpg_enabled"):
        rpg_settings = get_rpg_settings(session)
        facts_block = facts_to_prompt(session.get("facts_json", "[]"))
        llm_system += build_rpg_runtime_suffix(session, rpg_settings, facts_block)
    if persona_id != "default" or (session and session.get("rpg_enabled")):
        llm_system += RP_OUTPUT_REMINDER
    llm_messages = messages_for_llm(messages, llm_system)
    reply = await send_message(llm_messages)
    display = strip_ooc_from_reply(strip_image_prompt_tag(reply))
    bundle = await generate_sd_prompt(
        messages,
        persona_id,
        outfit_json=session.get("outfit_json", "[]") if session else "[]",
        scene_json=session.get("scene_json", "{}") if session else "{}",
    )
    prompt_str = bundle.tag_full if bundle else extract_image_prompt_tag(reply)

    await add_message(request.session_id, "assistant", display, image_prompt=prompt_str)

    return ChatResponse(
        reply=display,
        session_id=request.session_id,
        image_prompt=prompt_str,
    )


@router.delete("/messages/{message_id}")
async def remove_message(message_id: int):
    msg = await get_message(message_id)
    if not msg:
        raise HTTPException(status_code=404, detail="Сообщение не найдено")
    await delete_message_and_following(msg["session_id"], message_id)
    return {"status": "deleted", "message_id": message_id}


@router.patch("/messages/{message_id}")
async def edit_message(message_id: int, req: MessageEditRequest):
    msg = await get_message(message_id)
    if not msg:
        raise HTTPException(status_code=404, detail="Сообщение не найдено")
    await update_message_content(message_id, req.content)
    if req.truncate_after:
        await delete_messages_after(msg["session_id"], message_id)
    return {"status": "updated", "message_id": message_id}


@router.post("/regenerate")
async def regenerate_chat(req: RegenerateRequest):
    msg_id = req.message_id or await get_last_assistant_message_id(req.session_id)
    if not msg_id:
        raise HTTPException(status_code=400, detail="Нет сообщения для перегенерации")
    msg = await get_message(msg_id)
    if not msg or msg.get("role") != "assistant":
        raise HTTPException(status_code=400, detail="Неверное сообщение")
    await delete_message(msg_id)
    history = await get_history(req.session_id)
    last_user = next((m for m in reversed(history) if m["role"] == "user"), None)
    if not last_user:
        raise HTTPException(status_code=400, detail="Нет сообщения пользователя")
    user_text = last_user["content"]
    if user_text.startswith("[Player chose: ") and user_text.endswith("]"):
        user_text = user_text[15:-1]
    stream_req = ChatRequest(
        message=user_text,
        session_id=req.session_id,
        skip_user_add=True,
    )
    return await chat_stream(stream_req)


@router.delete("/{session_id}")
async def clear_chat(session_id: str):
    await clear_history(session_id)
    return {"status": "cleared", "session_id": session_id}
