from __future__ import annotations

import json
import re

from .paths import (
    AMBIENCE_PATH, CHAR_PATH, WORLD_PATH,
    get_mechanics_path, get_core_rules_path,
    get_character_creation_path, get_end_game_path,
)
from .state import read_file, validate_update_size, update_journal, append_llm_log, get_valid_ambiences


TOOL_REGISTRY: dict[str, dict] = {
    "modify_traits": {"description": "Change STR/DEX/WIL.", "args": {"stat": "str/dex/wil", "operation": "set|diff", "value": "new value"}},
    "modify_vitals": {"description": "Change HP, max_hp, weapon, armour.", "args": {"stat": "HP|max_hp|weapon|armour", "operation": "set|diff", "value": "number or string"}},
    "modify_cash": {"description": "Change cash amount.", "args": {"operation": "set|diff", "value": "number"}},
    "modify_inventory": {"description": "Add, remove, or replace gear. For add with quantity, item name prefix matches existing stack.", "args": {"operation": "add|remove|replace", "item": "item name", "value": "quantity (int) for add/remove, new text for replace"}},
    "modify_note": {"description": "Add, remove, or replace a note.", "args": {"operation": "add|remove|replace", "stat": "existing text (for remove/replace)", "value": "note text"}},
    "modify_world": {"description": "Replace full world state.", "args": {"operation": "set", "value": "full world markdown"}},
    "modify_journal": {"description": "Update TODO/DONE list.", "args": {"operation": "add|done|remove", "value": "entry text"}},
    "finalize_turn": {"description": "End turn.", "args": {"ambience": "soundscape name", "log_entry": "one-line summary of what happened", "meta_log": "optional behind-the-scenes mechanics explanation"}},
    "read_rules": {"description": "Read a rules file by category. Categories: mechanics (full mechanics reference), core (core mechanics), character_creation, end_game (end-game closure rules). Call when you need details beyond the Core Rules in the prompt.", "args": {"category": "optional — one of: mechanics, core, character_creation, end_game (default: mechanics)"}},
}


def patch_character(pattern: str, repl: str, count: int = 1, flags: int = 0) -> str:
    """Apply a regex replacement to character.md. Returns error msg or empty string."""
    text = CHAR_PATH.read_text()
    new, n = re.subn(pattern, repl, text, count=count, flags=flags)
    if n == 0:
        return f"**Error:** pattern not found:\n{pattern}"
    CHAR_PATH.write_text(new)
    return ""


VITAL_LABELS = {
    "HP": "Current Health",
    "max_hp": "Max Health",
    "weapon": "Weapon",
    "armour": "Armour",
}


def _parse_current_int(label: str) -> int | None:
    """Read a numeric value from character.md by label."""
    text = CHAR_PATH.read_text()
    m = re.search(rf"^- \*\*{re.escape(label)}:\*\*\s*(\d+)", text, re.MULTILINE)
    return int(m.group(1)) if m else None


def _apply_diff(label: str, diff: int) -> str:
    """Apply a numeric diff to a label in character.md. Returns result string."""
    current = _parse_current_int(label)
    if current is None:
        return f"**Error:** cannot find numeric {label} in character sheet"
    new_val = current + diff
    err = patch_character(
        rf"^(- \*\*{re.escape(label)}:\*\*\s*)\d+",
        rf"\g<1>{new_val}",
        count=1, flags=re.MULTILINE,
    )
    if err:
        return err
    return f"{label}: {current} → {new_val} ({diff:+d})"


def tool_modify_traits(args: dict) -> str:
    errors = []
    for stat in ("str", "dex", "wil"):
        val = args.get(stat)
        if val is not None:
            err = patch_character(
                rf"^(- \*\*{stat.upper()}:\*\*\s*)\d+", rf"\g<1>{val}", count=1, flags=re.MULTILINE
            )
            if err:
                errors.append(err)
    return "; ".join(errors) if errors else "Traits updated."


