import json

DEFAULT_NARRATIVE_STATS = {"lust": 0, "stamina": 10, "tension": 0}
STAT_KEYS = ("lust", "stamina", "tension")


def parse_stats_json(raw: str | None) -> dict:
    try:
        data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
    except Exception:
        data = {}
    if not isinstance(data, dict):
        data = {}
    out = dict(DEFAULT_NARRATIVE_STATS)
    for k in STAT_KEYS:
        try:
            out[k] = max(0, min(10, int(data.get(k, out[k]))))
        except (TypeError, ValueError):
            pass
    return out


def parse_scene_json(raw: str | None) -> dict:
    try:
        data = json.loads(raw or "{}") if isinstance(raw, str) else (raw or {})
    except Exception:
        data = {}
    return data if isinstance(data, dict) else {}


def merge_scene(existing: dict, update: dict | None) -> dict:
    if not update or not isinstance(update, dict):
        return dict(existing)
    merged = dict(existing)
    for k, v in update.items():
        if v is None:
            continue
        if k == "exits" and isinstance(v, list):
            merged["exits"] = v
        elif isinstance(v, str) and v.strip():
            merged[k] = v.strip()
        elif v != "":
            merged[k] = v
    return merged


def apply_stats_delta(stats: dict, delta: dict | None) -> dict:
    out = parse_stats_json(json.dumps(stats, ensure_ascii=False))
    if not delta or not isinstance(delta, dict):
        return out
    for k in STAT_KEYS:
        try:
            d = int(delta.get(k, 0))
        except (TypeError, ValueError):
            d = 0
        d = max(-2, min(2, d))
        if d:
            out[k] = max(0, min(10, out[k] + d))
    return out


def scene_prompt_block(scene: dict) -> str:
    if not scene:
        return ""
    lines = []
    for key, label in (
        ("place", "Location"),
        ("place_id", "Location ID"),
        ("time_of_day", "Time of day"),
        ("day", "Day"),
        ("weather", "Weather"),
        ("layout_note", "Layout"),
    ):
        v = (scene.get(key) or "").strip()
        if v:
            lines.append(f"{label}: {v}")
    exits = scene.get("exits")
    if isinstance(exits, list) and exits:
        lines.append("Exits: " + ", ".join(str(e) for e in exits))
    if not lines:
        return ""
    return (
        "\n\n--- Scene ---\n"
        + "\n".join(lines)
        + "\nUse this setting consistently. Do not recite scene labels verbatim.\n---"
    )


# Scoped to current player until multi-user / multi-card relationships exist.
RELATIONSHIP_TARGET_LABEL = "current player (the human you are roleplaying with)"


def _mandatory_state_preamble() -> str:
    return (
        "MANDATORY ROLEPLAY DIRECTIVE (binding — overrides default cheerfulness or energy):\n"
        f"- Target: {RELATIONSHIP_TARGET_LABEL} only.\n"
        "- Your reply MUST visibly obey this state in tone, body language, pacing, and what the character attempts.\n"
        "- Do NOT contradict a low stamina / high tension / low affinity reading with peppy or intimate behavior unless the text above explicitly allows recovery.\n"
        "- Never name affinity, lust, stamina, tension, stats, meters, or numeric values in dialogue.\n"
    )


def _band_instruction(value: int, bands: list[tuple[int, str]]) -> str:
    """bands: list of (min_inclusive, instruction) sorted by min descending."""
    v = int(value)
    for min_val, text in bands:
        if v >= min_val:
            return text
    return bands[-1][1] if bands else ""


def affinity_prompt_block(affinity: int) -> str:
    aff = max(-30, min(30, int(affinity)))
    attitude = _band_instruction(
        aff,
        [
            (10, "Devoted trust: openly affectionate, seeks closeness, defends the player, vulnerable honesty."),
            (5, "Warm bond: friendly, teasing allowed, volunteers help, remembers small kindnesses."),
            (1, "Slight fondness: polite-positive, rare soft moments, not yet intimate."),
            (0, "Neutral professional distance: neither warm nor cold unless scene demands."),
            (-1, "Cool and guarded: short answers, deflects personal topics, skeptical of motives."),
            (-5, "Hostile or deeply distrustful: sarcasm, refusal, may threaten to leave or expose the player."),
            (-30, "Open enmity: antagonistic, undermines, may sabotage or attack socially/physically if fitting genre."),
        ],
    )
    return (
        "\n\n--- Relationship toward player (MANDATORY) ---\n"
        + _mandatory_state_preamble()
        + f"Affinity (internal, not spoken): level {aff}.\n"
        f"Required attitude toward {RELATIONSHIP_TARGET_LABEL}: {attitude}\n"
        "---"
    )


