"""
wivexx_web.py — phone / browser interface for WIVEXX  (v1.8)
═══════════════════════════════════════════════════════════════════════
Stdlib-only HTTP server with a mobile WIVEXX page:

  • Live chat feed — everything said by voice OR typed anywhere appears here
    (in-memory; cleared when WIVEXX restarts).
  • Animated WIVEXX sphere mirroring his state (idle/listening/speaking/…).
  • 🖥 Screen view — live preview of any monitor; tap the image to CLICK
    there with the real mouse.
  • 📎 attachments from the phone — "Ask" sends them to Claude vision;
    "Save to PC" stores them under  <WEB_SAVED_DIR>\<Category>\…
  • 📁 Files — browse the saved-files folders and download to the phone.
  • Client tracking — the desktop GUI shows how many devices are connected
    (hover for name / IP / MAC).

Security: PIN on every request (WEB_PIN in wivexx_config.py).  LAN only —
never port-forward; use Tailscale/WireGuard for remote access.

Public API:
    start(callbacks, cfg, logger) -> Optional[str]
    record_chat(role, text)            # called by wivexx_api._console_turn
    get_clients() -> list[dict]        # for the desktop GUI indicator
    get_urls(cfg) -> (host_url, ip_url)

callbacks keys (all optional except handle_message):
    handle_message(text) -> str
    handle_image(text, image_b64, filename) -> str
    screenshot(monitor:int) -> bytes|None        # JPEG
    click(monitor:int, fx:float, fy:float) -> bool
    monitors() -> int
    get_state() -> str
"""

import json
import os
import re
import socket
import subprocess
import threading
import time
import urllib.parse
from base64 import b64decode
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Optional

_callbacks: dict = {}
_pin = ""
_logger = None
_saved_dir = os.path.join(os.path.expanduser("~"), "Documents", "Wivexx", "Saved")

_MAX_UPLOAD = 300_000_000          # JSON "Ask" path: ~220 MB of files after
                                   # base64 (analysable types are small anyway)
_MAX_RAW_UPLOAD = 4_000_000_000    # v1.13 streaming "Save to PC" path: 4 GB
                                   # per file, written to disk in 1 MB chunks
_THUMB_READBACK_MAX = 40_000_000   # only thumbnail saved images below this

# ── Chat feed ──────────────────────────────────────────────────────────────────
_chat_lock = threading.Lock()
_chat: deque = deque(maxlen=400)
_chat_seq = 0


def record_chat(role: str, text: str, atts: Optional[list] = None) -> None:
    """Append a line to the live feed (called for every console turn).
    v1.12: *atts* ([{name, kind, thumb}, …]) renders the actual images /
    file chips inline in the browser chat."""
    global _chat_seq
    try:
        entry = {"i": 0,
                 "role": "you" if role.lower() in ("you", "user") else "wivexx",
                 "text": str(text),
                 "ts": time.strftime("%H:%M")}
        if atts:
            entry["atts"] = [{"name": str(a.get("name", "file"))[:80],
                              "kind": a.get("kind", "file"),
                              "thumb": a.get("thumb")} for a in atts[:12]]
        with _chat_lock:
            _chat_seq += 1
            entry["i"] = _chat_seq
            _chat.append(entry)
    except Exception:
        pass


# ── Connected clients ──────────────────────────────────────────────────────────
_clients: dict = {}      # ip → {"last": ts, "name": str|None, "mac": str|None}
_CLIENT_TTL = 75         # page polls every few seconds; drop after this


def _resolve_client_bg(ip: str) -> None:
    rec = _clients.get(ip)
    if rec is None:
        return
    try:
        if ip in ("127.0.0.1", "::1"):
            rec["name"] = "this PC"
        else:
            rec["name"] = socket.gethostbyaddr(ip)[0]
    except Exception:
        rec["name"] = ""
    try:
        if ip not in ("127.0.0.1", "::1"):
            out = subprocess.run(["arp", "-a", ip], capture_output=True,
                                 timeout=4, text=True)
            m = re.search(r"([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})", out.stdout)
            rec["mac"] = m.group(1).upper().replace("-", ":") if m else ""
    except Exception:
        rec["mac"] = ""


def _touch_client(ip: str) -> None:
    rec = _clients.get(ip)
    if rec is None:
        rec = {"last": time.time(), "name": None, "mac": None}
        _clients[ip] = rec
        threading.Thread(target=_resolve_client_bg, args=(ip,),
                         daemon=True, name="wivexx-web-resolve").start()
    else:
        rec["last"] = time.time()


def get_clients() -> list:
    now = time.time()
    out = []
    for ip, rec in list(_clients.items()):
        if now - rec.get("last", 0) <= _CLIENT_TTL:
            out.append({"ip": ip, "name": rec.get("name") or "",
                        "mac": rec.get("mac") or ""})
    return out