def tool_modify_vitals(args: dict) -> str:
    stat = args.get("stat", "")
    op = args.get("operation", "set")
    value = args.get("value")

    label = VITAL_LABELS.get(stat)
    if not label:
        return f"**Error:** unknown stat '{stat}'. Use: HP, max_hp, weapon, armour."

    if op == "set":
        err = patch_character(
            rf"^(- \*\*{re.escape(label)}:\*\*\s*).*", rf"\g<1>{value}",
            count=1, flags=re.MULTILINE,
        )
        return f"{label} set to {value}." if not err else err
    elif op == "diff":
        if stat in ("weapon", "armour"):
            return f"**Error:** diff not supported for {stat}. Use set."
        try:
            diff = int(value)
        except (TypeError, ValueError):
            return f"**Error:** value must be a number for diff, got {value!r}"
        return _apply_diff(label, diff)
    else:
        return f"**Error:** unknown operation '{op}'. Use set or diff."


def tool_modify_cash(args: dict) -> str:
    op = args.get("operation", "set")
    value = args.get("value")

    if op == "set":
        err = patch_character(
            rf"^(- \*\*Cash:\*\*\s*).*", rf"\g<1>{value}",
            count=1, flags=re.MULTILINE,
        )
        return f"Cash set to {value}." if not err else err
    elif op == "diff":
        try:
            diff = int(value)
        except (TypeError, ValueError):
            return f"**Error:** value must be a number for diff, got {value!r}"
        return _apply_diff("Cash", diff)
    else:
        return f"**Error:** unknown operation '{op}'. Use set or diff."


def _find_gear_line(text: str, item_name: str) -> tuple[str, int, str] | None:
    """Find a gear line matching item_name (prefix match). Returns (full_line, line_start, quantity_str) or None."""
    for m in re.finditer(r"^- (.+)$", text, re.MULTILINE):
        line = m.group(1)
        line_start = m.start(1) - 2  # start of "- "
        line_lower = line.lower().strip()
        name_lower = item_name.lower().strip()
        if line_lower.startswith(name_lower):
            qty_match = re.search(r"\((\d+)\)\s*$", line)
            qty = qty_match.group(1) if qty_match else None
            return line, line_start, qty
    return None


def tool_modify_inventory(args: dict) -> str:
    op = args.get("operation", "")
    item = args.get("item", "")
    value = args.get("value")

    if not item:
        return "**Error:** `item` is required."

    text = CHAR_PATH.read_text()

    if op == "add":
        existing = _find_gear_line(text, item)
        if existing:
            full_line, line_start, qty_str = existing
            if qty_str is not None and value is not None:
                try:
                    new_qty = int(qty_str) + int(value)
                except (TypeError, ValueError):
                    return f"**Error:** value must be a number for add with quantity, got {value!r}"
                if new_qty <= 0:
                    new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
                    CHAR_PATH.write_text(new_text)
                    return f"Removed all {item} from inventory (quantity reached 0)."
                new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
                CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
                return f"{item}: {qty_str} → {new_qty}"
            if qty_str is None:
                return f"Item '{full_line}' exists but has no quantity to stack. Use replace to update it."

        gear_section = re.search(r"^## Gear\n", text, re.MULTILINE)
        insert_at = gear_section.end() if gear_section else len(text)
        if not gear_section:
            text += "\n## Gear\n"
            insert_at = len(text)
        val_str = f" ({value})" if value is not None else ""
        new_entry = f"- {item}{val_str}\n"
        text = text[:insert_at] + new_entry + text[insert_at:]
        CHAR_PATH.write_text(text)
        return f"Added to inventory: {item}{val_str}"

    elif op == "remove":
        existing = _find_gear_line(text, item)
        if not existing:
            return f"**Error:** item not found: {item}"
        full_line, line_start, qty_str = existing
        if qty_str is not None and value is not None:
            try:
                new_qty = int(qty_str) - int(value)
            except (TypeError, ValueError):
                return f"**Error:** value must be a number for remove with quantity, got {value!r}"
            if new_qty <= 0:
                new_text = text[:line_start] + text[line_start + len(full_line) + 2:]
                CHAR_PATH.write_text(new_text)
                return f"Removed all {item} from inventory (quantity reached 0)."
            new_line = full_line.replace(f"({qty_str})", f"({new_qty})", 1)
            CHAR_PATH.write_text(text.replace(full_line, new_line, 1))
            return f"{item}: {qty_str} → {new_qty}"
        err = patch_character(rf"^- {re.escape(full_line)}\n?", "", count=1, flags=re.MULTILINE)
        if err:
            return f"**Error:** item not found: {item}"
        return f"Removed from inventory: {full_line}"

    elif op == "replace":
        after = value or ""
        if not after:
            return "**Error:** `value` (new text) is required for replace."
        existing = _find_gear_line(text, item)
        if not existing:
            return f"**Error:** item not found: {item}"
        full_line = existing[0]
        err = patch_character(rf"^- {re.escape(full_line)}", f"- {after}", count=1, flags=re.MULTILINE)
        if err:
            return f"**Error:** item not found: {item}"
        return f"Gear replaced: {full_line} → {after}"

    return "**Error:** unknown operation. Use add, remove, or replace."