def stats_prompt_block(stats: dict) -> str:
    s = parse_stats_json(json.dumps(stats, ensure_ascii=False))
    lust = s["lust"]
    stamina = s["stamina"]
    tension = s["tension"]

    lust_line = _band_instruction(
        lust,
        [
            (9, "Overwhelming arousal colors every beat: breathy, distracted, struggles to stay on task."),
            (7, "Strong desire: flushed skin, lingering touch, voice unsteady, thoughts drift to intimacy."),
            (5, "Clear attraction: meaningful glances, leaning in, playful double meanings if genre fits."),
            (3, "Mild warmth: subtle flirt only when appropriate; easily redirected."),
            (1, "Little romantic charge: platonic focus unless the player escalates."),
            (0, "No romantic or sexual undertone in body language or subtext."),
        ],
    )
    stamina_line = _band_instruction(
        stamina,
        [
            (9, "Peak energy: brisk movement, sharp focus, may offer to take strenuous actions."),
            (7, "Well-rested: alert, steady pace, normal exertion."),
            (5, "Average fatigue: fine for talk/light action; heavy labor needs justification."),
            (4, "Tired: slower reactions, sits when possible, voice softer."),
            (3, "Heavy fatigue: frequent pauses, avoids running/fighting, may ask to stop."),
            (2, "Exhausted: barely moves, slumped posture, short sentences, needs support to walk."),
            (1, "On the verge of collapse: eyelids heavy, may stumble or nearly pass out; minimal action only."),
            (0, "Cannot sustain activity: collapse/immediate sleep/rest is imminent unless helped."),
        ],
    )
    tension_line = _band_instruction(
        tension,
        [
            (9, "Breaking point: trembling, tears or rage close to surface, irrational snap decisions."),
            (7, "High stress: clipped speech, hyper-vigilant, jumps at sounds, defensive."),
            (5, "Uneasy: fidgeting, forced smiles, changes subject from danger."),
            (3, "Mild edge: occasional sigh, watches exits, relaxes with reassurance."),
            (1, "Mostly calm: normal breathing, open posture."),
            (0, "Fully at ease in body and voice."),
        ],
    )

    return (
        "\n\n--- Physical & emotional state (MANDATORY) ---\n"
        + _mandatory_state_preamble()
        + f"Internal scales (0–10, never spoken): lust/arousal={lust}, stamina/energy={stamina}, tension/stress={tension}.\n"
        f"- Lust/arousal: {lust_line}\n"
        f"- Stamina/energy: {stamina_line}\n"
        f"- Tension/stress: {tension_line}\n"
        "If multiple apply, combine them (e.g. low stamina + high tension = shaky exhaustion, not peppy panic).\n"
        "---"
    )


def format_narrator_outcome_for_llm(data: dict, *, lang: str = "ru") -> str:
    """Turn stored narrator JSON into a binding user-turn for the character model."""
    roll = data.get("roll")
    outcome = (data.get("outcome") or "").strip().lower()
    text = (data.get("text") or "").strip()
    if lang == "ru":
        lines = [
            "--- Правило рассказчика (ОБЯЗАТЕЛЬНО — ответ персонажа должен ему следовать) ---",
            f"Бросок d20={roll}. Исход: {outcome}.",
            f"Что РЕАЛЬНО произошло (канон): {text}",
        ]
        if outcome in ("failure", "critical failure"):
            lines.append(
                "Действие игрока НЕ удалось. Не пиши успешную версию — покажи провал или частичный результат выше."
            )
        elif outcome == "critical success":
            lines.append("Попытка удалась блестяще. Усиль успех в духе исхода выше.")
        else:
            lines.append("Попытка удалась. Ответ должен совпадать с исходом выше, не противоречить ему.")
        lines.append("Отвечай только как персонаж на ЭТОТ исход. Не упоминай кубики, броски, статы.")
    else:
        lines = [
            "--- Narrator ruling (MANDATORY — your next in-character reply MUST follow this) ---",
            f"Roll d20={roll}. Outcome: {outcome}.",
            f"What ACTUALLY happened (canonical truth): {text}",
        ]
        if outcome in ("failure", "critical failure"):
            lines.append(
                "The player's action FAILED. Do NOT write a success version; show failure per above."
            )
        elif outcome == "critical success":
            lines.append(
                "The attempt succeeded dramatically. Align with the outcome above."
            )
        else:
            lines.append(
                "The attempt succeeded. Your reply must align with the narrator outcome above."
            )
        lines.append("Respond as the character to THIS outcome only. Never cite dice, rolls, or stats.")
    lines.append("---")
    return "\n".join(lines)


