#!/usr/bin/env python3
"""
Mortdecai Chat App — interactive dev console for testing gateway and model calls.

Features:
  - Mode switch: Gateway call (full pipeline) vs Model call (direct Ollama)
  - Template selector powered by training/prompts/ JSONL files
  - Editable JSON payload with live trace log (SSE streaming)
  - Training data export — save interactions for audit/training
  - Branding: Rajdhani Bold, Sethian orange (#D35400), dark theme

Usage:
    python3 web/chat_app.py --port 8097

Deploy on CT 650 @ pve112, serve as chat.api.mortdec.ai behind Caddy + Google OAuth.
"""

import argparse
import json
import os
import sys
import time
import threading
import traceback
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from datetime import datetime

PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT))

# Lazy imports — agent modules may not be on every machine
def _get_assistant(config):
    from agent.serve import MinecraftAssistant
    return MinecraftAssistant(config)

def _get_prompts_module():
    from training.prompts.loader import load_manifest, load_prompts, load_prompt_entries, expand_template, TEMPLATE_VARS
    return load_manifest, load_prompts, load_prompt_entries, expand_template, TEMPLATE_VARS

PORT = 8097
LOG_DIR = PROJECT_ROOT / "data" / "chat_logs"
TRAINING_LOG = LOG_DIR / "training_export.jsonl"

# Default call configs
DEFAULT_GATEWAY_CONFIG = {
    "mode": "sudo",
    "player": "slingshooter08",
    "query": "",
    "ollama_url": "http://192.168.0.141:11434",
    "model": "mortdecai:0.5.0-f16",
    "rcon_host": "192.168.0.244",
    "rcon_port": 25578,
    "rcon_password": "REDACTED_RCON",
    "temperature": 0.7,
    "max_tokens": 800,
}

DEFAULT_MODEL_CONFIG = {
    "model": "mortdecai:0.5.0-f16",
    "ollama_url": "http://192.168.0.141:11434",
    "messages": [
        {"role": "system", "content": "You are a Minecraft 1.21 command translator for a Paper server.\nPlugins: FastAsyncWorldEdit, WorldGuard, CoreProtect, EssentialsX, Vault, LuckPerms.\nReturn JSON: {\"commands\": [...], \"reasoning\": \"...\", \"message\": \"...\"}\nUse /no_think mode."},
        {"role": "user", "content": "Player slingshooter08: sudo give me diamond armor"}
    ],
    "temperature": 0.7,
    "max_tokens": 800,
    "format": "json",
}


# ── HTML / CSS / JS ──────────────────────────────────────────────────────────

HTML_PAGE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mortdecai Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
  --orange: #D35400;
  --orange-dim: #a34200;
  --orange-glow: rgba(211,84,0,0.25);
  --bg-0: #0d0d0d;
  --bg-1: #141414;
  --bg-2: #1c1c1c;
  --bg-3: #252525;
  --border: #2a2a2a;
  --border-hi: #3a3a3a;
  --text: #d4d4d4;
  --text-dim: #777;
  --text-bright: #f0f0f0;
  --green: #27ae60;
  --green-dim: rgba(39,174,96,0.15);
  --red: #c0392b;
  --red-dim: rgba(192,57,43,0.15);
  --blue: #2980b9;
  --blue-dim: rgba(41,128,185,0.15);
  --yellow: #f39c12;
  --yellow-dim: rgba(243,156,18,0.15);
  --purple: #8e44ad;
  --purple-dim: rgba(142,68,173,0.15);
  --cyan: #00bcd4;
}

* { margin:0; padding:0; box-sizing:border-box; }

body {
  font-family: 'Rajdhani', sans-serif;
  background: var(--bg-0);
  color: var(--text);
  height: 100vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* ── Noise overlay ── */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Crect width='1' height='1' fill='%23fff' opacity='0.02'/%3E%3C/svg%3E");
  pointer-events: none;
  z-index: 9999;
}

/* ── Top Bar ── */
.topbar {
  background: var(--bg-1);
  border-bottom: 2px solid var(--orange);
  padding: 0 20px;
  height: 56px;
  display: flex;
  align-items: center;
  gap: 16px;
  flex-shrink: 0;
  position: relative;
}

.topbar::after {
  content: '';
  position: absolute;
  bottom: -6px;
  left: 0; right: 0;
  height: 4px;
  background: linear-gradient(180deg, var(--orange-glow), transparent);
}

.logo {
  font-weight: 700;
  font-size: 22px;
  color: var(--orange);
  letter-spacing: 2px;
  text-transform: uppercase;
  margin-right: 8px;
  white-space: nowrap;
}

.logo span {
  color: var(--text-dim);
  font-weight: 400;
  font-size: 14px;
  letter-spacing: 1px;
  margin-left: 6px;
}