def tool_modify_note(args: dict) -> str:
    op = args.get("operation", "")
    stat = args.get("stat", "")
    value = args.get("value", "")

    if op == "add":
        if not value:
            return "**Error:** `value` (note text) is required for add."
        text = CHAR_PATH.read_text()
        notes_section = re.search(r"^## Notes & Scribbles\n", text, re.MULTILINE)
        if notes_section:
            text = text[:notes_section.end()] + f"- {value}\n" + text[notes_section.end():]
        else:
            text += f"\n## Notes & Scribbles\n- {value}\n"
        CHAR_PATH.write_text(text)
        return f"Note added: {value}"

    elif op == "remove":
        if not stat:
            return "**Error:** `stat` (exact note text) is required for remove."
        err = patch_character(rf"^- {re.escape(stat)}\n?", "", count=1, flags=re.MULTILINE)
        if err:
            return f"**Error:** note not found: {stat}"
        return f"Note removed."

    elif op == "replace":
        if not stat or not value:
            return "**Error:** `stat` (old text) and `value` (new text) are required for replace."
        err = patch_character(rf"^- {re.escape(stat)}", f"- {value}", count=1, flags=re.MULTILINE)
        if err:
            return f"**Error:** note not found: {stat}"
        return f"Note replaced."

    return "**Error:** unknown operation. Use add, remove, or replace."


def tool_modify_world(args: dict) -> str:
    op = args.get("operation", "set")
    value = args.get("value", "")

    if op != "set":
        return f"**Error:** only 'set' operation is supported for world, got '{op}'."
    if not value:
        return "**Error:** `value` (full world markdown) is required."
    if not validate_update_size("world", value, WORLD_PATH):
        return "**Error:** Update rejected — content is too short (likely a partial paste)."
    WORLD_PATH.write_text(value.strip() + "\n")
    return "World state updated."


def tool_modify_journal(args: dict) -> str:
    op = args.get("operation", "")
    value = args.get("value", "")

    if not value:
        return "**Error:** `value` (entry text) is required."

    if op == "add":
        update_journal(add=[value])
        return f"Journal entry added: {value}"
    elif op == "done":
        update_journal(done=[value])
        return f"Journal entry done: {value}"
    elif op == "remove":
        update_journal(done=[value])
        return f"Journal entry removed: {value}"
    else:
        return f"**Error:** unknown operation '{op}'. Use add, done, or remove."


def tool_finalize_turn(args: dict) -> str:
    """Validate ambience and write to AMBIENCE_PATH."""
    raw = (args or {}).get("ambience", "").strip().lower()
    if not raw:
        AMBIENCE_PATH.write_text("silence\n")
        return "Ambience set to silence."
    valid = get_valid_ambiences()
    if raw not in valid:
        append_llm_log(f"\n[WARN] invalid ambience '{raw}', allowed: {sorted(valid)}")
        return f"**Error:** invalid ambience '{raw}'. Allowed: {', '.join(sorted(valid))}."
    AMBIENCE_PATH.write_text(raw + "\n")
    return f"Ambience set to {raw}."