def format_user_message_for_llm(content: str, *, has_dice_resolution: bool) -> str:
    if not has_dice_resolution:
        return content
    return (
        "[Player stated intent — canonical outcome is in the narrator ruling immediately below]\n"
        + content
    )


def scene_to_sd_hint(scene: dict) -> str:
    if not scene:
        return ""
    parts = []
    for k in ("place", "time_of_day", "day", "weather", "layout_note"):
        v = (scene.get(k) or "").strip()
        if v:
            parts.append(f"{k}: {v}")
    return "\n".join(parts)


async def apply_narrator_post(session_id: str, post: dict, rpg_settings: dict, session: dict | None = None) -> dict:
    """Persist narrator_post fields into session. Returns summary of what changed."""
    from services.memory import (
        get_session,
        update_session_status_quo,
        update_session_affinity,
        update_session_outfit,
        update_session_scene,
        update_session_narrative_stats,
        update_session_facts,
        upsert_quest,
    )
    from services.rpg_facts import merge_facts_persist, rp_day_from_scene, parse_facts_list

    if session is None:
        session = await get_session(session_id) or {}

    applied = {
        "status_quo": False,
        "facts_added": 0,
        "affinity_delta": 0,
        "quests_updated": 0,
        "scene": False,
        "outfit": False,
    }

    sq = (post.get("status_quo_update") or "").strip()
    if sq:
        await update_session_status_quo(session_id, sq)
        applied["status_quo"] = True

    post_facts = post.get("facts") or []
    if isinstance(post_facts, list) and post_facts:
        rp_day = rp_day_from_scene(session.get("scene_json"))
        before = len(parse_facts_list(session.get("facts_json") or "[]"))
        merged = await merge_facts_persist(
            session.get("facts_json", "[]"),
            post_facts,
            rp_day_default=rp_day,
            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(session_id, merged)
        after = len(parse_facts_list(merged))
        applied["facts_added"] = max(0, after - before)

    if rpg_settings.get("affinity", True):
        delta = int(post.get("affinity_delta") or 0)
        if delta:
            await update_session_affinity(session_id, delta)
            applied["affinity_delta"] = delta

    outfit_update = post.get("outfit_update")
    if isinstance(outfit_update, list) and outfit_update:
        from services.outfit_tags import outfit_list_to_json

        await update_session_outfit(session_id, outfit_list_to_json(outfit_update))
        applied["outfit"] = True

    scene_update = post.get("scene_update")
    if isinstance(scene_update, dict) and scene_update:
        merged = merge_scene(parse_scene_json(session.get("scene_json")), scene_update)
        await update_session_scene(session_id, json.dumps(merged, ensure_ascii=False))
        applied["scene"] = True

    if rpg_settings.get("stats", False):
        stats_delta = post.get("stats_delta")
        if isinstance(stats_delta, dict) and stats_delta:
            current = parse_stats_json(session.get("narrative_stats_json"))
            updated = apply_stats_delta(current, stats_delta)
            await update_session_narrative_stats(
                session_id, json.dumps(updated, ensure_ascii=False)
            )

    if rpg_settings.get("quests", True):
        for qu in post.get("quest_updates") or []:
            title = (qu.get("title") or "").strip()
            if title:
                await upsert_quest(session_id, title[:120], qu.get("status", "active"))
                applied["quests_updated"] += 1

    return applied


async def apply_narrator_post_with_story(
    session_id: str,
    post: dict,
    rpg_settings: dict,
    session: dict | None = None,
    arc: dict | None = None,
) -> dict:
    """apply_narrator_post + linear story step advance."""
    from services.rpg_story import apply_story_post, normalize_story_arc

    applied = await apply_narrator_post(session_id, post, rpg_settings, session)
    arc = normalize_story_arc(arc or {})
    story = await apply_story_post(session_id, post, arc, rpg_settings)
    applied.update({
        "step_advanced": story.get("step_advanced", False),
        "arc_completed": story.get("arc_completed", False),
        "new_step_title": story.get("new_step_title", ""),
        "step_injection": story.get("step_injection", ""),
    })
    if story.get("arc"):
        applied["arc"] = story["arc"]
    return applied