# ── Saved files ────────────────────────────────────────────────────────────────
_CATEGORIES = {
    "Photos":    {".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".bmp",
                  ".gif", ".tif", ".tiff", ".arw", ".cr2", ".cr3", ".nef", ".dng"},
    "Videos":    {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"},
    "Documents": {".pdf", ".doc", ".docx", ".txt", ".md", ".xls", ".xlsx",
                  ".ppt", ".pptx", ".csv", ".rtf"},
    "Audio":     {".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac"},
}


def _categorize(filename: str) -> str:
    ext = os.path.splitext(filename)[1].lower()
    for cat, exts in _CATEGORIES.items():
        if ext in exts:
            return cat
    return "Other"


def _safe_name(filename: str) -> str:
    name = os.path.basename(filename or "file")
    return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip() or "file"


def _save_uploaded(filename: str, raw: bytes) -> str:
    """Store a phone upload under the categorized saved-files tree.
    Returns the path relative to the saved dir."""
    fname = _safe_name(filename)
    cat = _categorize(fname)
    folder = os.path.join(_saved_dir, cat)
    os.makedirs(folder, exist_ok=True)
    target = os.path.join(folder, fname)
    if os.path.exists(target):
        base, ext = os.path.splitext(fname)
        n = 1
        while os.path.exists(target):
            target = os.path.join(folder, f"{base}_{n}{ext}")
            n += 1
    with open(target, "wb") as f:
        f.write(raw)
    return os.path.relpath(target, _saved_dir)


def _list_saved() -> dict:
    out: dict = {}
    try:
        for root, dirs, files in os.walk(_saved_dir):
            dirs[:] = [d for d in dirs if not d.startswith(("$", "."))]
            for fname in files:
                full = os.path.join(root, fname)
                rel = os.path.relpath(full, _saved_dir)
                cat = rel.split(os.sep)[0] if os.sep in rel else "Other"
                try:
                    size = os.path.getsize(full)
                except OSError:
                    size = 0
                out.setdefault(cat, []).append(
                    {"n": fname, "s": size, "p": rel.replace(os.sep, "/")})
        for cat in out:
            out[cat].sort(key=lambda f: f["n"].lower())
    except Exception:
        pass
    return out


def _resolve_saved_path(rel: str) -> Optional[str]:
    """Traversal-proof resolution of a download path."""
    try:
        base = os.path.abspath(_saved_dir)
        full = os.path.abspath(os.path.join(base, rel.replace("/", os.sep)))
        if full.startswith(base + os.sep) and os.path.isfile(full):
            return full
    except Exception:
        pass
    return None


_VIEW_TYPES = {
    ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
    ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
    ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime",
    ".webm": "video/webm", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
    ".mp3": "audio/mpeg", ".wav": "audio/wav", ".m4a": "audio/mp4",
    ".flac": "audio/flac", ".ogg": "audio/ogg", ".aac": "audio/aac",
    ".pdf": "application/pdf",
    ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
    ".csv": "text/plain; charset=utf-8", ".json": "text/plain; charset=utf-8",
    ".log": "text/plain; charset=utf-8",
}


# ══════════════════════════════════════════════════════════════════════════════
#  The page
# ══════════════════════════════════════════════════════════════════════════════
_PAGE = """<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>wivexx.</title>
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@500;700&family=Space+Mono&display=swap" rel="stylesheet">
<style>
  :root { --bg:#0D0D0D; --panel:#1C1C1A; --edge:#262624; --fg:#F2F0EA;
          --dim:#8A8A85; --accent:#C1F73A; --you:#F2F0EA; --danger:#E0553A;
          --font-ui:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;
          --font-mono:'Space Mono',Consolas,Menlo,monospace;
          --font-disp:'Space Grotesk',var(--font-ui); }
  * { box-sizing:border-box; margin:0; padding:0; }
  body { background:var(--bg); color:var(--fg);
         font-family:var(--font-ui); height:100dvh;
         max-width:920px; margin:0 auto;
         padding-bottom:env(safe-area-inset-bottom);
         display:flex; flex-direction:column; }
  @media (min-width:921px) {
    body { border-left:1px solid var(--edge); border-right:1px solid var(--edge); }
  }
  header { border-bottom:1px solid var(--edge); position:relative; }
  #wordmark { position:absolute; top:9px; left:14px; z-index:2;
              font-family:var(--font-disp); font-weight:500; font-size:15px;
              letter-spacing:-0.02em; color:var(--fg); }
  #wordmark b { color:var(--accent); font-weight:500; }
  #sphere { display:block; width:100%; height:110px; }
  #statebar { position:absolute; bottom:6px; left:12px; color:#8A8A85;
              font-size:11px; }
  #hbtns { position:absolute; top:8px; right:8px; display:flex; gap:6px; }
  .hbtn { background:var(--panel); color:var(--dim); border:1px solid var(--edge);
          border-radius:8px; padding:5px 10px; font:inherit; font-size:12px; }
  #chat { flex:1; overflow-y:auto; padding:14px; display:flex;
          flex-direction:column; gap:10px; }
  /* v1.57: friendly empty state + tappable starter chips (parity with the PC app) */
  #pw-empty { margin:auto; text-align:center; color:var(--dim);
              font-size:13px; line-height:1.8; max-width:320px; }
  .pw-chips { margin-top:12px; display:flex; flex-wrap:wrap; gap:8px; justify-content:center; }
  .pw-chips .schip { background:var(--panel); color:var(--fg); border:1px solid var(--edge);
                     border-radius:999px; padding:8px 14px; font:inherit; font-size:12.5px; }
  .pw-chips .schip:active { border-color:var(--accent); color:var(--accent); }
  .msg { max-width:86%; padding:9px 12px; border-radius:10px;
         font-size:14px; line-height:1.45; white-space:pre-wrap;
         word-wrap:break-word; }
  .you    { align-self:flex-end; background:var(--edge); color:var(--you);
            border-bottom-right-radius:2px; }
  .wivexx { align-self:flex-start; background:var(--panel); color:var(--fg);
            border:1px solid var(--edge); border-bottom-left-radius:2px; }
  .sys { align-self:center; color:#8A8A85; font-size:12px; font-style:italic; }
  .msg img.att { display:block; max-width:100%; max-height:240px;
                 border-radius:8px; margin-top:6px;
                 border:1px solid var(--edge); cursor:pointer; }
  .msg .fchip { display:inline-block; background:var(--bg);
                border:1px solid var(--edge); border-radius:6px;
                padding:4px 8px; margin:6px 4px 0 0; font-size:12px;
                color:var(--dim); }
  .msg .fchip.clickable { cursor:pointer; color:var(--accent); }
  /* v1.13: upload progress */
  #upwrap { display:none; padding:8px 12px; background:var(--panel);
            border-top:1px solid var(--edge); }
  #uplabel { font-size:12px; color:var(--dim); margin-bottom:5px;
             display:flex; justify-content:space-between; }
  #upbar { height:8px; background:var(--bg); border:1px solid var(--edge);
           border-radius:6px; overflow:hidden; }
  #upfill { height:100%; width:0%; border-radius:6px;
            background:var(--accent);
            transition:width .15s linear; }
  #upfill.done { background:var(--accent); }
  /* v1.13: content preview overlay */
  #prevbody { flex:1; overflow:auto; display:flex; align-items:center;
              justify-content:center; padding:10px; }
  #prevbody img, #prevbody video { max-width:100%; max-height:100%;
              border-radius:8px; border:1px solid var(--edge); }
  #prevbody iframe { width:100%; height:100%; border:none;
              background:#fff; border-radius:6px; }
  #prevbody pre { align-self:flex-start; white-space:pre-wrap;
              word-break:break-word; font-size:13px; color:var(--fg);
              max-width:100%; }
  #prevbody audio { width:90%; }
  #attachbar { display:none; align-items:center; gap:8px; padding:6px 10px;
               background:var(--panel); border-top:1px solid var(--edge);
               font-size:12px; color:var(--dim); }
  #attachbar img { height:38px; border-radius:6px; }
  #attachbar button { background:var(--edge); color:var(--accent); border:none;
                      border-radius:6px; padding:6px 10px; font:inherit;
                      font-size:12px; }
  footer { display:flex; gap:8px; padding:10px;
           border-top:1px solid var(--edge); }
  input[type=text], input[type=password] {
          flex:1; background:var(--panel); border:1px solid var(--edge);
          color:var(--fg); border-radius:8px; padding:11px 12px;
          font:inherit; font-size:15px; outline:none; }
  input:focus { border-color:var(--accent); }
  footer button { background:var(--edge); color:var(--accent); border:none;
           border-radius:8px; padding:0 14px; font:inherit; font-size:15px; }
  .overlay { position:fixed; inset:0; background:rgba(13,13,13,.96); z-index:50;
             display:none; flex-direction:column; }
  .overlay.show { display:flex; }
  .obar { display:flex; gap:8px; padding:10px; align-items:center;
          border-bottom:1px solid var(--edge); }
  .obar .title { color:var(--accent); font-size:14px; flex:1; }
  .obar button { background:var(--panel); color:var(--dim); border:1px solid
                 var(--edge); border-radius:8px; padding:6px 12px; font:inherit;
                 font-size:13px; }
  #scrwrap { flex:1; overflow:auto; display:flex; align-items:flex-start;
             justify-content:center; padding:8px; }
  #scrimg { width:100%; height:auto; border:1px solid var(--edge);
            border-radius:6px; touch-action:manipulation; }
  #files { flex:1; overflow-y:auto; padding:12px; }
  .fcat { color:var(--accent); margin:12px 0 6px; font-size:14px; }
  .frow { display:flex; justify-content:space-between; gap:10px; padding:8px 10px;
          background:var(--panel); border:1px solid var(--edge); border-radius:8px;
          margin-bottom:6px; font-size:13px; color:var(--fg);
          text-decoration:none; }
  .frow span.sz { color:#8A8A85; white-space:nowrap; }
  #pinbox { position:fixed; inset:0; background:var(--bg); z-index:99;
            display:flex; flex-direction:column; align-items:center;
            justify-content:center; gap:14px; }
  #pinbox.hidden { display:none; }
  #pinbox h2 { color:var(--accent); letter-spacing:4px; font-size:18px; }
  #pinbox input { flex:none; width:200px; text-align:center;
                  letter-spacing:6px; font-size:20px; }
  #pinbox button { background:var(--edge); color:var(--accent); border:none;
                   border-radius:8px; padding:10px 22px; font:inherit; }
  /* ── v1.11: small-phone adaptability ── */
  #hbtns { flex-wrap:wrap; justify-content:flex-end; max-width:62%; gap:5px; }
  @media (max-width: 420px) {
    .hbtn { padding:4px 8px; font-size:11px; }
    #sphere { height:92px; }
    #statebar { font-size:10px; bottom:4px; }
    .msg { max-width:93%; font-size:13px; padding:8px 10px; }
    #chat { padding:10px; gap:8px; }
    footer { padding:8px; gap:6px; }
    footer button { padding:0 11px; font-size:14px; }
    input[type=text], input[type=password] { font-size:14px; padding:9px 10px; }
    .obar { flex-wrap:wrap; }
    .obar .title { font-size:13px; flex-basis:100%; margin-bottom:4px; }
    .frow { font-size:12px; padding:7px 8px; }
  }
  @media (max-width: 340px) {
    .hbtn { padding:3px 6px; font-size:10px; }
    h1 { font-size:13px; letter-spacing:2px; }
  }
  /* ── v1.18 Wivexx brand polish ── */
  #statebar, .sys { font-family:var(--font-mono); }
  .hbtn { font-family:var(--font-mono); min-height:34px; }
  footer button, #attachbar button, .obar button { font-family:var(--font-mono); }
  footer button { min-width:44px; min-height:44px; }
  #pinbox h2 { font-family:var(--font-disp); }
  .msg { animation:msgin .22s ease both; }
  @keyframes msgin { from { opacity:0; transform:translateY(6px); }
                     to   { opacity:1; transform:none; } }
  .hbtn, footer button, .obar button, #attachbar button, input { transition:border-color .15s, background .15s, color .15s; }
  .hbtn:active, footer button:active, .obar button:active { transform:scale(.96); }
  @media (prefers-reduced-motion: reduce) {
    .msg { animation:none; }
    *, *::before, *::after { transition:none !important; }
  }
</style></head><body>
<div id="pinbox">
  <h2>wivexx<b style="color:var(--accent)">.</b></h2>
  <div style="color:var(--dim);font-size:13px">Enter the access PIN</div>
  <input id="pin" type="password" inputmode="numeric" autocomplete="off"
         autocapitalize="off" spellcheck="false" enterkeyhint="go">
  <button onclick="tryPin()">Connect</button>
  <div id="pinerr" style="color:#E0553A;font-size:12px;min-height:16px"></div>
</div>

<header>
  <div id="wordmark">wivexx<b>.</b></div>
  <canvas id="sphere"></canvas>
  <div id="statebar">● idle</div>
  <div id="hbtns">
    <button class="hbtn" id="mutebtn" onclick="toggleMute()">🔊</button>
    <button class="hbtn" onclick="openGoals()">🎯</button>
    <button class="hbtn" onclick="openMemory()">🧠</button>
    <button class="hbtn" onclick="openScreen()">🖥 Screen</button>
    <button class="hbtn" onclick="openFiles()">📁 Files</button>
    <button class="hbtn" onclick="openExamples()">💡</button>
  </div>
</header>

<div id="chat">
  <!-- v1.57: phone parity with the control room's friendly empty state — the chip's
       visible (i18n-translated) text IS the command it sends -->
  <div id="pw-empty">
    Connected to Wivexx on your PC. Ask anything, or try one:
    <div class="pw-chips">
      <button type="button" class="schip" onclick="sendChip(this)">what can you do?</button>
      <button type="button" class="schip" onclick="sendChip(this)">take a screenshot</button>
      <button type="button" class="schip" onclick="sendChip(this)">set a timer for 5 minutes</button>
      <button type="button" class="schip" onclick="sendChip(this)">what&rsquo;s the weather?</button>
    </div>
  </div>
</div>

<div id="attachbar">
  <img id="athumb" style="display:none">
  <span id="aname"></span>
  <button onclick="sendUpload('ask')">Ask</button>
  <button onclick="sendUpload('save')">💾 Save to PC</button>
  <button onclick="clearAttach()">✕</button>
</div>

<div id="upwrap">
  <div id="uplabel"><span id="upname">uploading…</span><span id="uppct">0%</span></div>
  <div id="upbar"><div id="upfill"></div></div>
</div>

<footer>
  <button onclick="document.getElementById('file').click()">📎</button>
  <input id="file" type="file" multiple style="display:none" onchange="pickFile(this)">
  <input id="box" type="text" placeholder="Type a command or question…" autocomplete="off">
  <button onclick="send()">➤</button>
</footer>

<div id="screenov" class="overlay">
  <div class="obar">
    <span class="title">🖥 Live screen — tap to click</span>
    <button id="monbtn" onclick="nextMonitor()">Monitor 1</button>
    <button onclick="toggleTypeBar()">⌨ Type</button>
    <button onclick="closeScreen()">✕ Close</button>
  </div>
  <div id="typebar" style="display:none; gap:8px; padding:8px 10px;
       border-bottom:1px solid var(--edge);">
    <input id="typebox" type="text" placeholder="Tap a field on screen first, then type here…"
           style="flex:1" enterkeyhint="send">
    <button onclick="sendType(true)" style="background:var(--edge);color:var(--accent);
            border:none;border-radius:8px;padding:0 14px;">Send ⏎</button>
  </div>
  <div id="scrwrap"><img id="scrimg" alt="screen"></div>
</div>

<div id="filesov" class="overlay">
  <div class="obar">
    <span class="title">📁 Wivexx saved — Files</span>
    <button onclick="closeFiles()">✕ Close</button>
  </div>
  <div id="files"></div>
</div>

<div id="exov" class="overlay">
  <div class="obar">
    <span class="title">💡 Things you can say</span>
    <button onclick="closeExamples()">✕ Close</button>
  </div>
  <div style="flex:1;overflow-y:auto;padding:12px;">
    <style>
      #exov .excat{color:var(--accent);font-size:13px;margin:14px 0 6px;}
      #exov .excmd{display:block;width:100%;text-align:left;background:var(--panel);
        color:var(--fg);border:1px solid var(--edge);border-radius:8px;
        padding:10px 12px;margin:6px 0;font:inherit;font-size:14px;cursor:pointer;}
      #exov .excmd:active{border-color:var(--accent);}
    </style>
    <div class="excat">🧮 Calculate &amp; convert — instant, no API cost</div>
    <button class="excmd" onclick="runExample(&quot;what is 15% of 850&quot;)">What's 15% of 850?</button>
    <button class="excmd" onclick="runExample(&quot;convert 1500 USD to euros&quot;)">Convert 1500 USD to euros</button>
    <button class="excmd" onclick="runExample(&quot;convert 10 km to miles&quot;)">Convert 10 km to miles</button>
    <div class="excat">📋 Clipboard</div>
    <button class="excmd" onclick="runExample(&quot;summarize what I just copied&quot;)">Summarize what I just copied</button>
    <button class="excmd" onclick="runExample(&quot;translate my clipboard to Spanish&quot;)">Translate my clipboard to Spanish</button>
    <div class="excat">🪟 Windows (controls the PC)</div>
    <button class="excmd" onclick="runExample(&quot;move Discord to the left monitor&quot;)">Move Discord to the left monitor</button>
    <button class="excmd" onclick="runExample(&quot;minimize everything except Chrome&quot;)">Minimize everything except Chrome</button>
    <div class="excat">📅 Calendar</div>
    <button class="excmd" onclick="runExample(&quot;what's my first meeting tomorrow&quot;)">What's my first meeting tomorrow?</button>
    <button class="excmd" onclick="runExample(&quot;what's on my calendar today&quot;)">What's on my calendar today?</button>
    <button class="excmd" onclick="runExample(&quot;schedule a call tomorrow at 3pm for an hour&quot;)">Schedule a call tomorrow at 3pm</button>
    <div class="excat">📦 Photos &amp; content</div>
    <button class="excmd" onclick="runExample(&quot;deliver photos to a client&quot;)">Deliver photos to a client</button>
    <button class="excmd" onclick="runExample(&quot;create a caption&quot;)">Create a caption</button>
    <button class="excmd" onclick="runExample(&quot;how is my instagram doing&quot;)">How is my Instagram doing?</button>
  </div>
</div>

<div id="prevov" class="overlay" style="z-index:60">
  <div class="obar">
    <span class="title" id="prevtitle">👁 Preview</span>
    <a id="prevdl" style="background:var(--panel);color:var(--dim);
       border:1px solid var(--edge);border-radius:8px;padding:6px 12px;
       font-size:13px;text-decoration:none;">⬇ Download</a>
    <button onclick="closePreview()">✕ Close</button>
  </div>
  <div id="prevbody"></div>
</div>

<div id="memov" class="overlay">
  <div class="obar">
    <span class="title">🧠 Memories</span>
    <button onclick="closeMemory()">✕ Close</button>
  </div>
  <div style="padding:8px 12px 0">
    <input id="msearch" type="text" placeholder="🔎 search memories…"
           style="width:100%" oninput="refreshMemory()">
  </div>
  <div id="mlist" style="flex:1;overflow-y:auto;padding:12px"></div>
  <div id="medit" style="display:none;flex-direction:column;gap:6px;
       padding:10px;border-top:1px solid var(--edge)">
    <input id="mkey" type="text" placeholder="category (key)">
    <textarea id="mval" rows="2" placeholder="memory (value)"
      style="background:var(--panel);border:1px solid var(--edge);
             color:var(--fg);border-radius:8px;padding:10px;font:inherit;
             font-size:14px;resize:none"></textarea>
    <div style="display:flex;gap:6px;align-items:center">
      <span style="color:var(--dim);font-size:12px">importance</span>
      <select id="mimp" style="background:var(--panel);color:var(--fg);
              border:1px solid var(--edge);border-radius:8px;font:inherit">
        <option>1</option><option>2</option><option>3</option><option>4</option>
        <option selected>5</option><option>6</option><option>7</option>
        <option>8</option><option>9</option><option>10</option>
      </select>
      <span style="flex:1"></span>
      <button onclick="saveMemory()" style="background:var(--edge);
              color:var(--accent);border:none;border-radius:8px;
              padding:8px 16px;">💾 Save</button>
      <button onclick="cancelMemEdit()" style="background:none;border:none;
              color:var(--dim);">✕</button>
    </div>
  </div>
  <div style="display:flex;gap:6px;padding:10px;border-top:1px solid var(--edge)">
    <button onclick="newMemory()" style="flex:1;background:var(--edge);
            color:var(--accent);border:none;border-radius:8px;
            padding:10px;">＋ Add a memory</button>
  </div>
</div>

<div id="goalsov" class="overlay">
  <div class="obar">
    <span class="title">🎯 Goals</span>
    <span id="gstats" style="color:#8A8A85;font-size:11px"></span>
    <button onclick="closeGoals()">✕ Close</button>
  </div>
  <div id="glist" style="flex:1;overflow-y:auto;padding:12px"></div>
  <div style="display:flex;gap:6px;padding:10px;border-top:1px solid var(--edge)">
    <input id="gnew" type="text" placeholder="New goal…" style="flex:1"
           enterkeyhint="done">
    <select id="gprio" style="background:var(--panel);color:var(--fg);
            border:1px solid var(--edge);border-radius:8px;font:inherit;
            font-size:13px">
      <option value="normal">normal</option>
      <option value="high">high</option>
      <option value="low">low</option>
    </select>
    <button onclick="addGoal()" style="background:var(--edge);
            color:var(--accent);border:none;border-radius:8px;
            padding:0 14px;">＋</button>
  </div>
</div>

<script>
const chat = document.getElementById('chat');
const box  = document.getElementById('box');
let lastSeq = 0, monitors = 1, curMon = 1, scrTimer = null;
let attach = null;   // {name, b64, isImage}
let state = 'standby';
let muted = localStorage.getItem('wivexx_muted') === '1';
let webSpeaking = false;          // browser TTS is currently reading aloud
let firstChatLoad = true;         // don't read the whole backlog on connect

/* ── read-aloud (Web Speech API) with mute + sphere sync ─────────── */
function updateMuteBtn() {
  const b = document.getElementById('mutebtn');
  if (b) b.textContent = muted ? '🔇' : '🔊';
}
function toggleMute() {
  muted = !muted;
  localStorage.setItem('wivexx_muted', muted ? '1' : '0');
  if (muted && window.speechSynthesis) speechSynthesis.cancel();
  updateMuteBtn();
}
function speakAloud(text) {
  if (muted || !window.speechSynthesis) return;
  try {
    const u = new SpeechSynthesisUtterance(text);
    u.rate = 1.02; u.pitch = 0.9;
    const vs = speechSynthesis.getVoices();
    const pref = vs.find(v => /Guy|Ryan|David|UK English Male|Male/i.test(v.name))
              || vs.find(v => /en-US|en-GB/i.test(v.lang));
    if (pref) u.voice = pref;
    u.onstart = () => { webSpeaking = true; };
    u.onend   = () => { webSpeaking = false; };
    u.onerror = () => { webSpeaking = false; };
    speechSynthesis.speak(u);
  } catch (e) {}
}

function pin() { return localStorage.getItem('wivexx_pin') || ''; }
function esc(s){ return s; }
function addRaw(cls, text, atts) {
  const pe = document.getElementById('pw-empty');
  if (pe) pe.style.display = 'none';
  const d = document.createElement('div');
  d.className = 'msg ' + cls;
  d.textContent = text;
  for (const a of (atts || [])) {
    if (a.thumb) {
      const im = document.createElement('img');
      im.className = 'att';
      im.src = 'data:image/jpeg;base64,' + a.thumb;
      im.alt = a.name || 'image';
      /* v1.13: tap to preview — full quality when saved on the PC */
      im.onclick = () => a.p ? openPreview(a.p, a.name)
                             : openPreviewData(im.src, a.name);
      d.appendChild(im);
    } else {
      const c = document.createElement('span');
      const icon = a.kind === 'video' ? '🎬' : '📄';
      c.className = 'fchip' + (a.p ? ' clickable' : '');
      c.textContent = icon + ' ' + (a.name || 'file');
      if (a.p) c.onclick = () => openPreview(a.p, a.name);
      d.appendChild(c);
    }
  }
  chat.appendChild(d);
  chat.scrollTop = chat.scrollHeight;
  return d;
}

/* ── v1.13: content preview overlay ─────────────────────────────── */
const PREV_IMG = ['jpg','jpeg','png','gif','webp','bmp'];
const PREV_VID = ['mp4','m4v','mov','webm','mkv','avi'];
const PREV_AUD = ['mp3','wav','m4a','flac','ogg','aac'];
const PREV_TXT = ['txt','md','csv','json','log'];
function viewUrl(p) {
  return '/api/view?p=' + encodeURIComponent(p) +
         '&pin=' + encodeURIComponent(pin());
}
function openPreview(p, name) {
  const body = document.getElementById('prevbody');
  body.innerHTML = '';
  document.getElementById('prevtitle').textContent = '👁 ' + (name || p);
  const dl = document.getElementById('prevdl');
  dl.href = '/api/dl?p=' + encodeURIComponent(p) +
            '&pin=' + encodeURIComponent(pin());
  dl.setAttribute('download', name || '');
  dl.style.display = '';
  const ext = (name || p).split('.').pop().toLowerCase();
  if (PREV_IMG.includes(ext)) {
    const im = document.createElement('img');
    im.src = viewUrl(p);
    body.appendChild(im);
  } else if (PREV_VID.includes(ext)) {
    const v = document.createElement('video');
    v.src = viewUrl(p); v.controls = true; v.autoplay = true;
    v.playsInline = true;
    body.appendChild(v);
  } else if (PREV_AUD.includes(ext)) {
    const a = document.createElement('audio');
    a.src = viewUrl(p); a.controls = true; a.autoplay = true;
    body.appendChild(a);
  } else if (ext === 'pdf') {
    const f = document.createElement('iframe');
    f.src = viewUrl(p);
    body.appendChild(f);
  } else if (PREV_TXT.includes(ext)) {
    const pre = document.createElement('pre');
    pre.textContent = 'loading…';
    body.appendChild(pre);
    fetch(viewUrl(p)).then(r => r.text())
      .then(t => { pre.textContent = t.slice(0, 200000); })
      .catch(() => { pre.textContent = '⚠ could not load the file'; });
  } else {
    const s = document.createElement('div');
    s.className = 'sys';
    s.textContent = 'No preview for .' + ext + ' — use Download.';
    body.appendChild(s);
  }
  document.getElementById('prevov').classList.add('show');
}
function openPreviewData(dataUrl, name) {
  const body = document.getElementById('prevbody');
  body.innerHTML = '';
  document.getElementById('prevtitle').textContent = '👁 ' + (name || 'image');
  document.getElementById('prevdl').style.display = 'none';
  const im = document.createElement('img');
  im.src = dataUrl;
  body.appendChild(im);
  document.getElementById('prevov').classList.add('show');
}
function closePreview() {
  const body = document.getElementById('prevbody');
  /* stop any playing video/audio */
  body.querySelectorAll('video,audio').forEach(m => { try { m.pause(); } catch(e){} });
  body.innerHTML = '';
  document.getElementById('prevov').classList.remove('show');
}
function openExamples() { document.getElementById('exov').classList.add('show'); }
function closeExamples() { document.getElementById('exov').classList.remove('show'); }
function runExample(t) { closeExamples(); const b = document.getElementById('box'); b.value = t; send(); }
async function api(path, body, method) {
  const r = await fetch(path, {
    method: method || (body ? 'POST' : 'GET'),
    headers: {'Content-Type': 'application/json', 'X-Wivexx-Pin': pin()},
    body: body ? JSON.stringify(body) : undefined });
  if (r.status === 401) { showPin('Wrong PIN'); throw new Error('unauthorized'); }
  return r.json();
}

/* ── live chat feed ─────────────────────────────────────────────── */
async function poll() {
  try {
    const res = await api('/api/chat?since=' + lastSeq);
    if (res.entries) for (const e of res.entries) {
      addRaw(e.role === 'you' ? 'you' : 'wivexx', e.text, e.atts);
      if (e.role === 'wivexx' && !firstChatLoad) speakAloud(e.text);
      lastSeq = e.i;
    }
    firstChatLoad = false;
    if (res.state && res.state !== state) {
      state = res.state;
      document.getElementById('statebar').textContent = '● ' + state;
    }
    if (res.monitors) monitors = res.monitors;
  } catch (e) {}
}
setInterval(poll, 2000);

async function send() {
  const text = box.value.trim();
  if (!text) return;
  box.value = '';
  try { await api('/api/message', {text}); poll(); }
  catch (e) { addRaw('sys', '⚠ connection problem — is Wivexx running?'); }
}
/* starter chip: the chip's visible (i18n-translated) text IS the command */
function sendChip(b) { box.value = b.textContent.trim(); send(); }
box.addEventListener('keydown', e => { if (e.key === 'Enter') send(); });

/* ── attachments ────────────────────────────────────────────────── */
/* v1.13: keep File objects (no base64 in memory) — big videos stream
   straight to the PC with live progress. */
function pickFile(inp) {
  const list = Array.from(inp.files || []);
  if (!list.length) return;
  if (!attach) attach = [];
  for (const f of list) {
    const item = {file: f, name: f.name,
                  isImage: f.type.startsWith('image/'), dataUrl: null};
    attach.push(item);
    if (item.isImage && f.size < 26214400) {     /* preview ≤25 MB images */
      const rd = new FileReader();
      rd.onload = () => { item.dataUrl = rd.result; showAttachBar(); };
      rd.readAsDataURL(f);
    }
  }
  showAttachBar();
  inp.value = '';
}
function showAttachBar() {
  if (!attach || !attach.length) return;
  const bar = document.getElementById('attachbar');
  const im  = document.getElementById('athumb');
  const firstImg = attach.find(a => a.isImage && a.dataUrl);
  if (firstImg) { im.src = firstImg.dataUrl; im.style.display = ''; }
  else im.style.display = 'none';
  const names = attach.map(a => a.name);
  const mb = attach.reduce((s, a) => s + a.file.size, 0) / 1048576;
  document.getElementById('aname').textContent =
    (attach.length === 1 ? names[0]
     : attach.length + ' files: ' + names.slice(0, 3).join(', ') +
       (names.length > 3 ? '…' : '')) + '  (' + mb.toFixed(1) + ' MB)';
  bar.style.display = 'flex';
}
function clearAttach() {
  attach = null;
  document.getElementById('attachbar').style.display = 'none';
}

/* ── v1.13: upload progress bar ─────────────────────────────────── */
function upShow(name) {
  document.getElementById('upwrap').style.display = 'block';
  document.getElementById('upname').textContent = name;
  upSet(0);
  document.getElementById('upfill').classList.remove('done');
}
function upSet(pct) {
  document.getElementById('upfill').style.width = pct + '%';
  document.getElementById('uppct').textContent = Math.round(pct) + '%';
}
function upDone(label) {
  upSet(100);
  document.getElementById('upfill').classList.add('done');
  document.getElementById('upname').textContent = label;
  document.getElementById('uppct').textContent = '✓';
  setTimeout(() => {
    document.getElementById('upwrap').style.display = 'none';
  }, 2000);
}
function upFail() {
  document.getElementById('upname').textContent = '⚠ upload failed';
  setTimeout(() => {
    document.getElementById('upwrap').style.display = 'none';
  }, 3000);
}

/* one XHR with progress events (fetch can't report upload progress) */
function xhrSend(method, url, body, headers, onProgress) {
  return new Promise((resolve, reject) => {
    const x = new XMLHttpRequest();
    x.open(method, url);
    for (const k in (headers || {})) x.setRequestHeader(k, headers[k]);
    x.upload.onprogress = ev => {
      if (ev.lengthComputable && onProgress) onProgress(ev.loaded, ev.total);
    };
    x.onload = () => (x.status >= 200 && x.status < 300)
        ? resolve(x.responseText) : reject(new Error('HTTP ' + x.status));
    x.onerror = () => reject(new Error('network'));
    x.send(body);
  });
}

async function sendUpload(mode) {
  if (!attach || !attach.length) return;
  const items = attach.slice();
  const text = box.value.trim();
  box.value = '';
  clearAttach();

  if (mode === 'save') {
    /* v1.13: stream each file raw — no base64, no size ceiling */
    const total = items.reduce((s, a) => s + a.file.size, 0);
    let sent = 0;
    const cats = [];
    try {
      for (let i = 0; i < items.length; i++) {
        const a = items[i];
        upShow('⬆ ' + a.name + '  (' + (i + 1) + '/' + items.length + ')');
        const params = new URLSearchParams({
          name: a.name, pin: pin(),
          text: i === 0 ? text : '',
        });
        if (i === items.length - 1) {
          params.set('final', '1');
          params.set('total', String(items.length));
        }
        const base = sent;
        const resp = await xhrSend(
          'PUT', '/api/upload_raw?' + params.toString()
                 + (i === items.length - 1 && cats.length
                    ? '&cats=' + encodeURIComponent(
                        Array.from(new Set(cats)).join(', ')) : ''),
          a.file, {'X-Wivexx-Pin': pin()},
          (loaded) => upSet(Math.min(100, 100 * (base + loaded) / total)));
        try { cats.push(JSON.parse(resp).cat || ''); } catch (e) {}
        sent += a.file.size;
      }
      upDone('✓ saved ' + items.length + ' file' +
             (items.length > 1 ? 's' : '') + ' to the PC');
      poll();
    } catch (e) { upFail(); addRaw('sys', '⚠ upload failed — ' + e.message); }
    return;
  }

  /* mode === 'ask' — Claude can read images/PDFs/text (kept as JSON) */
  const mb = items.reduce((s, a) => s + a.file.size, 0) / 1048576;
  if (mb > 200) {
    addRaw('sys', '⚠ that is ' + mb.toFixed(0) + ' MB — too big to analyse. ' +
                  'Use 💾 Save to PC for big videos.');
    return;
  }
  upShow('⬆ uploading for analysis…');
  try {
    const files = [];
    for (const a of items) {
      const b64 = await new Promise((res, rej) => {
        const rd = new FileReader();
        rd.onload = () => res(rd.result.split(',')[1]);
        rd.onerror = rej;
        rd.readAsDataURL(a.file);
      });
      files.push({name: a.name, data_b64: b64});
    }
    await xhrSend('POST', '/api/upload',
                  JSON.stringify({files: files, text: text, mode: 'ask'}),
                  {'Content-Type': 'application/json', 'X-Wivexx-Pin': pin()},
                  (l, t) => upSet(100 * l / t));
    upDone('✓ sent — Wivexx is analysing');
    poll();
  } catch (e) { upFail(); addRaw('sys', '⚠ upload failed — ' + e.message); }
}

/* ── live screen + tap-to-click ─────────────────────────────────── */
function openScreen() {
  document.getElementById('screenov').classList.add('show');
  refreshScreen();
  scrTimer = setInterval(refreshScreen, 900);
}
function closeScreen() {
  document.getElementById('screenov').classList.remove('show');
  clearInterval(scrTimer);
}
function refreshScreen() {
  document.getElementById('scrimg').src =
    '/api/screen?monitor=' + curMon + '&pin=' + encodeURIComponent(pin()) +
    '&t=' + Date.now();
}
function nextMonitor() {
  curMon = curMon >= monitors ? 1 : curMon + 1;
  document.getElementById('monbtn').textContent = 'Monitor ' + curMon;
  refreshScreen();
}
document.getElementById('scrimg').addEventListener('click', async ev => {
  const img = ev.target;
  const fx = ev.offsetX / img.clientWidth;
  const fy = ev.offsetY / img.clientHeight;
  try { await api('/api/click', {monitor: curMon, fx: fx, fy: fy});
        setTimeout(refreshScreen, 250); } catch (e) {}
});

/* ── type from the phone keyboard into the focused PC field ──────── */
function toggleTypeBar() {
  const tb = document.getElementById('typebar');
  const show = tb.style.display === 'none';
  tb.style.display = show ? 'flex' : 'none';
  if (show) document.getElementById('typebox').focus();
}
async function sendType(withEnter) {
  const inp = document.getElementById('typebox');
  const text = inp.value;
  if (!text && !withEnter) return;
  inp.value = '';
  try {
    await api('/api/type', {text: text, enter: !!withEnter});
    setTimeout(refreshScreen, 250);
  } catch (e) {}
}
document.getElementById('typebox').addEventListener('keydown',
  e => { if (e.key === 'Enter') sendType(true); });

/* ── saved files browser ────────────────────────────────────────── */
async function openFiles() {
  document.getElementById('filesov').classList.add('show');
  const el = document.getElementById('files');
  el.innerHTML = '<div class="sys">loading…</div>';
  try {
    const res = await api('/api/files');
    el.innerHTML = '';
    const cats = Object.keys(res).sort();
    if (!cats.length) el.innerHTML = '<div class="sys">No saved files yet — attach something and tap “Save to PC”.</div>';
    for (const cat of cats) {
      const h = document.createElement('div');
      h.className = 'fcat'; h.textContent = cat;
      el.appendChild(h);
      for (const f of res[cat]) {
        const row = document.createElement('div');
        row.className = 'frow';
        /* v1.13: tap the name → in-app preview (image/video/pdf/text) */
        const name = document.createElement('span');
        name.style.cssText = 'flex:1;display:flex;justify-content:space-between;' +
                             'gap:10px;color:var(--fg);cursor:pointer;';
        const kb = f.s > 1048576 ? (f.s/1048576).toFixed(1)+' MB'
                                 : Math.max(1, Math.round(f.s/1024))+' KB';
        name.innerHTML = '<span></span><span class="sz"></span>';
        name.children[0].textContent = '👁 ' + f.n;
        name.children[1].textContent = kb;
        name.onclick = () => openPreview(f.p, f.n);
        const dl = document.createElement('a');
        dl.href = '/api/dl?p=' + encodeURIComponent(f.p) +
                  '&pin=' + encodeURIComponent(pin());
        dl.download = f.n;
        dl.textContent = '⬇';
        dl.style.cssText = 'color:var(--accent);text-decoration:none;' +
                           'font-size:15px;margin-left:6px;';
        const del = document.createElement('button');
        del.textContent = '🗑';
        del.style.cssText = 'background:none;border:none;color:#E0553A;font-size:15px;' +
                            'margin-left:10px;';
        del.onclick = (ev) => { ev.preventDefault(); deleteSaved(f.p, f.n); };
        row.appendChild(name);
        row.appendChild(dl);
        row.appendChild(del);
        el.appendChild(row);
      }
    }
  } catch (e) { el.innerHTML = '<div class="sys">⚠ could not load files</div>'; }
}
function closeFiles() { document.getElementById('filesov').classList.remove('show'); }

/* ── memories (v1.10) ───────────────────────────────────────────── */
let memEditId = null;
function openMemory() {
  document.getElementById('memov').classList.add('show');
  refreshMemory();
}
function closeMemory() {
  document.getElementById('memov').classList.remove('show');
  cancelMemEdit();
}
async function refreshMemory() {
  const el = document.getElementById('mlist');
  const q = document.getElementById('msearch').value.trim();
  try {
    const res = await api('/api/memory' + (q ? '?q=' + encodeURIComponent(q) : ''));
    el.innerHTML = '';
    if (!res.facts.length)
      el.innerHTML = '<div class="sys">No memories' + (q ? ' matching that' : '') + '.</div>';
    for (const f of res.facts) {
      const row = document.createElement('div');
      row.className = 'frow';
      const src = f.source === 'explicit' ? '★' : (f.source === 'manual' ? '✎' : '·');
      const span = document.createElement('span');
      span.style.cssText = 'flex:1;';
      span.textContent = src + ' [' + (f.importance || 5) + '] ' + f.key + ': ' + f.value;
      span.onclick = () => editMemory(f);
      const del = document.createElement('button');
      del.textContent = '🗑';
      del.style.cssText = 'background:none;border:none;color:#E0553A;font-size:14px;';
      del.onclick = async () => {
        if (confirm('Delete the memory "' + f.key + '"?')) {
          await api('/api/memory', {action: 'delete', id: f.id});
          refreshMemory();
        }
      };
      row.appendChild(span); row.appendChild(del);
      el.appendChild(row);
    }
    if (res.topics && res.topics.length && !q) {
      const h = document.createElement('div');
      h.className = 'fcat'; h.textContent = 'Topic summaries';
      el.appendChild(h);
      for (const t of res.topics) {
        const row = document.createElement('div');
        row.className = 'frow';
        const span = document.createElement('span');
        span.style.cssText = 'flex:1;color:var(--dim);';
        span.textContent = t.topic + ': ' + t.summary;
        const del = document.createElement('button');
        del.textContent = '🗑';
        del.style.cssText = 'background:none;border:none;color:#E0553A;font-size:14px;';
        del.onclick = async () => {
          await api('/api/memory', {action: 'delete_topic', id: t.id});
          refreshMemory();
        };
        row.appendChild(span); row.appendChild(del);
        el.appendChild(row);
      }
    }
  } catch (e) { el.innerHTML = '<div class="sys">⚠ could not load memories</div>'; }
}
function editMemory(f) {
  memEditId = f.id;
  document.getElementById('medit').style.display = 'flex';
  document.getElementById('mkey').value = f.key;
  document.getElementById('mval').value = f.value;
  document.getElementById('mimp').value = String(f.importance || 5);
}
function newMemory() {
  memEditId = null;
  document.getElementById('medit').style.display = 'flex';
  document.getElementById('mkey').value = '';
  document.getElementById('mval').value = '';
  document.getElementById('mimp').value = '5';
  document.getElementById('mkey').focus();
}
function cancelMemEdit() {
  memEditId = null;
  document.getElementById('medit').style.display = 'none';
}
async function saveMemory() {
  const key = document.getElementById('mkey').value.trim();
  const val = document.getElementById('mval').value.trim();
  const imp = parseInt(document.getElementById('mimp').value, 10) || 5;
  if (!key || !val) return;
  const body = memEditId === null
    ? {action: 'add', key: key, value: val, importance: imp}
    : {action: 'update', id: memEditId, key: key, value: val, importance: imp};
  try {
    const res = await api('/api/memory', body);
    if (!res.ok) { alert('Save failed (duplicate key?)'); return; }
    cancelMemEdit();
    refreshMemory();
  } catch (e) {}
}

/* ── goals (v1.9) ───────────────────────────────────────────────── */
async function openGoals() {
  document.getElementById('goalsov').classList.add('show');
  refreshGoals();
}
function closeGoals() { document.getElementById('goalsov').classList.remove('show'); }
async function refreshGoals() {
  const el = document.getElementById('glist');
  try {
    const res = await api('/api/goals');
    const today = new Date().toISOString().slice(0, 10);
    document.getElementById('gstats').textContent =
      res.stats.open + ' open · ' + res.stats.done_week + ' done this week · 🔥' +
      res.stats.streak;
    el.innerHTML = '';
    if (!res.goals.length) {
      el.innerHTML = '<div class="sys">No open goals — add one below 🎯</div>';
      return;
    }
    for (const g of res.goals) {
      const row = document.createElement('div');
      row.className = 'frow';
      const chk = document.createElement('button');
      chk.textContent = '○';
      chk.style.cssText = 'background:none;border:none;color:var(--accent);' +
                          'font-size:17px;margin-right:8px;';
      chk.onclick = async () => {
        await api('/api/goals', {action: 'complete', id: g.id});
        refreshGoals();
      };
      const span = document.createElement('span');
      span.style.cssText = 'flex:1;';
      let txt = g.title;
      if (g.due) txt += (g.due < today ? '  ‼ overdue' :
                         (g.due === today ? '  · due today' : '  · due ' + g.due));
      if (g.priority === 'high') txt = '★ ' + txt;
      span.textContent = txt;
      if (g.due && g.due < today) span.style.color = '#ff6644';
      const del = document.createElement('button');
      del.textContent = '🗑';
      del.style.cssText = 'background:none;border:none;color:#E0553A;font-size:14px;';
      del.onclick = async () => {
        if (confirm('Delete goal "' + g.title + '"?')) {
          await api('/api/goals', {action: 'delete', id: g.id});
          refreshGoals();
        }
      };
      row.appendChild(chk); row.appendChild(span); row.appendChild(del);
      el.appendChild(row);
    }
  } catch (e) { el.innerHTML = '<div class="sys">⚠ could not load goals</div>'; }
}
async function addGoal() {
  const inp = document.getElementById('gnew');
  const title = inp.value.trim();
  if (!title) return;
  inp.value = '';
  try {
    await api('/api/goals', {action: 'add', title: title,
                             priority: document.getElementById('gprio').value});
    refreshGoals();
  } catch (e) {}
}
document.getElementById('gnew').addEventListener('keydown',
  e => { if (e.key === 'Enter') addGoal(); });
async function deleteSaved(relPath, name) {
  if (!confirm('Delete "' + name + '" from the PC? This cannot be undone.')) return;
  try {
    const res = await api('/api/delete', {p: relPath});
    if (res.ok) openFiles();
    else alert('Could not delete that file.');
  } catch (e) { alert('Delete failed.'); }
}

/* ── the DOT (Wivexx mascot — mini port of the desktop renderer) ──── */
const cv = document.getElementById('sphere');
const cx2d = cv.getContext('2d');
const DOT_VOLT = [193,247,58];
const STATE_COLORS = {standby:[130,160,60], idle:[130,160,60], listening:[193,247,58],
  active:[180,235,70], speaking:[210,250,110], processing:[150,170,90],
  searching:[193,247,58], mic_muted:[224,85,58], audio_muted:[224,160,48]};
function dimv(rgb, a) { return 'rgba('+rgb[0]+','+rgb[1]+','+rgb[2]+','+a+')'; }
function dotEl(x, y, a, b, fill) {
  cx2d.fillStyle = fill; cx2d.beginPath();
  cx2d.ellipse(x, y, a, b, 0, 0, Math.PI*2); cx2d.fill();
}
let dPhase=0, dGlance=0, dGlanceTgt=0, dNextGlance=0, dBlinkUntil=0, dNextBlink=0,
    dRings=[], dLastRing=0, dPrev=performance.now()/1000;
function drawDot() {
  const dpr = devicePixelRatio || 1;
  const W = cv.clientWidth, H = cv.clientHeight || 110;
  cv.width = W * dpr; cv.height = H * dpr;
  cx2d.setTransform(dpr, 0, 0, dpr, 0, 0);
  cx2d.clearRect(0, 0, W, H);
  // While the phone reads a reply aloud, drive the DOT like the desktop
  // 'speaking' state so the animation + colour match the voice on the phone.
  const eff = webSpeaking ? 'speaking' : state;
  const rgb = STATE_COLORS[eff] || DOT_VOLT;
  const main = 'rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';
  const now = performance.now()/1000;
  const dt = Math.min(0.05, now - dPrev); dPrev = now;
  if (dNextBlink === 0) { dNextBlink = now + 3; dNextGlance = now + 2.5; }

  const cx = W/2, cy = H/2, baseR = H * 0.28;
  dPhase += 0.045;
  const breathe = Math.sin(dPhase), bob = breathe*baseR*0.10, squash = breathe*0.06;
  let pulse=0, perk=0, wob=0;
  if (eff === 'speaking') pulse = 0.18*Math.abs(Math.sin(now*6)) + 0.12;
  if (eff === 'listening') perk = 0.14;
  if (eff === 'processing' || eff === 'searching') wob = Math.sin(now*4)*baseR*0.12;

  const calm = (eff==='idle' || eff==='standby' || eff==='active');
  if (calm) {
    if (now >= dNextGlance) { dGlanceTgt = [-1,0,1][Math.floor(Math.random()*3)]*baseR*0.18; dNextGlance = now + 2 + Math.random()*2.5; }
    if (now >= dNextBlink) { dBlinkUntil = now + 0.12; dNextBlink = now + 2.5 + Math.random()*3.5; }
  }
  dGlance += (dGlanceTgt - dGlance) * 0.12;

  const r = baseR*(1+pulse+perk), rx = r*(1+squash), ry = r*(1-squash);
  const dx = cx + dGlance + wob, dy = cy + bob;

  const gy = cy + baseR*1.05;
  dotEl(dx, gy, r*1.4, r*0.26, dimv(rgb, 0.05));
  dotEl(dx, gy, r*1.0, r*0.18, dimv(rgb, 0.09));

  if (['listening','speaking','processing','searching'].includes(eff)) {
    const intv = eff==='speaking' ? 0.5 : eff==='listening' ? 0.8 : 1.4;
    if (now - dLastRing > intv && dRings.length < 5) { dRings.push({r:r*1.1, a:0.5}); dLastRing = now; }
  }
  dRings.forEach(g => { g.r += baseR*0.9*dt; g.a -= 0.5*dt; });
  dRings = dRings.filter(g => g.a > 0);
  dRings.forEach(g => { cx2d.strokeStyle = dimv(rgb, Math.max(0, g.a)); cx2d.lineWidth = 2;
    cx2d.beginPath(); cx2d.ellipse(cx, cy, g.r, g.r*0.9, 0, 0, 7); cx2d.stroke(); });

  dotEl(dx, dy, rx*1.5, ry*1.5, dimv(rgb, 0.07));
  dotEl(dx, dy, rx*1.2, ry*1.2, dimv(rgb, 0.13));
  // glossy candy body — a radial gloss from the upper-left highlight, matching the
  // wivexx.com WivexxOrb (light highlight -> state colour -> darker candy edge, no
  // black rim). Keeps the per-state colour so muted/thinking still read.
  const _mw = (f) => 'rgb('+Math.round(rgb[0]+(255-rgb[0])*f)+','+Math.round(rgb[1]+(255-rgb[1])*f)+','+Math.round(rgb[2]+(255-rgb[2])*f)+')';
  const _ml = (f) => 'rgb('+Math.round(rgb[0]*f)+','+Math.round(rgb[1]*f)+','+Math.round(rgb[2]*f)+')';
  const bg = cx2d.createRadialGradient(dx - rx*0.30, dy - ry*0.38, r*0.05, dx, dy, r*1.12);
  bg.addColorStop(0, _mw(0.85)); bg.addColorStop(0.2, _mw(0.32)); bg.addColorStop(0.55, main);
  bg.addColorStop(0.86, _ml(0.90)); bg.addColorStop(1, _ml(0.72));
  cx2d.fillStyle = bg; cx2d.beginPath(); cx2d.ellipse(dx, dy, rx, ry, 0, 0, Math.PI*2); cx2d.fill();

  if (now >= dBlinkUntil) {
    dotEl(dx + dGlance*1.4 - rx*0.28, dy - ry*0.32, rx*0.24, rx*0.24, 'rgba(255,255,255,0.95)');
  } else {
    cx2d.fillStyle = '#0D0D0D'; cx2d.fillRect(dx-rx, dy-ry*0.14, rx*2, ry*0.28);
  }

  if (eff === 'processing' || eff === 'searching') {
    const ox = dx + Math.cos(now*3)*r*1.35, oy = dy + Math.sin(now*3)*r*0.5, sr = Math.max(2, baseR*0.13);
    dotEl(ox, oy, sr, sr, main);
  }
  requestAnimationFrame(drawDot);
}
requestAnimationFrame(drawDot);

/* ── PIN flow ───────────────────────────────────────────────────── */
function showPin(err) {
  document.getElementById('pinbox').classList.remove('hidden');
  document.getElementById('pinerr').textContent = err || '';
}
async function tryPin() {
  const entered = document.getElementById('pin').value.trim();
  if (!entered) { showPin('Type the PIN first'); return; }
  localStorage.setItem('wivexx_pin', entered);
  try {
    const res = await api('/api/ping');
    if (res.ok) {
      document.getElementById('pinbox').classList.add('hidden');
      monitors = res.monitors || 1;
      poll();
    }
  } catch (e) {
    if (e.message !== 'unauthorized')
      showPin('Cannot reach Wivexx — is it running on the PC?');
  }
}
document.getElementById('pin').addEventListener('keydown',
  e => { if (e.key === 'Enter') tryPin(); });
updateMuteBtn();
// Some browsers populate speechSynthesis voices asynchronously
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => {};
(async () => {
  try { const res = await api('/api/ping');
        if (res.ok) {
          document.getElementById('pinbox').classList.add('hidden');
          monitors = res.monitors || 1;
          poll();
        }
  } catch (e) {
    if (e.message !== 'unauthorized')
      showPin('Cannot reach Wivexx — is it running on the PC?');
  }
})();
</script>
<script>window.__WVX_LANG="__WVX_LANG__";</script><script src="/icons.js"></script><script src="/i18n.js"></script></body></html>"""


# ══════════════════════════════════════════════════════════════════════════════
#  HTTP handler
# ══════════════════════════════════════════════════════════════════════════════
class _Handler(BaseHTTPRequestHandler):
    server_version = "Wivexx/1.18"

    # ── helpers ────────────────────────────────────────────────────────────────
    def _send(self, code: int, body: bytes, ctype: str,
              extra: Optional[dict] = None) -> None:
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        self.wfile.write(body)

    def _json(self, code: int, obj: dict) -> None:
        self._send(code, json.dumps(obj).encode("utf-8"),
                   "application/json; charset=utf-8")

    def _query(self) -> dict:
        try:
            return {k: v[0] for k, v in urllib.parse.parse_qs(
                urllib.parse.urlparse(self.path).query).items()}
        except Exception:
            return {}

    def _authed(self, allow_query_pin: bool = False) -> bool:
        if not _pin:
            return True
        sent = (self.headers.get("X-Wivexx-Pin", "") or "").strip()
        if not sent and allow_query_pin:
            sent = (self._query().get("pin", "") or "").strip()
        if sent == _pin.strip():
            _touch_client(self.client_address[0])
            return True
        if _logger:
            _logger.info("web: PIN rejected from %s (sent %d chars, expected %d)",
                         self.client_address[0], len(sent), len(_pin.strip()))
        return False

    def log_message(self, fmt, *args):       # quiet console; use wivexx logger
        if _logger:
            _logger.debug("web: " + fmt, *args)

    def _read_json(self, cap: int = 64_000) -> Optional[dict]:
        try:
            length = int(self.headers.get("Content-Length", 0))
            if length <= 0 or length > cap:
                return None
            return json.loads(self.rfile.read(length).decode("utf-8"))
        except Exception:
            return None

    # ── GET routes ─────────────────────────────────────────────────────────────
    def do_GET(self):
        path = urllib.parse.urlparse(self.path).path
        if path in ("/", "/index.html"):
            html = _PAGE
            try:                              # serve the page in the PC's language
                import wivexx_i18n as _i18n
                html = html.replace("__WVX_LANG__", _i18n.current_language())
            except Exception:
                html = html.replace("__WVX_LANG__", "en")
            self._send(200, html.encode("utf-8"), "text/html; charset=utf-8")
            return

        if path in ("/favicon.ico", "/icon.png"):
            # Prefer the Wivexx DOT icon; fall back to the old wivexx.* files.
            _ext = ".ico" if path == "/favicon.ico" else ".png"
            _dir = os.path.dirname(os.path.abspath(__file__))
            fname = "wivexx" + _ext
            if not os.path.exists(os.path.join(_dir, fname)):
                fname = "jarvis" + _ext
            full = os.path.join(_dir, fname)
            try:
                with open(full, "rb") as f:
                    self._send(200, f.read(),
                               "image/x-icon" if fname.endswith(".ico") else "image/png",
                               {"Cache-Control": "max-age=86400"})
            except OSError:
                self._json(404, {"error": "no icon"})
            return

        if path == "/icons.js":
            # the shared brand line-icon set (+ auto-iconize transformer) so the phone
            # page swaps emojis for on-brand icons exactly like the control room
            _dir = os.path.dirname(os.path.abspath(__file__))
            try:
                with open(os.path.join(_dir, "ui", "icons.js"), "rb") as f:
                    self._send(200, f.read(), "application/javascript; charset=utf-8",
                               {"Cache-Control": "max-age=3600"})
            except OSError:
                self._send(404, b"", "application/javascript")
            return

        if path == "/i18n.js":
            # the shared UI translator so the phone page localizes to the PC's language
            _dir = os.path.dirname(os.path.abspath(__file__))
            try:
                with open(os.path.join(_dir, "ui", "i18n.js"), "rb") as f:
                    self._send(200, f.read(), "application/javascript; charset=utf-8",
                               {"Cache-Control": "max-age=3600"})
            except OSError:
                self._send(404, b"", "application/javascript")
            return

        if path == "/api/ping":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            mons = 1
            try:
                if _callbacks.get("monitors"):
                    mons = int(_callbacks["monitors"]())
            except Exception:
                pass
            self._json(200, {"ok": True, "name": "Wivexx", "monitors": mons})
            return

        if path == "/api/chat":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            try:
                since = int(self._query().get("since", "0"))
            except ValueError:
                since = 0
            with _chat_lock:
                entries = [e for e in _chat if e["i"] > since]
            state = "standby"
            mons = 1
            try:
                if _callbacks.get("get_state"):
                    state = str(_callbacks["get_state"]())
                if _callbacks.get("monitors"):
                    mons = int(_callbacks["monitors"]())
            except Exception:
                pass
            self._json(200, {"entries": entries, "state": state, "monitors": mons})
            return

        if path == "/api/screen":
            if not self._authed(allow_query_pin=True):
                self._json(401, {"ok": False})
                return
            shot_fn = _callbacks.get("screenshot")
            if shot_fn is None:
                self._json(503, {"error": "screen preview unavailable"})
                return
            try:
                mon = int(self._query().get("monitor", "1"))
            except ValueError:
                mon = 1
            data = None
            try:
                data = shot_fn(mon)
            except Exception as e:
                if _logger:
                    _logger.warning("web: screenshot failed: %s", e)
            if not data:
                self._json(500, {"error": "screenshot failed"})
                return
            self._send(200, data, "image/jpeg")
            return

        if path == "/api/files":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            self._json(200, _list_saved())
            return

        if path == "/api/goals":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            goals = _callbacks.get("goals")
            if goals is None:
                self._json(503, {"error": "goals unavailable"})
                return
            try:
                self._json(200, {"goals": goals.list_goals(),
                                 "stats": goals.stats()})
            except Exception as e:
                if _logger:
                    _logger.warning("web: goals list failed: %s", e)
                self._json(500, {"error": "goals failed"})
            return

        if path == "/api/memory":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            mem = _callbacks.get("memory")
            if mem is None:
                self._json(503, {"error": "memory unavailable"})
                return
            try:
                search = self._query().get("q", "")
                self._json(200, {"facts": mem.list_facts(search),
                                 "topics": mem.list_topics()})
            except Exception as e:
                if _logger:
                    _logger.warning("web: memory list failed: %s", e)
                self._json(500, {"error": "memory failed"})
            return

        if path == "/api/dl":
            if not self._authed(allow_query_pin=True):
                self._json(401, {"ok": False})
                return
            full = _resolve_saved_path(self._query().get("p", ""))
            if full is None:
                self._json(404, {"error": "not found"})
                return
            try:
                with open(full, "rb") as f:
                    data = f.read()
                fname = os.path.basename(full)
                self._send(200, data, "application/octet-stream",
                           {"Content-Disposition": f'attachment; filename="{fname}"'})
            except Exception:
                self._json(500, {"error": "read failed"})
            return

        # v1.13: inline preview — correct Content-Type, streamed in chunks,
        # with HTTP Range support so <video> can seek.
        if path == "/api/view":
            if not self._authed(allow_query_pin=True):
                self._json(401, {"ok": False})
                return
            full = _resolve_saved_path(self._query().get("p", ""))
            if full is None:
                self._json(404, {"error": "not found"})
                return
            self._stream_file(full)
            return

        self._json(404, {"error": "not found"})

    def _stream_file(self, full: str) -> None:
        """Stream a file inline (chunked, Range-aware) — never loads a 2 GB
        video into RAM."""
        ext = os.path.splitext(full)[1].lower()
        ctype = _VIEW_TYPES.get(ext, "application/octet-stream")
        try:
            size = os.path.getsize(full)
            start, end = 0, size - 1
            rng = (self.headers.get("Range") or "").strip()
            m = re.match(r"bytes=(\d*)-(\d*)$", rng)
            partial = False
            if m and (m.group(1) or m.group(2)):
                if m.group(1):
                    start = int(m.group(1))
                    if m.group(2):
                        end = min(int(m.group(2)), size - 1)
                else:                          # suffix range: bytes=-N
                    start = max(0, size - int(m.group(2)))
                if start > end or start >= size:
                    self.send_response(416)
                    self.send_header("Content-Range", f"bytes */{size}")
                    self.end_headers()
                    return
                partial = True
            length = end - start + 1
            self.send_response(206 if partial else 200)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(length))
            self.send_header("Accept-Ranges", "bytes")
            self.send_header("Content-Disposition",
                             f'inline; filename="{os.path.basename(full)}"')
            if partial:
                self.send_header("Content-Range",
                                 f"bytes {start}-{end}/{size}")
            self.end_headers()
            with open(full, "rb") as f:
                f.seek(start)
                remaining = length
                while remaining > 0:
                    chunk = f.read(min(1024 * 1024, remaining))
                    if not chunk:
                        break
                    self.wfile.write(chunk)
                    remaining -= len(chunk)
        except (ConnectionAbortedError, ConnectionResetError, BrokenPipeError):
            pass                                # viewer closed / seeked away
        except Exception as e:
            if _logger:
                _logger.debug("web: stream failed: %s", e)

    # ── PUT: v1.13 streaming upload (raw binary body, any size) ───────────────
    def do_PUT(self):
        path = urllib.parse.urlparse(self.path).path
        if path != "/api/upload_raw":
            self._json(404, {"error": "not found"})
            return
        if not self._authed(allow_query_pin=True):
            self._json(401, {"ok": False})
            return
        q = self._query()
        fname = _safe_name(q.get("name", "file"))
        try:
            length = int(self.headers.get("Content-Length", 0))
        except ValueError:
            length = 0
        if length <= 0 or length > _MAX_RAW_UPLOAD:
            self._json(413, {"error": "missing or oversized body"})
            return

        # stream straight to the categorized saved-files tree — 1 MB chunks,
        # so a 2 GB video never sits in RAM
        cat = _categorize(fname)
        folder = os.path.join(_saved_dir, cat)
        target = None
        try:
            os.makedirs(folder, exist_ok=True)
            target = os.path.join(folder, fname)
            if os.path.exists(target):
                base, ext = os.path.splitext(fname)
                n = 1
                while os.path.exists(target):
                    target = os.path.join(folder, f"{base}_{n}{ext}")
                    n += 1
            remaining = length
            with open(target, "wb") as out:
                while remaining > 0:
                    chunk = self.rfile.read(min(1024 * 1024, remaining))
                    if not chunk:
                        raise ConnectionError("client stopped sending")
                    out.write(chunk)
                    remaining -= len(chunk)
        except Exception as e:
            if _logger:
                _logger.error("web: raw upload failed (%s): %s", fname, e)
            try:
                if target and os.path.exists(target):
                    os.remove(target)           # never keep half a file
            except Exception:
                pass
            self._json(500, {"error": "upload failed"})
            return

        rel = os.path.relpath(target, _saved_dir).replace(os.sep, "/")
        if _logger:
            _logger.info("web: saved upload %s (%.1f MB) → %s",
                         fname, length / 1048576, rel)

        # chat-feed entry with a real thumbnail (images) or a preview chip
        att = {"name": os.path.basename(target), "kind": "file",
               "thumb": None, "p": rel}
        ext = os.path.splitext(target)[1].lower()
        if ext in (".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp"):
            att["kind"] = "video"
        try:
            meta_fn = _callbacks.get("att_meta")
            if (meta_fn is not None and length <= _THUMB_READBACK_MAX
                    and _categorize(fname) == "Photos"):
                with open(target, "rb") as f:
                    metas = meta_fn([(att["name"], f.read())])
                if metas:
                    att.update({k: metas[0][k] for k in ("kind", "thumb")})
        except Exception:
            pass
        text = (q.get("text") or "").strip()[:2000]
        record_chat("you", text, [att])
        if q.get("final") == "1":
            total = max(1, int(q.get("total", "1") or 1))
            cats = q.get("cats") or cat
            record_chat("wivexx",
                        f"Saved {total} file{'s' if total != 1 else ''} to "
                        f"the PC, sir — filed under {cats}.")
        self._json(200, {"ok": True, "rel": rel, "cat": cat})

    # ── POST routes ────────────────────────────────────────────────────────────
    def do_POST(self):
        path = urllib.parse.urlparse(self.path).path

        if path == "/api/message":
            if not self._authed():
                self._json(401, {"ok": False, "error": "bad pin"})
                return
            data = self._read_json()
            text = str((data or {}).get("text", "")).strip()[:2000]
            if not text:
                self._json(400, {"error": "empty"})
                return
            if _logger:
                _logger.info("web: remote message: %r", text)
            try:
                handler = _callbacks.get("handle_message")
                reply = handler(text) if handler else "Not ready."
            except Exception as e:
                if _logger:
                    _logger.error("web: handler failed: %s", e)
                reply = "I encountered an error processing that, sir."
            self._json(200, {"reply": reply})
            return

        if path == "/api/click":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            click_fn = _callbacks.get("click")
            data = self._read_json()
            if click_fn is None or data is None:
                self._json(503, {"error": "unavailable"})
                return
            try:
                ok = bool(click_fn(int(data.get("monitor", 1)),
                                   float(data.get("fx", 0.5)),
                                   float(data.get("fy", 0.5))))
            except Exception as e:
                if _logger:
                    _logger.warning("web: click failed: %s", e)
                ok = False
            self._json(200, {"ok": ok})
            return

        if path == "/api/type":
            # v1.8.1: type from the phone keyboard into the focused PC field
            if not self._authed():
                self._json(401, {"ok": False})
                return
            type_fn = _callbacks.get("type_text")
            data = self._read_json()
            if type_fn is None or data is None:
                self._json(503, {"error": "unavailable"})
                return
            text = str(data.get("text", ""))[:5000]
            press_enter = bool(data.get("enter", False))
            try:
                ok = bool(type_fn(text, press_enter))
            except Exception as e:
                if _logger:
                    _logger.warning("web: type failed: %s", e)
                ok = False
            self._json(200, {"ok": ok})
            return

        if path == "/api/goals":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            goals = _callbacks.get("goals")
            data = self._read_json()
            if goals is None or data is None:
                self._json(503, {"error": "unavailable"})
                return
            action = str(data.get("action", ""))
            try:
                if action == "add":
                    g = goals.add_goal(str(data.get("title", "")),
                                       priority=str(data.get("priority", "normal")),
                                       due=data.get("due") or None,
                                       reminder=str(data.get("reminder", "none")),
                                       remind_time=str(data.get("remind_time", "09:00")))
                    self._json(200, {"ok": True, "goal": g})
                elif action == "complete":
                    self._json(200, {"ok": goals.complete_goal(int(data.get("id", 0)))})
                elif action == "reopen":
                    self._json(200, {"ok": goals.reopen_goal(int(data.get("id", 0)))})
                elif action == "delete":
                    self._json(200, {"ok": goals.delete_goal(int(data.get("id", 0)))})
                else:
                    self._json(400, {"error": "unknown action"})
            except Exception as e:
                if _logger:
                    _logger.warning("web: goals action failed: %s", e)
                self._json(500, {"ok": False})
            return

        if path == "/api/memory":
            # v1.10: edit WIVEXX's memories from the phone
            if not self._authed():
                self._json(401, {"ok": False})
                return
            mem = _callbacks.get("memory")
            data = self._read_json()
            if mem is None or data is None:
                self._json(503, {"error": "unavailable"})
                return
            action = str(data.get("action", ""))
            try:
                if action == "add":
                    ok = mem.add_fact(str(data.get("key", "")),
                                      str(data.get("value", "")),
                                      importance=int(data.get("importance", 5)),
                                      source="manual")
                    self._json(200, {"ok": bool(ok)})
                elif action == "update":
                    ok = mem.update_fact(int(data.get("id", 0)),
                                         key=data.get("key"),
                                         value=data.get("value"),
                                         importance=data.get("importance"))
                    self._json(200, {"ok": bool(ok)})
                elif action == "delete":
                    self._json(200, {"ok": mem.delete_fact(int(data.get("id", 0)))})
                elif action == "delete_topic":
                    self._json(200, {"ok": mem.delete_topic(int(data.get("id", 0)))})
                else:
                    self._json(400, {"error": "unknown action"})
                if _logger and action in ("update", "delete", "add"):
                    _logger.info("web: memory %s from %s", action,
                                 self.client_address[0])
            except Exception as e:
                if _logger:
                    _logger.warning("web: memory action failed: %s", e)
                self._json(500, {"ok": False})
            return

        if path == "/api/delete":
            # v1.8.1: delete a saved file from the phone (traversal-protected)
            if not self._authed():
                self._json(401, {"ok": False})
                return
            data = self._read_json()
            full = _resolve_saved_path((data or {}).get("p", ""))
            if full is None:
                self._json(404, {"ok": False, "error": "not found"})
                return
            try:
                os.remove(full)
                if _logger:
                    _logger.info("web: deleted saved file %s", full)
                self._json(200, {"ok": True})
            except Exception as e:
                if _logger:
                    _logger.warning("web: delete failed: %s", e)
                self._json(500, {"ok": False, "error": "delete failed"})
            return

        if path == "/api/upload":
            if not self._authed():
                self._json(401, {"ok": False})
                return
            data = self._read_json(cap=_MAX_UPLOAD)
            if data is None:
                self._json(413, {"error": "too large or malformed"})
                return
            mode = str(data.get("mode", "ask"))
            text = str(data.get("text", "")).strip()[:2000]

            # v1.9: multiple files per message.  New form: {"files": [{name,
            # data_b64}, …]}; the old single-file fields still work.
            raw_files: list = []
            entries = data.get("files")
            if not isinstance(entries, list):
                entries = [{"name": data.get("filename", "file"),
                            "data_b64": data.get("data_b64", "")}]
            for ent in entries[:12]:                  # sanity cap per message
                try:
                    name = _safe_name(str(ent.get("name", "file")))
                    raw = b64decode(str(ent.get("data_b64", "")), validate=False)
                    if raw:
                        raw_files.append((name, raw))
                except Exception:
                    continue
            if not raw_files:
                self._json(400, {"error": "no readable files"})
                return
            names = ", ".join(n for n, _ in raw_files)

            if mode == "save":
                saved, failed = [], []
                cats = set()
                for name, raw in raw_files:
                    try:
                        rel = _save_uploaded(name, raw)
                        saved.append(name)
                        cats.add(rel.replace("/", os.sep).split(os.sep)[0])
                    except Exception as e:
                        if _logger:
                            _logger.error("web: save failed (%s): %s", name, e)
                        failed.append(name)
                if saved:
                    msg = (f"Saved {len(saved)} file{'s' if len(saved) != 1 else ''} "
                           f"to the PC, sir — filed under {', '.join(sorted(cats))}.")
                    if failed:
                        msg += f" ({len(failed)} failed: {', '.join(failed)})"
                else:
                    msg = "I couldn't save those files, sir."
                # v1.12: show the actual images/file chips in the feed
                atts = None
                try:
                    meta_fn = _callbacks.get("att_meta")
                    if meta_fn is not None:
                        atts = meta_fn(raw_files)
                except Exception:
                    atts = None
                record_chat("you", text or f"[sent {len(raw_files)} file(s)]",
                            atts)
                record_chat("wivexx", msg)
                self._json(200, {"reply": msg})
                return

            # mode == "ask" — images/PDFs/text handled by wivexx_api
            try:
                handler = _callbacks.get("handle_files")
                if handler is None:
                    msg = "File analysis isn't available right now, sir."
                else:
                    msg = handler(text, raw_files)
            except Exception as e:
                if _logger:
                    _logger.error("web: files handler failed: %s", e)
                msg = "I encountered an error analysing those files, sir."
            self._json(200, {"reply": msg})
            return

        self._json(404, {"error": "not found"})