.sep {
  width: 1px;
  height: 28px;
  background: var(--border-hi);
  flex-shrink: 0;
}

.ctrl-group {
  display: flex;
  align-items: center;
  gap: 8px;
}

.ctrl-label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--text-dim);
}

select, .btn {
  font-family: 'Rajdhani', sans-serif;
  font-weight: 600;
  font-size: 14px;
  background: var(--bg-3);
  color: var(--text-bright);
  border: 1px solid var(--border-hi);
  padding: 6px 12px;
  cursor: pointer;
  transition: all 0.15s;
}

select:hover, .btn:hover {
  border-color: var(--orange);
  background: var(--bg-2);
}

select:focus, .btn:focus {
  outline: none;
  border-color: var(--orange);
  box-shadow: 0 0 0 2px var(--orange-glow);
}

select { padding-right: 28px; }

.btn-send {
  background: var(--orange);
  color: #fff;
  border-color: var(--orange);
  padding: 6px 20px;
  letter-spacing: 1px;
  text-transform: uppercase;
  font-size: 13px;
}

.btn-send:hover {
  background: var(--orange-dim);
}

.btn-send:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.btn-save {
  background: var(--bg-3);
  border-color: var(--green);
  color: var(--green);
  font-size: 12px;
  padding: 5px 12px;
}

.btn-save:hover { background: var(--green-dim); }

.btn-clear {
  background: var(--bg-3);
  border-color: var(--border-hi);
  color: var(--text-dim);
  font-size: 12px;
  padding: 5px 12px;
}

.topbar-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
}

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--green);
  display: inline-block;
}

.status-dot.busy {
  background: var(--orange);
  animation: pulse 1s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

/* ── Main Split ── */
.main {
  flex: 1;
  display: flex;
  overflow: hidden;
}

/* ── Left Pane: Payload Editor ── */
.pane-left {
  width: 45%;
  min-width: 380px;
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--border);
}

.pane-header {
  background: var(--bg-2);
  border-bottom: 1px solid var(--border);
  padding: 8px 16px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
}

.pane-title {
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--text-dim);
}

.pane-body {
  flex: 1;
  overflow: hidden;
  position: relative;
}

#payload-editor {
  width: 100%;
  height: 100%;
  background: var(--bg-1);
  color: var(--cyan);
  border: none;
  padding: 16px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 13px;
  line-height: 1.6;
  resize: none;
  outline: none;
  tab-size: 2;
}

#payload-editor::selection {
  background: var(--orange-glow);
}

/* ── Right Pane: Trace Log ── */
.pane-right {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

#trace-log {
  flex: 1;
  overflow-y: auto;
  padding: 12px 16px;
  background: var(--bg-0);
  font-family: 'JetBrains Mono', monospace;
  font-size: 12px;
  line-height: 1.7;
}

#trace-log::-webkit-scrollbar {
  width: 6px;
}
#trace-log::-webkit-scrollbar-track {
  background: var(--bg-1);
}
#trace-log::-webkit-scrollbar-thumb {
  background: var(--border-hi);
  border-radius: 3px;
}

.trace-entry {
  margin-bottom: 2px;
  padding: 3px 8px;
  border-left: 3px solid transparent;
  animation: fadeIn 0.2s ease-out;
  cursor: pointer;
}

.trace-entry:hover {
  filter: brightness(1.15);
}

.trace-expand {
  display: none;
  margin-top: 4px;
  padding: 8px 10px;
  background: var(--bg-1);
  border: 1px solid var(--border);
  border-radius: 2px;
  font-size: 11px;
  line-height: 1.5;
  color: var(--cyan);
  white-space: pre-wrap;
  word-break: break-all;
  max-height: 400px;
  overflow-y: auto;
}

.trace-entry.expanded .trace-expand {
  display: block;
  animation: fadeIn 0.15s ease-out;
}

.trace-chevron {
  display: inline-block;
  width: 12px;
  font-size: 9px;
  color: var(--text-dim);
  transition: transform 0.15s;
}

.trace-entry.expanded .trace-chevron {
  transform: rotate(90deg);
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateX(-4px); }
  to { opacity: 1; transform: translateX(0); }
}

.trace-entry.type-system  { border-color: var(--text-dim); color: var(--text-dim); }
.trace-entry.type-context { border-color: var(--blue); background: var(--blue-dim); }
.trace-entry.type-llm     { border-color: var(--orange); background: var(--orange-glow); }
.trace-entry.type-guard   { border-color: var(--yellow); background: var(--yellow-dim); }
.trace-entry.type-rcon    { border-color: var(--green); background: var(--green-dim); }
.trace-entry.type-rcon-fail { border-color: var(--red); background: var(--red-dim); }
.trace-entry.type-error   { border-color: var(--red); background: var(--red-dim); color: var(--red); }
.trace-entry.type-result  { border-color: var(--purple); background: var(--purple-dim); }
.trace-entry.type-saved   { border-color: var(--green); background: var(--green-dim); color: var(--green); }

