#!/usr/bin/env python3
"""
Timmy's Local Brain — Morrowind gameplay loop on Ollama.

Reads perception from OpenMW log, decides actions via local model,
executes via CGEvent. Zero cloud. Sovereign.

Usage:
    python3 ~/.timmy/morrowind/local_brain.py
    python3 ~/.timmy/morrowind/local_brain.py --model hermes4:14b
    python3 ~/.timmy/morrowind/local_brain.py --cycles 50
"""

import argparse
import json
import os
import re
import subprocess
import sys
import time

import requests

# ═══════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════

OLLAMA_URL = "http://localhost:11434/api/chat"
OPENMW_LOG = os.path.expanduser("~/Library/Preferences/openmw/openmw.log")
SESSION_LOG = os.path.expanduser(f"~/.timmy/morrowind/sessions/session_{time.strftime('%Y%m%d_%H%M')}.jsonl")
LOOP_INTERVAL = 4  # seconds between cycles

SYSTEM_PROMPT = """You are Timmy, playing Morrowind. You see the world through perception data and act through simple commands.

AVAILABLE ACTIONS (respond with exactly ONE json object):
{"action": "move", "direction": "forward", "duration": 2.0, "run": false}
{"action": "move", "direction": "turn_left", "duration": 0.5}
{"action": "move", "direction": "turn_right", "duration": 0.5}
{"action": "activate"}   — interact with what's in front of you (doors, NPCs, items)
{"action": "jump"}
{"action": "attack"}
{"action": "wait"}        — do nothing this cycle, observe
{"action": "quicksave"}

RULES:
- Respond with ONLY a JSON object. No explanation, no markdown.
- Explore the world. Talk to NPCs. Enter buildings. Pick up items.
- If an NPC is nearby (<200 dist), approach and activate to talk.
- If a door is nearby (<300 dist), approach and activate to enter.
- If you're stuck (same position 3+ cycles), try turning and moving differently.
- You are a new prisoner just arrived in Seyda Neen. Explore and find adventure.
"""

# ═══════════════════════════════════════
# PERCEPTION
# ═══════════════════════════════════════

def parse_latest_perception():
    """Parse the most recent perception block from the OpenMW log."""
    try:
        with open(OPENMW_LOG, "r") as f:
            content = f.read()
    except FileNotFoundError:
        return None

    blocks = re.findall(
        r"=== TIMMY PERCEPTION ===(.*?)=== END PERCEPTION ===",
        content, re.DOTALL
    )
    if not blocks:
        return None

    block = blocks[-1]
    state = {"npcs": [], "doors": [], "items": []}

    for line in block.strip().split("\n"):
        line = line.strip()
        if "]:\t" in line:
            line = line.split("]:\t", 1)[1]

        if line.startswith("Cell:"):
            state["cell"] = line.split(":", 1)[1].strip()
        elif line.startswith("Pos:"):
            state["position"] = line.split(":", 1)[1].strip()
        elif line.startswith("Yaw:"):
            state["yaw"] = line.split(":", 1)[1].strip()
        elif line.startswith("HP:"):
            state["health"] = line.split(":", 1)[1].strip()
        elif line.startswith("MP:"):
            state["magicka"] = line.split(":", 1)[1].strip()
        elif line.startswith("FT:"):
            state["fatigue"] = line.split(":", 1)[1].strip()
        elif line.startswith("Mode:"):
            state["mode"] = line.split(":", 1)[1].strip()
        elif line.startswith("NPC:"):
            state["npcs"].append(line[4:].strip())
        elif line.startswith("Door:"):
            state["doors"].append(line[5:].strip())
        elif line.startswith("Item:"):
            state["items"].append(line[5:].strip())

    return state


def format_perception(state):
    """Format perception state for the model prompt."""
    if not state:
        return "No perception data available."

    lines = []
    lines.append(f"Location: {state.get('cell', '?')}")
    lines.append(f"Position: {state.get('position', '?')}")
    lines.append(f"Facing: yaw {state.get('yaw', '?')}")
    lines.append(f"Health: {state.get('health', '?')}  Magicka: {state.get('magicka', '?')}  Fatigue: {state.get('fatigue', '?')}")

    if state["npcs"]:
        lines.append("Nearby NPCs: " + "; ".join(state["npcs"]))
    if state["doors"]:
        lines.append("Nearby Doors: " + "; ".join(state["doors"]))
    if state["items"]:
        lines.append("Nearby Items: " + "; ".join(state["items"]))

    if not state["npcs"] and not state["doors"] and not state["items"]:
        lines.append("Nothing notable nearby.")

    return "\n".join(lines)

# ═══════════════════════════════════════
# OLLAMA
# ═══════════════════════════════════════