def _local_ip() -> str:
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))
            return s.getsockname()[0]
    except Exception:
        return "127.0.0.1"


def start(callbacks: dict, cfg, logger=None) -> Optional[str]:
    """Start the web server in a daemon thread. Returns the primary URL."""
    global _callbacks, _pin, _logger, _saved_dir
    if not getattr(cfg, "WEB_ENABLED", True):
        return None
    _callbacks = dict(callbacks or {})
    _pin = str(getattr(cfg, "WEB_PIN", "") or "")
    _logger = logger
    _saved_dir = str(getattr(cfg, "WEB_SAVED_DIR", "") or cfg.default_saved_dir())
    # If the configured folder can't be created (e.g. it points at a drive that
    # doesn't exist on this PC), fall back to a portable per-user location so the
    # phone "Save to PC" feature still works instead of erroring.
    try:
        os.makedirs(_saved_dir, exist_ok=True)
    except Exception:
        _saved_dir = cfg.default_saved_dir()
        try:
            os.makedirs(_saved_dir, exist_ok=True)
        except Exception:
            pass
    port = int(getattr(cfg, "WEB_PORT", 7777))

    try:
        server = ThreadingHTTPServer(("0.0.0.0", port), _Handler)
    except OSError as e:
        if logger:
            logger.warning("web: could not bind port %d: %s", port, e)
        return None
    threading.Thread(target=server.serve_forever, daemon=True,
                     name="wivexx-web").start()

    host_url = f"http://{socket.gethostname().lower()}:{port}"
    ip_url   = f"http://{_local_ip()}:{port}"
    if logger:
        # IP first: phones can't resolve the Windows hostname, only the LAN IP.
        logger.info("web: phone interface at %s  (or %s on this PC)", ip_url, host_url)
    return ip_url


def get_urls(cfg) -> tuple:
    port = int(getattr(cfg, "WEB_PORT", 7777))
    return (f"http://{socket.gethostname().lower()}:{port}",
            f"http://{_local_ip()}:{port}")