.trace-ts {
  color: var(--text-dim);
  font-size: 11px;
  margin-right: 8px;
}

.trace-tag {
  display: inline-block;
  padding: 0 5px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  margin-right: 6px;
  border-radius: 2px;
}

.trace-tag.ctx   { background: var(--blue); color: #fff; }
.trace-tag.llm   { background: var(--orange); color: #fff; }
.trace-tag.guard { background: var(--yellow); color: #000; }
.trace-tag.rcon  { background: var(--green); color: #fff; }
.trace-tag.err   { background: var(--red); color: #fff; }
.trace-tag.res   { background: var(--purple); color: #fff; }

/* ── Bottom Bar ── */
.bottombar {
  background: var(--bg-1);
  border-top: 1px solid var(--border);
  padding: 6px 20px;
  font-size: 11px;
  color: var(--text-dim);
  display: flex;
  align-items: center;
  gap: 20px;
  flex-shrink: 0;
}

.bottombar .stat { font-family: 'JetBrains Mono', monospace; }
.bottombar .stat b { color: var(--text); }

/* ── Template Picker Overlay ── */
.template-list {
  max-height: 300px;
  overflow-y: auto;
  background: var(--bg-2);
  border: 1px solid var(--border-hi);
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 100;
  display: none;
}

.template-item {
  padding: 6px 12px;
  font-family: 'JetBrains Mono', monospace;
  font-size: 12px;
  cursor: pointer;
  border-bottom: 1px solid var(--border);
  transition: background 0.1s;
}

.template-item:hover {
  background: var(--orange-glow);
  color: var(--text-bright);
}
</style>
</head>
<body>

<!-- Top Bar -->
<div class="topbar">
  <div class="logo">MORTDECAI<span>CHAT</span></div>
  <div class="sep"></div>

  <div class="ctrl-group">
    <span class="ctrl-label">Call</span>
    <select id="call-type">
      <option value="gateway">Gateway</option>
      <option value="model">Model</option>
    </select>
  </div>

  <div class="ctrl-group">
    <span class="ctrl-label">Mode</span>
    <select id="mode-select">
      <option value="sudo">sudo</option>
      <option value="god">god</option>
      <option value="god_system">god_system</option>
    </select>
  </div>

  <div class="ctrl-group" style="position:relative">
    <span class="ctrl-label">Template</span>
    <select id="category-select">
      <option value="">— select category —</option>
    </select>
    <select id="prompt-select" style="max-width:280px">
      <option value="">— select prompt —</option>
    </select>
  </div>

  <div class="topbar-right">
    <button class="btn btn-clear" onclick="clearLog()">Clear Log</button>
    <button class="btn btn-save" id="btn-save" onclick="saveTraining()" disabled>Save to Training</button>
    <button class="btn btn-send" id="btn-send" onclick="sendCall()">Send</button>
    <span class="status-dot" id="status-dot"></span>
  </div>
</div>

<!-- Main Split -->
<div class="main">
  <div class="pane-left">
    <div class="pane-header">
      <span class="pane-title" id="pane-left-title">Gateway Call Payload</span>
      <button class="btn" style="font-size:11px;padding:3px 8px" onclick="resetPayload()">Reset</button>
    </div>
    <div class="pane-body">
      <textarea id="payload-editor" spellcheck="false"></textarea>
    </div>
  </div>
  <div class="pane-right">
    <div class="pane-header">
      <span class="pane-title">Trace Log</span>
      <span class="pane-title" id="trace-count" style="color:var(--text-dim)">0 entries</span>
    </div>
    <div id="trace-log"></div>
  </div>
</div>

<!-- Bottom Bar -->
<div class="bottombar">
  <span class="stat">Model: <b id="stat-model">—</b></span>
  <span class="stat">Last: <b id="stat-duration">—</b></span>
  <span class="stat">Cmds: <b id="stat-cmds">—</b></span>
  <span class="stat">Session: <b id="stat-session">0</b> calls</span>
  <span style="margin-left:auto;font-family:'Rajdhani',sans-serif;font-size:12px">
    chat.api.mortdec.ai
  </span>
</div>

<script>
const $ = id => document.getElementById(id);
let currentResult = null;
let sessionCalls = 0;
let traceCount = 0;
let manifest = {};

// ── Init ──
async function init() {
  try {
    const r = await fetch('/api/manifest');
    manifest = await r.json();
    populateCategories();
  } catch(e) {
    log('system', 'Failed to load manifest: ' + e.message);
  }
  resetPayload();
  log('system', 'Mortdecai Chat ready. Select a mode and template, or edit the payload directly.');
}

function populateCategories() {
  const sel = $('category-select');
  const mode = $('mode-select').value;
  const callType = $('call-type').value;

  // Clear existing options
  sel.innerHTML = '<option value="">-- select category --</option>';

  for (const [cat, meta] of Object.entries(manifest)) {
    // Filter by mode
    const catMode = meta.mode || 'sudo';
    if (catMode !== 'mixed' && catMode !== mode) continue;
    // Filter by call type
    const catCall = meta.call_type || 'model';
    if (callType === 'model' && catCall === 'gateway') continue;

    const opt = document.createElement('option');
    opt.value = cat;
    opt.textContent = cat.replace(/_/g, ' ') + ' (' + meta.count + ')';
    sel.appendChild(opt);
  }
}

async function loadPrompts(category) {
  const sel = $('prompt-select');
  sel.innerHTML = '<option value="">-- select prompt --</option>';
  if (!category) return;

  try {
    const r = await fetch('/api/prompts/' + category);
    const prompts = await r.json();
    prompts.forEach((p, i) => {
      const opt = document.createElement('option');
      opt.value = p.prompt;
      opt.textContent = p.prompt.length > 60 ? p.prompt.slice(0, 60) + '...' : p.prompt;
      sel.appendChild(opt);
    });
  } catch(e) {
    log('error', 'Failed to load prompts: ' + e.message);
  }
}

function applyPromptToPayload(promptText) {
  if (!promptText) return;
  try {
    const payload = JSON.parse($('payload-editor').value);
    const callType = $('call-type').value;
    if (callType === 'gateway') {
      payload.query = promptText;
    } else {
      // Model mode: update the user message
      const userMsg = payload.messages.find(m => m.role === 'user');
      if (userMsg) {
        userMsg.content = 'Player ' + (payload.player || 'slingshooter08') + ': ' + promptText;
      }
    }
    $('payload-editor').value = JSON.stringify(payload, null, 2);
  } catch(e) {
    log('error', 'Payload parse error: ' + e.message);
  }
}

function resetPayload() {
  const callType = $('call-type').value;
  const mode = $('mode-select').value;
  let payload;

  if (callType === 'gateway') {
    payload = Object.assign({}, DEFAULTS.gateway, { mode: mode });
  } else {
    payload = JSON.parse(JSON.stringify(DEFAULTS.model));
  }
  $('payload-editor').value = JSON.stringify(payload, null, 2);
  $('pane-left-title').textContent = callType === 'gateway' ? 'Gateway Call Payload' : 'Model Call Payload';
}

// ── Logging ──
function log(type, text, rawData) {
  const el = $('trace-log');
  const ts = new Date().toLocaleTimeString('en-US', {hour12:false});
  const entry = document.createElement('div');
  entry.className = 'trace-entry type-' + type;

  const tagMap = {
    system:'SYS', context:'CTX', llm:'LLM', guard:'GRD',
    rcon:'RCON', 'rcon-fail':'RCON', error:'ERR', result:'RES', saved:'SAVE'
  };
  const tagClass = {
    system:'', context:'ctx', llm:'llm', guard:'guard',
    rcon:'rcon', 'rcon-fail':'err', error:'err', result:'res', saved:'rcon'
  };

  const tag = tagMap[type] || type.toUpperCase();
  const cls = tagClass[type] || '';

  // Use MC color rendering for RCON results
  const renderText = (type === 'rcon' || type === 'rcon-fail') ? mcColorize(text) : escHtml(text);

  // Build expand panel content
  const expandContent = rawData ? JSON.stringify(rawData, null, 2) : text;

  entry.innerHTML =
    '<span class="trace-chevron">&#9654;</span>' +
    '<span class="trace-ts">' + ts + '</span>' +
    (cls ? '<span class="trace-tag ' + cls + '">' + tag + '</span>' : '') +
    '<span>' + renderText + '</span>' +
    '<div class="trace-expand">' + escHtml(expandContent) + '</div>';

  entry.addEventListener('click', () => entry.classList.toggle('expanded'));

  el.appendChild(entry);
  el.scrollTop = el.scrollHeight;
  traceCount++;
  $('trace-count').textContent = traceCount + ' entries';
}

function escHtml(s) {
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}

// Minecraft § color code rendering
const MC_COLORS = {
  '0':'#000','1':'#0000AA','2':'#00AA00','3':'#00AAAA','4':'#AA0000',
  '5':'#AA00AA','6':'#FFAA00','7':'#AAAAAA','8':'#555555','9':'#5555FF',
  'a':'#55FF55','b':'#55FFFF','c':'#FF5555','d':'#FF55FF','e':'#FFFF55','f':'#FFFFFF',
  'r':'', // reset
};
const MC_FORMAT = {'l':'font-weight:bold','o':'font-style:italic','n':'text-decoration:underline','m':'text-decoration:line-through'};

function mcColorize(s) {
  if (!s.includes('\u00a7') && !s.includes('§')) return escHtml(s);
  let out = '', color = '', fmt = '';
  const raw = s.replace(/§/g, '\u00a7');
  let i = 0;
  while (i < raw.length) {
    if (raw[i] === '\u00a7' && i + 1 < raw.length) {
      const code = raw[i+1].toLowerCase();
      if (code === 'r') { color = ''; fmt = ''; }
      else if (MC_COLORS[code] !== undefined) { color = MC_COLORS[code]; }
      else if (MC_FORMAT[code]) { fmt += MC_FORMAT[code] + ';'; }
      i += 2;
    } else {
      const style = (color ? 'color:'+color+';' : '') + fmt;
      if (style) out += '<span style="'+style+'">' + escHtml(raw[i]) + '</span>';
      else out += escHtml(raw[i]);
      i++;
    }
  }
  return out;
}

function clearLog() {
  $('trace-log').innerHTML = '';
  traceCount = 0;
  $('trace-count').textContent = '0 entries';
}

// ── Send Call ──
async function sendCall() {
  const btn = $('btn-send');
  const dot = $('status-dot');
  btn.disabled = true;
  dot.classList.add('busy');
  currentResult = null;
  $('btn-save').disabled = true;

  let payload;
  try {
    payload = JSON.parse($('payload-editor').value);
  } catch(e) {
    log('error', 'Invalid JSON: ' + e.message);
    btn.disabled = false;
    dot.classList.remove('busy');
    return;
  }

  const callType = $('call-type').value;
  log('system', 'Sending ' + callType + ' call...');

  const startTime = performance.now();

  try {
    const r = await fetch('/api/send', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({ call_type: callType, payload: payload }),
    });

    // Stream SSE response
    const reader = r.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });

      // Process complete SSE events
      const events = buffer.split('\n\n');
      buffer = events.pop(); // Keep incomplete event in buffer

      for (const event of events) {
        if (!event.trim()) continue;
        for (const line of event.split('\n')) {
          if (line.startsWith('data: ')) {
            try {
              const data = JSON.parse(line.slice(6));
              handleTraceEvent(data);
            } catch(e) { /* skip malformed */ }
          }
        }
      }
    }

    const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
    $('stat-duration').textContent = elapsed + 's';
    sessionCalls++;
    $('stat-session').textContent = sessionCalls;

  } catch(e) {
    log('error', 'Request failed: ' + e.message);
  }

  btn.disabled = false;
  dot.classList.remove('busy');
}

function handleTraceEvent(data) {
  const type = data.type || 'system';
  const text = data.text || JSON.stringify(data);

  log(type, text, data);

  if (data.model) $('stat-model').textContent = data.model;
  if (data.cmd_count !== undefined) $('stat-cmds').textContent = data.cmd_count;

  if (type === 'result') {
    currentResult = data.full_result;
    $('btn-save').disabled = false;
  }
}

// ── Save to Training ──
async function saveTraining() {
  if (!currentResult) return;
  try {
    const r = await fetch('/api/save-training', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(currentResult),
    });
    const resp = await r.json();
    log('saved', 'Saved to training log: ' + (resp.file || 'training_export.jsonl'));
    $('btn-save').disabled = true;
  } catch(e) {
    log('error', 'Save failed: ' + e.message);
  }
}

// ── Event Listeners ──
$('call-type').addEventListener('change', () => { resetPayload(); populateCategories(); });
$('mode-select').addEventListener('change', () => {
  const mode = $('mode-select').value;
  try {
    const payload = JSON.parse($('payload-editor').value);
    if (payload.mode !== undefined) {
      payload.mode = mode;
      $('payload-editor').value = JSON.stringify(payload, null, 2);
    }
  } catch(e) {}
  populateCategories();
});
$('category-select').addEventListener('change', (e) => loadPrompts(e.target.value));
$('prompt-select').addEventListener('change', (e) => applyPromptToPayload(e.target.value));

// Ctrl+Enter to send
document.addEventListener('keydown', (e) => {
  if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
    e.preventDefault();
    sendCall();
  }
});

