from __future__ import annotations

import json
import random
import re
import sys
from datetime import datetime
from difflib import SequenceMatcher
from pathlib import Path

from engine_lib.models import TurnResult, END_MARKER
from engine_lib import config
from engine_lib.context import build_system_prompt
from engine_lib.validation import validate_turn
from engine_lib.tools_handler import execute_tool, describe_change, extract_tool_calls
from engine_lib.parsing import log_turn_details
from engine_lib import state
from engine_lib.llm import call_llm
from engine_lib.paths import get_character_creation_path, RULES_INJECTION_PATH


class GameEngine:
    REQUIRED_TOOL_ARGS: dict[str, list[str]] = {
        "modify_inventory": ["operation", "item"],
        "modify_note": ["operation"],
        "modify_world": ["operation", "value"],
        "modify_journal": ["operation", "value"],
    }

    def __init__(self, session_dir: str | Path | None = None):
        self.config = config.load_config()

    def _check_required_tool_args(self, state_changes: list[dict]) -> str:
        """Check that all state-changing tool calls have required args. Returns empty string if OK, or a description of what's missing."""
        missing = []
        for tc in state_changes:
            name = tc.get("tool", "")
            req = self.REQUIRED_TOOL_ARGS.get(name)
            if not req:
                continue
            args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}
            for arg in req:
                val = args.get(arg)
                if val is None or (isinstance(val, str) and not val.strip()):
                    missing.append(f"{name}: missing required `{arg}`")
        return "; ".join(missing)

    def generate_turn(
        self,
        player_action: str | None = None,
        recent_narrative: str | None = None,
        on_thought: callable = None,
        on_action: callable = None,
    ) -> TurnResult:
        now = datetime.now()
        state.append_llm_log(f"\n{'='*60}")
        state.append_llm_log(f"=== Turn — {now.strftime('%Y-%m-%d %H:%M:%S')} ===")
        state.append_llm_log(f"{'='*60}")
        if player_action:
            state.append_llm_log(f"Player: {player_action}")
        if recent_narrative is None:
            recent_narrative = state.read_recent_book(2)
        session_log = state.read_recent_log()

        die_roll = random.randint(1, 6)
        state.append_llm_log(f"Dice: {die_roll} (1d6)")

        lm = self.config.get("llm", {})
        model = lm.get("model", "ollama/llama3.1")

        if on_action:
            on_action("DM is preparing a response")

        is_new_game = not player_action and not recent_narrative
        system = build_system_prompt(recent_narrative=recent_narrative, recent_log=session_log)
        if is_new_game:
            cc = state.read_file(get_character_creation_path())
            if cc:
                system += f"\n\n## Character Creation Reference\n{cc}"
                state.append_llm_log(f"\n[NEW GAME] injected character_creation.md ({len(cc)} chars)")

        is_meta = bool(player_action and player_action.strip().startswith(">"))

        base_parts = []
        if player_action:
            base_parts.append(f"## Player's Request\n{player_action}")
        if is_meta:
            base_parts.append(
                "## Instructions\n"
                "The player's message starts with `>` — this is a meta out-of-character question to the DM. "
                "Do NOT advance the story. Respond as the DM in meta language, starting the response with `>`. "
                "Use the `narrative` tool to output your meta response. Do NOT call any other tools (no modify_journal, no finalize_turn, no rolls, no state changes)."
            )
        elif is_new_game:
            base_parts.append(
                "## Instructions\n"
                "This is a new story. Welcome the player and guide them through the game setup."
            )
        else:
            base_parts.append(
                "## Instructions\n"
                "Advance the story based on the player's request. "
                "All state is shown above — write the outcome directly."
            )
        if not is_meta:
            base_parts.append(f"\n*A die is cast: **{die_roll}** (1d6).*")
        base_user = "\n\n".join(base_parts)

        MAX_RETRIES = 2
        tool_calls = []
        book_log = ""
        ambience = None
        errors: list[str] = []
        changes: list[str] = []
        start_time = datetime.now()
        total_attempts = 0
        prev_raw = ""

        _ = None  # placeholder

        for attempt in range(MAX_RETRIES + 1):
            total_attempts = attempt + 1
            user = base_user
            if attempt > 0:
                user += f"\n\n---\n\n## Your Previous Response\n\n```\n{prev_raw}\n```\n\n---\n\n## Feedback\n{feedback}"

            state.append_llm_log(f"\n[TOOL] Attempt {attempt + 1}/{MAX_RETRIES + 1} — {len(system)} chars system, {len(user)} chars user")

            text = call_llm(
                [{"role": "system", "content": system}, {"role": "user", "content": user}],
                label="Turn generation",
            )

            if not text or not text.strip():
                if attempt < MAX_RETRIES:
                    feedback = f"Your response was empty. Generate a complete turn with narrative and state changes."
                    state.append_llm_log("\n[RETRY] empty response")
                    if on_action:
                        on_action("DM is weaving the tale...")
                    continue
                return TurnResult(error="LLM returned empty response after retries")

            raw = text.strip()
            state.append_llm_log(f"\n[TOOL] got {len(raw)} chars in {(datetime.now() - start_time).total_seconds() * 1000:.1f}ms")
            prev_raw = raw

            tool_calls = extract_tool_calls(raw)
            if not tool_calls:
                state.append_llm_log("\n[TOOL] no tool blocks found")

            # First pass — extract narrative + identify state changes (don't execute yet)
            book_log = ""
            ambience = None
            log_entry = None
            meta_log = ""
            state_changes: list[dict] = []

            for tc in tool_calls:
                name = tc.get("tool", "")
                args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}

                if name == "narrative":
                    text = args.get("text", "") or ""
                    if text:
                        book_log = (book_log + "\n\n" + text) if book_log else text
                elif name == "finalize_turn":
                    raw = (args.get("ambience") or "").strip().lower()
                    if raw:
                        valid = state.get_valid_ambiences()
                        if raw in valid:
                            ambience = raw
                        else:
                            state.append_llm_log(f"\n[WARN] invalid ambience '{raw}'")
                            ambience = None
                    if args.get("log_entry"):
                        log_entry = args["log_entry"]
                    if args.get("meta_log"):
                        meta_log = args["meta_log"]
                elif name == "read_rules":
                    cat = args.get("category", "mechanics")
                    result = execute_tool("read_rules", {"category": cat})
                    state.append_llm_log(f"\n[READ RULES] loaded {len(result)} chars")
                    RULES_INJECTION_PATH.parent.mkdir(parents=True, exist_ok=True)
                    RULES_INJECTION_PATH.write_text(result)
                else:
                    state_changes.append(tc)

            # Required args check — reject if any state-changing tool is missing required arguments
            missing_args = self._check_required_tool_args(state_changes)
            if missing_args:
                state.append_llm_log(f"\n[TURN MISSING ARGS] {missing_args}")
                if attempt < MAX_RETRIES:
                    feedback = f"The following tool calls are missing required arguments: {missing_args}. Include all required fields for each tool and regenerate."
                    state.append_llm_log(f"\n[TURN REGENERATE] (missing args) attempt {attempt + 2}")
                    if on_action:
                        on_action("DM is consulting the fates...")
                    continue
                state.append_llm_log(f"\n[TURN MISSING ARGS EXCEEDED] accepting despite missing args")

            # Meta check — reject if state changes produced for a meta action
            if is_meta and state_changes:
                state.append_llm_log(f"\n[TURN META REJECTED] state changes not allowed for meta action")
                if attempt < MAX_RETRIES:
                    feedback = f"This is a meta action. Do NOT call any state-changing tools. Respond only with meta text (starting with `>`) and no tool calls beyond a finalize_turn."
                    state.append_llm_log(f"\n[TURN REGENERATE] (meta) attempt {attempt + 2}")
                    if on_action:
                        on_action("DM is consulting the fates...")
                    continue
                state.append_llm_log(f"\n[TURN META EXCEEDED] accepting despite state changes")

            # Narrative check — reject if finalized with log_entry but no narrative
            if not is_meta and log_entry and not book_log:
                state.append_llm_log(f"\n[TURN NO NARRATIVE] finalized with log_entry but no narrative")
                if attempt < MAX_RETRIES:
                    feedback = f"You called finalize_turn with a log_entry but produced no narrative. Every turn must include a `narrative` tool block with the story. Regenerate with both narrative and log_entry."
                    state.append_llm_log(f"\n[TURN REGENERATE] (no narrative) attempt {attempt + 2}")
                    if on_action:
                        on_action("DM is weaving the tale...")
                    continue
                state.append_llm_log(f"\n[TURN NO NARRATIVE EXCEEDED] accepting despite missing narrative")

            # Duplicate check — reject if narrative is 80%+ similar to last book entry
            if not is_meta and book_log:
                prev = state.read_recent_book(1)
                if prev and prev not in ("*No prior story.*",):
                    prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip()
                    ratio = SequenceMatcher(None, book_log, prev_text).ratio()
                    if ratio >= 0.8:
                        state.append_llm_log(f"\n[TURN DUPLICATE] {ratio:.0%} match with previous turn")
                        if attempt < MAX_RETRIES:
                            feedback = f"The narrative is nearly identical to the previous turn. Generate something new and different."
                            state.append_llm_log(f"\n[TURN REGENERATE] (duplicate) attempt {attempt + 2}")
                            if on_action:
                                on_action("DM is weaving the tale...")
                            continue
                        state.append_llm_log(f"\n[TURN DUPLICATE EXCEEDED] cannot generate unique narrative")
                        return TurnResult(
                            book_log="",
                            log_entry="Your action was rejected — could not generate a unique narrative.",
                            user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\nengine failed to produce unique narrative*",
                        )

            # Validate the generated turn
            if on_action:
                on_action("DM is validating the response")
            if player_action and book_log:
                valid, reason, action = validate_turn(
                    player_action,
                    narrative=book_log,
                    log_entry=log_entry or "",
                    changes=state_changes,
                    story=recent_narrative,
                    log=session_log,
                    meta=is_meta,
                )

                if valid:
                    state.append_llm_log(f"\n[TURN VALID] {reason}")
                elif reason == "Unrecognized":
                    if attempt < MAX_RETRIES:
                        feedback = f"The validation system could not process the previous turn. Please regenerate."
                        state.append_llm_log(f"\n[TURN REGENERATE] (unrecognized) attempt {attempt + 2}")
                        if on_action:
                            on_action("DM is consulting the fates...")
                        continue
                    state.append_llm_log(f"\n[TURN UNRECOGNIZED] cannot validate turn")
                    return TurnResult(
                        book_log="",
                        log_entry="Your action was rejected — cannot validate turn.",
                        user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\ncannot validate turn*",
                    )
                elif action == "reject":
                    state.append_llm_log(f"\n[TURN REJECTED] {reason}")
                    return TurnResult(
                        book_log="",
                        log_entry=f"Your action was rejected — {reason}.",
                        user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\n\t{reason}*",
                    )
                elif action == "regenerate" and attempt < MAX_RETRIES:
                    validator_tool = json.dumps({"tool": "validate", "args": {"valid": False, "reason": reason, "action": "regenerate"}})
                    feedback = f"The validation tool returned:\n```tool\n{validator_tool}\n```\n\nPlease regenerate the turn addressing the issues above. Keep the same player action but fix the problems described."
                    state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}")
                    if on_action:
                        on_action("DM is searching for inspiration...")
                    continue
                else:
                    state.append_llm_log(f"\n[TURN REGENERATE EXCEEDED] accepting despite: {reason}")
            else:
                state.append_llm_log("\n[TURN SKIP VALIDATION] no player action or no narrative")

            # Accept this turn — execute all tool calls
            break

        if is_meta:
            tool_calls = [tc for tc in tool_calls if tc.get("tool") == "narrative"]

        # Second pass — execute all tool calls
        extr_start = datetime.now()

        for tc in tool_calls:
            name = tc.get("tool", "")
            args = tc.get("args") or {k: v for k, v in tc.items() if k != "tool"}

            if name in ("narrative", "read_rules"):
                pass
            else:
                result = execute_tool(name, args)

            if name not in ("narrative", "finalize_turn", "player_roll", "read_rules"):
                if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
                    errors.append(f"{name}: {result}")
                else:
                    desc = describe_change(name, args)
                    if desc:
                        changes.append(desc)

        if not log_entry and book_log:
            clean = re.sub(r'\s+', ' ', book_log).strip()
            sentences = re.split(r'(?<=[.!?])\s+', clean)
            log_entry = sentences[0][:200] if sentences else clean[:200]

        apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000
        state.append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms")

        total_elapsed = (datetime.now() - start_time).total_seconds() * 1000

        game_over = END_MARKER in book_log

        if on_action:
            on_action("Turn complete")

        log_turn_details(
            player_action=player_action or "",
            last_prompt=recent_narrative or "",
            strategy_name="tools",
            die_roll=die_roll,
            model=model,
            temperature=lm.get("temperature", 0.8),
            max_tokens=lm.get("max_tokens", 4096),
            book_log=book_log,
            log_entry=log_entry or "",
            ambience=ambience,
            tool_calls=tool_calls,
            meta_log=meta_log,
        )

        return TurnResult(
            book_log=book_log,
            log_entry=log_entry,
            ambience=ambience,
            debug_info="; ".join(errors) if errors else "",
            changes=changes,
            is_meta=is_meta,
            game_over=game_over,
            meta_log=meta_log,
        )


def main():
    """Generate a turn from the command line (debug/testing)."""
    import argparse

    parser = argparse.ArgumentParser(description="The Chaos Game Engine (CLI)")
    parser.add_argument("--action", "-a", help="Player action text")
    args = parser.parse_args()

    engine = GameEngine()
    result = engine.generate_turn(
        player_action=args.action,
    )

    if result.error:
        print(f"ERROR: {result.error}", file=sys.stderr)
        sys.exit(1)

    print(result.book_log)
    if result.user_prompt:
        print(f"\n{result.user_prompt}")
    if result.log_entry:
        print(f"\n[Log] {result.log_entry}")
    if result.ambience:
        print(f"[Ambience] {result.ambience}")


if __name__ == "__main__":
    main()