RULES_CATEGORIES = {
    "mechanics": get_mechanics_path(),
    "core": get_core_rules_path(),
    "character_creation": get_character_creation_path(),
    "end_game": get_end_game_path(),
}

def tool_read_rules(args: dict) -> str:
    """Read a rules file by category and return its content."""
    category = (args or {}).get("category", "mechanics")
    path = RULES_CATEGORIES.get(category)
    if not path:
        allowed = ", ".join(RULES_CATEGORIES)
        return f"**Error:** unknown category '{category}'. Allowed: {allowed}."
    content = read_file(path)
    if not content:
        return f"**Error:** {path.name} not found."
    return content


def execute_tool(tool_name: str, args: dict) -> str:
    """Execute a tool by name. Returns result string."""
    fn_map = {
        "modify_traits": tool_modify_traits,
        "modify_vitals": tool_modify_vitals,
        "modify_cash": tool_modify_cash,
        "modify_inventory": tool_modify_inventory,
        "modify_note": tool_modify_note,
        "modify_world": tool_modify_world,
        "modify_journal": tool_modify_journal,
        "finalize_turn": tool_finalize_turn,
        "read_rules": tool_read_rules,
    }
    fn = fn_map.get(tool_name)
    if not fn:
        return f"Unknown tool: {tool_name}"
    try:
        return fn(args)
    except Exception as e:
        import traceback
        tb = traceback.format_exc()
        append_llm_log(f"\n--- TOOL ERROR ({tool_name}) ---\n{tb}")
        return f"Tool error ({tool_name}): {e}"


def describe_change(tool_name: str, args: dict) -> str:
    """Build a compact human-readable change description from a tool call."""
    if tool_name == "modify_vitals":
        return f"⚡ {args.get('stat', '?')}: {args.get('operation', '?')} {args.get('value', '?')}"
    elif tool_name == "modify_cash":
        op = args.get("operation", "?")
        val = args.get("value", "?")
        return f"💰 Cash {op} {val}"
    elif tool_name == "modify_traits":
        parts = []
        for k, v in args.items():
            parts.append(f"{k.upper()}: {v}")
        return f"⚡ {', '.join(parts)}"
    elif tool_name == "modify_inventory":
        op = args.get("operation", "?")
        item = args.get("item", "?")
        val = args.get("value", "")
        return f"{'↻' if op == 'replace' else '+' if op == 'add' else '−'} {item}{f' ({val})' if val else ''}"
    elif tool_name == "modify_note":
        op = args.get("operation", "?")
        val = (args.get("value") or args.get("stat") or "?")[:60]
        return f"📝 {op}: {val}{'…' if len(val) >= 60 else ''}"
    elif tool_name == "modify_world":
        return "🌍 World updated"
    elif tool_name == "modify_journal":
        op = args.get("operation", "?")
        val = args.get("value", "?")
        return f"{'✅' if op == 'done' else '📋'} {val}"
    elif tool_name == "finalize_turn":
        a = args.get("ambience", "")
        return f"♫ {a}" if a else ""
    return ""


def extract_tool_calls(text: str) -> list[dict]:
    """Extract tool calls from ```tool blocks in LLM response."""
    calls = []
    seen = set()

    for m in re.finditer(r"```tool\s*\n?", text):
        try:
            decoder = json.JSONDecoder()
            obj, end = decoder.raw_decode(text, m.end())
        except (json.JSONDecodeError, ValueError, StopIteration):
            close = text.find("```", m.end())
            if close > 0:
                raw = text[m.end():close].strip()
                raw = re.sub(r'"(?:[^"\\]|\\.)*"', lambda x: x.group(0).replace("\n", "\\n"), raw, flags=re.DOTALL)
                try:
                    obj = json.loads(raw)
                except json.JSONDecodeError:
                    continue
            else:
                continue

        if not isinstance(obj, dict) or "tool" not in obj:
            continue

        key = (obj["tool"], json.dumps(obj.get("args", {}), sort_keys=True))
        if key not in seen:
            seen.add(key)
            calls.append(obj)

    return calls