// Defaults injected by server
const DEFAULTS = __DEFAULTS_JSON__;

init();
</script>
</body>
</html>"""


# ── HTTP Handler ──────────────────────────────────────────────────────────────

class ChatHandler(BaseHTTPRequestHandler):
    """HTTP handler for the chat app."""

    def log_message(self, fmt, *args):
        """Suppress default request logging."""
        pass

    def _cors_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')

    def _json_response(self, data, status=200):
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self._cors_headers()
        self.end_headers()
        self.wfile.write(json.dumps(data).encode())

    def _html_response(self, html):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.end_headers()
        self.wfile.write(html.encode())

    def _sse_start(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/event-stream')
        self.send_header('Cache-Control', 'no-cache')
        self.send_header('Connection', 'keep-alive')
        self._cors_headers()
        self.end_headers()

    def _sse_event(self, data: dict):
        msg = f"data: {json.dumps(data)}\n\n"
        self.wfile.write(msg.encode())
        self.wfile.flush()

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors_headers()
        self.end_headers()

    def do_GET(self):
        path = urlparse(self.path).path

        if path in ('/', '/index.html'):
            defaults_json = json.dumps({
                'gateway': DEFAULT_GATEWAY_CONFIG,
                'model': DEFAULT_MODEL_CONFIG,
            })
            html = HTML_PAGE.replace('__DEFAULTS_JSON__', defaults_json)
            self._html_response(html)

        elif path == '/api/manifest':
            try:
                load_manifest, *_ = _get_prompts_module()
                self._json_response(load_manifest())
            except Exception as e:
                self._json_response({'error': str(e)}, 500)

        elif path.startswith('/api/prompts/'):
            category = path.split('/api/prompts/', 1)[1]
            try:
                *_, load_prompt_entries, _, _ = _get_prompts_module()
                entries = load_prompt_entries(category)
                self._json_response(entries)
            except Exception as e:
                self._json_response({'error': str(e)}, 500)

        elif path == '/api/template-vars':
            try:
                *_, TEMPLATE_VARS = _get_prompts_module()
                self._json_response(TEMPLATE_VARS)
            except Exception as e:
                self._json_response({'error': str(e)}, 500)

        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        path = urlparse(self.path).path
        content_len = int(self.headers.get('Content-Length', 0))
        body = json.loads(self.rfile.read(content_len)) if content_len else {}

        if path == '/api/send':
            self._handle_send(body)
        elif path == '/api/save-training':
            self._handle_save_training(body)
        else:
            self.send_response(404)
            self.end_headers()

    def _handle_send(self, body):
        """Handle a gateway or model call with SSE trace streaming."""
        call_type = body.get('call_type', 'gateway')
        payload = body.get('payload', {})

        self._sse_start()

        try:
            if call_type == 'gateway':
                self._run_gateway_call(payload)
            else:
                self._run_model_call(payload)
        except Exception as e:
            self._sse_event({'type': 'error', 'text': f'Exception: {e}'})
            traceback.print_exc()

    def _run_gateway_call(self, payload):
        """Execute a full gateway call with trace events."""
        import requests as req

        mode = payload.get('mode', 'sudo')
        player = payload.get('player', 'slingshooter08')
        query = payload.get('query', '')
        ollama_url = payload.get('ollama_url', DEFAULT_GATEWAY_CONFIG['ollama_url'])
        model = payload.get('model', DEFAULT_GATEWAY_CONFIG['model'])
        rcon_host = payload.get('rcon_host', DEFAULT_GATEWAY_CONFIG['rcon_host'])
        rcon_port = payload.get('rcon_port', DEFAULT_GATEWAY_CONFIG['rcon_port'])
        rcon_pass = payload.get('rcon_password', DEFAULT_GATEWAY_CONFIG['rcon_password'])
        temp = payload.get('temperature', 0.7)
        max_tokens = payload.get('max_tokens', 800)

        self._sse_event({'type': 'system', 'text': f'Gateway call: mode={mode} player={player} model={model}'})
        self._sse_event({'type': 'system', 'text': f'Query: {query}', 'model': model})

        start = time.time()

        # 1. Context gathering via RCON
        self._sse_event({'type': 'context', 'text': f'Gathering context from {rcon_host}:{rcon_port}...'})
        context_parts = []
        try:
            from agent.tools.persistent_rcon import get_rcon
            rcon = get_rcon(rcon_host, rcon_port, rcon_pass)

            # Player info
            player_list = rcon.command('list')
            context_parts.append(f'Online: {player_list}')
            self._sse_event({'type': 'context', 'text': f'Server: {player_list}'})

            if player:
                pos_result = rcon.command(f'data get entity {player} Pos')
                context_parts.append(f'Position: {pos_result}')
                self._sse_event({'type': 'context', 'text': f'Player pos: {pos_result[:100]}'})

        except Exception as e:
            self._sse_event({'type': 'context', 'text': f'RCON context failed: {e} (continuing without context)'})

        ctx_ms = int((time.time() - start) * 1000)
        self._sse_event({'type': 'context', 'text': f'Context gathered in {ctx_ms}ms'})

        # 2. Build system prompt
        try:
            from agent.prompts.system_prompts import get_prompt
            system_prompt = get_prompt(mode)
        except ImportError:
            system_prompt = f"You are a Minecraft 1.21 command translator. Mode: {mode}."

        context_str = '\n'.join(context_parts) if context_parts else 'No context available'
        user_message = f"Request from {player}: {query}\n\nContext:\n{context_str}"

        messages = [
            {'role': 'system', 'content': '/no_think\n' + system_prompt},
            {'role': 'user', 'content': user_message},
        ]

        # 3. LLM call
        ollama_payload = {
            'model': model,
            'messages': messages,
            'stream': False,
            'format': 'json',
            'options': {'temperature': temp, 'num_predict': max_tokens},
        }
        self._sse_event({
            'type': 'llm',
            'text': f'Calling {model} on {ollama_url}...',
            'api_request': {'url': f'{ollama_url}/api/chat', 'method': 'POST', 'body': ollama_payload},
        })
        llm_start = time.time()

        try:
            import re
            r = req.post(f"{ollama_url}/api/chat", json=ollama_payload, timeout=120)
            r.raise_for_status()

            api_response = r.json()
            raw_content = api_response['message']['content']
            # Strip thinking tags
            raw_content = re.sub(r'<think>[\s\S]*?</think>\s*', '', raw_content)

            llm_ms = int((time.time() - llm_start) * 1000)
            self._sse_event({
                'type': 'llm',
                'text': f'LLM responded in {llm_ms}ms',
                'api_response': {'raw': raw_content, 'model': api_response.get('model'), 'eval_count': api_response.get('eval_count'), 'eval_duration': api_response.get('eval_duration')},
            })

            try:
                parsed = json.loads(raw_content)
            except json.JSONDecodeError:
                parsed = {'commands': [], 'message': raw_content, 'reasoning': 'parse failed'}
                self._sse_event({'type': 'error', 'text': f'JSON parse failed, raw: {raw_content[:200]}'})

            commands = parsed.get('commands', [])
            message = parsed.get('message', '')
            reasoning = parsed.get('reasoning', '')

            if reasoning:
                self._sse_event({'type': 'llm', 'text': f'Reasoning: {reasoning}'})
            if message:
                self._sse_event({'type': 'llm', 'text': f'Message: {message}'})
            self._sse_event({'type': 'llm', 'text': f'Commands ({len(commands)}): {json.dumps(commands)}', 'parsed': parsed})

        except Exception as e:
            self._sse_event({'type': 'error', 'text': f'LLM call failed: {e}'})
            commands = []
            message = ''
            reasoning = ''
            raw_content = ''
            llm_ms = 0

        # 4. Guardrails
        self._sse_event({'type': 'guard', 'text': f'Running guardrails on {len(commands)} commands...'})
        guardrail_results = []
        safe_commands = []
        try:
            from agent.guardrails.command_filter import filter_commands
            safe_commands, guardrail_results = filter_commands(commands)
            blocked = len(commands) - len(safe_commands)
            if blocked:
                self._sse_event({'type': 'guard', 'text': f'Blocked {blocked} commands'})
            for gr in guardrail_results:
                if gr.get('warnings'):
                    self._sse_event({'type': 'guard', 'text': f"  {gr['command']}: {', '.join(gr['warnings'])}"})
        except ImportError:
            safe_commands = commands
            self._sse_event({'type': 'guard', 'text': 'Guardrails not available, passing all commands'})

        self._sse_event({'type': 'guard', 'text': f'Passed: {len(safe_commands)} commands'})

        # 5. Execute via RCON
        rcon_results = []
        if safe_commands:
            self._sse_event({'type': 'rcon', 'text': f'Executing {len(safe_commands)} commands via RCON...'})
            try:
                rcon = get_rcon(rcon_host, rcon_port, rcon_pass)
                for cmd in safe_commands[:12]:
                    if not isinstance(cmd, str) or not cmd.strip():
                        continue
                    try:
                        result = rcon.command(cmd)
                        is_error = any(e in result for e in ('<--[HERE]', 'Unknown', 'Incorrect', 'Expected'))
                        rcon_entry = {'cmd': cmd, 'result': result[:200], 'ok': not is_error}
                        rcon_results.append(rcon_entry)
                        trace_type = 'rcon' if not is_error else 'rcon-fail'
                        self._sse_event({'type': trace_type, 'text': f'{cmd} -> {result[:120]}',
                                         'rcon_command': cmd, 'rcon_result': result, 'rcon_ok': not is_error})
                    except Exception as e:
                        rcon_results.append({'cmd': cmd, 'result': str(e), 'ok': False})
                        self._sse_event({'type': 'rcon-fail', 'text': f'{cmd} -> ERROR: {e}',
                                         'rcon_command': cmd, 'rcon_error': str(e)})
            except Exception as e:
                self._sse_event({'type': 'error', 'text': f'RCON execution failed: {e}'})

        # 6. Final result
        total_ms = int((time.time() - start) * 1000)
        ok_count = sum(1 for r in rcon_results if r.get('ok'))
        fail_count = sum(1 for r in rcon_results if not r.get('ok'))

        full_result = {
            'timestamp': datetime.utcnow().isoformat() + 'Z',
            'source': 'chat_app',
            'mode': mode,
            'player': player,
            'input': {
                'user_message': (('pray ' if mode == 'god' else 'sudo ') + query) if query else '',
                'server_context': {'server_type': 'paper', 'version': '1.21.x'},
            },
            'output': {
                'commands_generated': commands,
                'commands_executed': safe_commands,
                'message': message,
                'reasoning': reasoning,
            },
            'rcon_results': rcon_results,
            'metadata': {
                'model': model,
                'ollama_url': ollama_url,
                'temperature': temp,
                'duration_ms': total_ms,
                'context_ms': ctx_ms,
                'llm_ms': llm_ms,
            },
        }

        self._sse_event({
            'type': 'result',
            'text': f'Complete: {total_ms}ms total, {ok_count} ok / {fail_count} fail',
            'cmd_count': f'{ok_count}/{ok_count + fail_count}',
            'model': model,
            'full_result': full_result,
        })

    def _run_model_call(self, payload):
        """Execute a direct model call (bypass gateway pipeline)."""
        import requests as req

        model = payload.get('model', DEFAULT_MODEL_CONFIG['model'])
        ollama_url = payload.get('ollama_url', DEFAULT_MODEL_CONFIG['ollama_url'])
        messages = payload.get('messages', DEFAULT_MODEL_CONFIG['messages'])
        temp = payload.get('temperature', 0.7)
        max_tokens = payload.get('max_tokens', 800)
        fmt = payload.get('format', 'json')

        self._sse_event({'type': 'system', 'text': f'Direct model call: {model} on {ollama_url}', 'model': model})
        self._sse_event({'type': 'llm', 'text': f'Sending {len(messages)} messages, temp={temp}'})

        start = time.time()

        try:
            import re
            api_payload = {
                'model': model,
                'messages': messages,
                'stream': False,
                'options': {'temperature': temp, 'num_predict': max_tokens},
            }
            if fmt:
                api_payload['format'] = fmt

            r = req.post(f"{ollama_url}/api/chat", json=api_payload, timeout=120)
            r.raise_for_status()

            raw_content = r.json()['message']['content']
            raw_content = re.sub(r'<think>[\s\S]*?</think>\s*', '', raw_content)

            llm_ms = int((time.time() - start) * 1000)
            self._sse_event({'type': 'llm', 'text': f'Response in {llm_ms}ms'})

            try:
                parsed = json.loads(raw_content)
                commands = parsed.get('commands', [])
                message = parsed.get('message', '')
                reasoning = parsed.get('reasoning', '')

                if reasoning:
                    self._sse_event({'type': 'llm', 'text': f'Reasoning: {reasoning}'})
                if message:
                    self._sse_event({'type': 'llm', 'text': f'Message: {message}'})
                self._sse_event({'type': 'llm', 'text': f'Commands ({len(commands)}): {json.dumps(commands)}',
                                 'cmd_count': str(len(commands))})
            except json.JSONDecodeError:
                self._sse_event({'type': 'llm', 'text': f'Raw output: {raw_content[:500]}'})
                commands = []
                message = raw_content
                reasoning = ''

            full_result = {
                'timestamp': datetime.utcnow().isoformat() + 'Z',
                'source': 'chat_app_model',
                'input': {'messages': messages},
                'output': {
                    'commands_generated': commands,
                    'message': message,
                    'reasoning': reasoning,
                    'raw': raw_content,
                },
                'metadata': {
                    'model': model,
                    'ollama_url': ollama_url,
                    'temperature': temp,
                    'duration_ms': llm_ms,
                },
            }

            self._sse_event({
                'type': 'result',
                'text': f'Complete: {llm_ms}ms, {len(commands)} commands',
                'model': model,
                'full_result': full_result,
            })

        except Exception as e:
            self._sse_event({'type': 'error', 'text': f'Model call failed: {e}'})

    def _handle_save_training(self, body):
        """Save an interaction to the training export log."""
        LOG_DIR.mkdir(parents=True, exist_ok=True)
        with open(TRAINING_LOG, 'a') as f:
            f.write(json.dumps(body) + '\n')
        self._json_response({'ok': True, 'file': str(TRAINING_LOG.name)})


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(description="Mortdecai Chat App")
    parser.add_argument("--port", type=int, default=PORT)
    parser.add_argument("--host", default="0.0.0.0")
    args = parser.parse_args()

    LOG_DIR.mkdir(parents=True, exist_ok=True)

    server = HTTPServer((args.host, args.port), ChatHandler)
    print(f"Mortdecai Chat App")
    print(f"  http://{args.host}:{args.port}")
    print(f"  Prompts: {PROJECT_ROOT / 'training' / 'prompts'}")
    print(f"  Training log: {TRAINING_LOG}")
    print()

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutdown.")
        server.server_close()


if __name__ == "__main__":
    main()