def ask_ollama(model, messages):
    """Send messages to Ollama and get a response."""
    payload = {
        "model": model,
        "messages": messages,
        "stream": False,
        "options": {
            "temperature": 0.7,
            "num_predict": 100,  # actions are short
        },
    }

    try:
        resp = requests.post(OLLAMA_URL, json=payload, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        return data["message"]["content"].strip()
    except Exception as e:
        print(f"  [Ollama error] {e}")
        return '{"action": "wait"}'


def parse_action(response):
    """Extract a JSON action from the model response."""
    # Try to find JSON in the response
    match = re.search(r'\{[^}]+\}', response)
    if match:
        try:
            return json.loads(match.group())
        except json.JSONDecodeError:
            pass

    # Fallback
    return {"action": "wait"}

# ═══════════════════════════════════════
# ACTIONS — CGEvent
# ═══════════════════════════════════════

KEYCODES = {
    "w": 13, "a": 0, "s": 1, "d": 2,
    "space": 49, "escape": 53, "return": 36,
    "e": 14, "f": 3, "q": 12, "j": 38, "t": 20,
    "f5": 96, "f9": 101,
    "left": 123, "right": 124, "up": 126, "down": 125,
}

def send_key(keycode, duration=0.0, shift=False):
    """Send a keypress to the game via CGEvent."""
    import Quartz

    flags = Quartz.kCGEventFlagMaskShift if shift else 0

    down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
    Quartz.CGEventSetFlags(down, flags)
    Quartz.CGEventPost(Quartz.kCGHIDEventTap, down)

    if duration > 0:
        time.sleep(duration)

    up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
    Quartz.CGEventSetFlags(up, 0)
    Quartz.CGEventPost(Quartz.kCGHIDEventTap, up)


def execute_action(action_dict):
    """Execute a parsed action."""
    action = action_dict.get("action", "wait")

    # Normalize shorthand actions like {"action": "turn_right"} -> move
    if action in ("forward", "backward", "left", "right", "turn_left", "turn_right"):
        action_dict["direction"] = action
        action_dict["action"] = "move"
        action = "move"

    if action == "move":
        direction = action_dict.get("direction", "forward")
        duration = min(action_dict.get("duration", 1.0), 5.0)  # cap at 5s
        run = action_dict.get("run", False)

        key_map = {
            "forward": "w", "backward": "s",
            "left": "a", "right": "d",
            "turn_left": "left", "turn_right": "right",
        }
        key = key_map.get(direction, "w")
        send_key(KEYCODES[key], duration=duration, shift=run)
        return f"move {direction} {duration}s" + (" (run)" if run else "")

    elif action == "activate":
        send_key(KEYCODES["space"], duration=0.1)
        return "activate"

    elif action == "jump":
        send_key(KEYCODES["space"], duration=0.05)
        return "jump"

    elif action == "attack":
        send_key(KEYCODES["f"], duration=0.3)
        return "attack"

    elif action == "quicksave":
        send_key(KEYCODES["f5"], duration=0.1)
        return "quicksave"

    elif action == "wait":
        return "wait (observing)"

    return f"unknown: {action}"

# ═══════════════════════════════════════
# SESSION LOG
# ═══════════════════════════════════════

def log_cycle(cycle, perception, model_response, action_desc, latency):
    """Append a cycle to the session log (JSONL for training data)."""
    entry = {
        "cycle": cycle,
        "timestamp": time.time(),
        "perception": perception,
        "model_response": model_response,
        "action": action_desc,
        "latency_ms": int(latency * 1000),
    }
    with open(SESSION_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")

# ═══════════════════════════════════════
# MAIN LOOP
# ═══════════════════════════════════════

def main():
    parser = argparse.ArgumentParser(description="Timmy's Local Morrowind Brain")
    parser.add_argument("--model", default="hermes3:8b", help="Ollama model (default: hermes3:8b)")
    parser.add_argument("--cycles", type=int, default=30, help="Number of gameplay cycles (default: 30)")
    parser.add_argument("--interval", type=float, default=LOOP_INTERVAL, help="Seconds between cycles")
    args = parser.parse_args()

    os.makedirs(os.path.dirname(SESSION_LOG), exist_ok=True)

    print(f"=== Timmy's Morrowind Brain ===")
    print(f"Model:    {args.model}")
    print(f"Cycles:   {args.cycles}")
    print(f"Interval: {args.interval}s")
    print(f"Log:      {SESSION_LOG}")
    print()

    # Keep recent history for context
    history = []
    last_positions = []

    for cycle in range(1, args.cycles + 1):
        print(f"--- Cycle {cycle}/{args.cycles} ---")

        # Perceive
        state = parse_latest_perception()
        if not state:
            print("  No perception data. Waiting...")
            time.sleep(args.interval)
            continue

        perception_text = format_perception(state)
        print(f"  {state.get('cell', '?')} | {state.get('position', '?')} | HP:{state.get('health', '?')}")

        # Track stuck detection
        pos = state.get("position", "")
        last_positions.append(pos)
        if len(last_positions) > 5:
            last_positions.pop(0)

        stuck = len(last_positions) >= 3 and len(set(last_positions[-3:])) == 1
        if stuck:
            perception_text += "\nWARNING: You haven't moved in 3 cycles. Try turning or a different direction."

        # Build messages
        messages = [{"role": "system", "content": SYSTEM_PROMPT}]

        # Add recent history (last 3 exchanges)
        for h in history[-3:]:
            messages.append({"role": "user", "content": h["perception"]})
            messages.append({"role": "assistant", "content": h["response"]})

        messages.append({"role": "user", "content": perception_text})

        # Think (local Ollama)
        t0 = time.time()
        response = ask_ollama(args.model, messages)
        latency = time.time() - t0

        # Parse and execute
        action_dict = parse_action(response)
        action_desc = execute_action(action_dict)

        print(f"  Action: {action_desc} ({int(latency*1000)}ms)")

        # Log
        history.append({"perception": perception_text, "response": response})
        log_cycle(cycle, state, response, action_desc, latency)

        # Wait for next cycle
        time.sleep(args.interval)

    print(f"\n=== Done. {args.cycles} cycles. Log: {SESSION_LOG} ===")


if __name__ == "__main__":
    main()
