"""Viewer HTTP server: static files + /api/ask endpoint (graph-aware and AI answer).

Replaces plain `python -m http.server 8777`. Endpoints:
  GET  /viewer/*  /corpus/*            static files
  GET  /api/ask?q=<question>&mode=graph|ai
      mode=graph  -> {"results":[{node...,score,hop}...]}  keyword+neighbor BFS scoring
      mode=ai     -> {"answer": "...", "results":[...]}    BFS context -> claude-cli answer
"""
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.parse
from pathlib import Path
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

ROOT = Path(__file__).resolve().parent.parent          # C:\Projects\LINE_note
OUT = ROOT / "graphify-out"

# ---- graph data (loaded once) ----
G = json.loads((OUT / "graph.json").read_text(encoding="utf-8"))
NODES = {n["id"]: n for n in G["nodes"]}
NODE_LIST = G["nodes"]
LINKS = G["links"]
ADJ = {}
for e in LINKS:
    ADJ.setdefault(e["source"], []).append((e["target"], e.get("relation", "")))
    ADJ.setdefault(e["target"], []).append((e["source"], e.get("relation", "")))
LABELS = {}
lp = OUT / ".graphify_labels.json"
if lp.exists():
    LABELS = json.loads(lp.read_text(encoding="utf-8"))

STOP = {"the", "a", "an", "to", "of", "in", "for", "and", "or", "how", "what",
        "is", "are", "my", "i", "on", "with", "ที่", "และ", "หรือ", "เป็น", "อะไร",
        "ทำ", "อย่างไร", "คือ", "ใน", "ของ"}


def tokenize(q):
    return [t for t in re.findall(r"[a-z0-9]+|[\u0e00-\u0e7f]+", q.lower())
            if len(t) > 1 and t not in STOP]


def node_score(n, terms):
    label = (n.get("label") or "").lower()
    src = (n.get("source_file") or "").lower()
    auth = (n.get("author") or "").lower()
    hits = 0
    for t in terms:
        w = 0
        if t in label:
            w = 6 if label.startswith(t) else 5
        if t in src:
            w = max(w, 3)
        if t in auth:
            w = max(w, 2)
        if w:
            hits += 1
    if not hits:
        return 0, 0
    deg = len(ADJ.get(n["id"], []))
    direct = sum(6 if t in label else 3 for t in terms if t in label + src + auth)
    return direct + min(deg, 30) * 0.1 + hits * 2, 0


def seed_and_expand(terms, top=40, max_hops=2):
    """Score nodes by direct term match, then add neighbor context from BFS."""
    scored = []
    for n in NODE_LIST:
        s, hop = node_score(n, terms)
        if s > 0:
            scored.append((s, hop, n))
    scored.sort(key=lambda x: -x[0])
    seeds = [n for _, _, n in scored[:12]]
    out = [(s, hop, n["id"]) for s, hop, n in scored[:top]]
    seen = {nid for _, _, nid in out}
    # hop-1 neighbors of strong seeds: boost related concept nodes
    for n in seeds:
        for nb, rel in ADJ.get(n["id"], [])[:20]:
            if nb in seen or nb not in NODES:
                continue
            m = NODES[nb]
            s, _ = node_score(m, terms)
            if s > 0:
                out.append((s * 0.6, 1, nb))
                seen.add(nb)
    out.sort(key=lambda x: -x[0])
    return out[:top], seeds


def subgraph_context(terms, budget_chars=48000):
    """BFS context for LLM: seed docs + their neighbors + relation types."""
    top, seeds = seed_and_expand(terms, top=30)
    lines = []
    used = 0
    chosen = []
    for s, hop, nid in top:
        n = NODES[nid]
        chosen.append(nid)
        line = f"- [{n.get('file_type', '?')}] {n.get('label', '')}"
        if n.get("author"):
            line += f" (author: {n['author']})"
        if n.get("source_url"):
            line += f" URL: {n['source_url']}"
        lines.append(line)
        used += len(line) + 4
        if used > budget_chars:
            break
    # relations among chosen nodes
    ids = set(chosen)
    rels = []
    for e in LINKS:
        if e["source"] in ids and e["target"] in ids:
            rels.append(f"{NODES[e['source']].get('label','?')} --[{e.get('relation','?')}]--> {NODES[e['target']].get('label','?')}")
        if len(rels) >= 60:
            break
    return "\n".join(lines), rels, chosen


AI_PROMPT = """You answer questions about the user's saved knowledge corpus (LINE Note+ links: \
YouTube videos, Facebook posts, articles - Thai and English).

Question: {question}

Below are the most relevant nodes from the user's knowledge graph (documents and concepts), \
followed by relationships between them.

{context}

RELATIONSHIPS:
{rels}

Answer the question in {lang}. Ground your answer in the nodes above: name the specific \
videos/posts/concepts that matter and why. If the corpus contains little on the topic, say \
so plainly. Keep it under 250 words. Structure: short answer first, then supporting nodes.\
"""


def lang_of(q):
    if re.search(r"[\u0e00-\u0e7f]", q):
        return "Thai"
    return "the same language as the question"


def ask_ai(question, terms):
    context, rels, chosen = subgraph_context(terms)
    prompt = AI_PROMPT.format(question=question, context=context,
                              rels="\n".join(rels) or "(none)", lang=lang_of(question))
    claude = shutil.which("claude") or shutil.which("claude.exe")
    if not claude:
        return "AI backend not configured on this server — only works on the local machine.", chosen
    cmd = [claude, "-p", prompt,
           "--model", "sonnet", "--strict-mcp-config",
           "--max-turns", "1", "--output-format", "text"]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
                           cwd=str(ROOT), encoding="utf-8", errors="replace")
        answer = r.stdout.strip()
        if not answer:
            answer = "AI backend returned empty answer." + (r.stderr[-300:] if r.stderr else "")
    except subprocess.TimeoutExpired:
        answer = "AI backend timed out (180s). Try again."
    return answer, chosen


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=str(ROOT), **kw)

    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        if parsed.path == "/api/ask":
            qs = urllib.parse.parse_qs(parsed.query)
            q = (qs.get("q") or [""])[0]
            mode = (qs.get("mode") or ["graph"])[0]
            if not q.strip():
                self._json(400, {"error": "empty query"})
                return
            terms = tokenize(q)
            if not terms:
                self._json(400, {"error": "no usable terms"})
                return
            if mode == "ai":
                answer, chosen = ask_ai(q, terms)
                self._json(200, {"answer": answer, "ids": chosen})
                return
            top, seeds = seed_and_expand(terms)
            results = []
            for s, hop, nid in top:
                n = NODES[nid]
                results.append({
                    "id": nid, "label": n.get("label"), "score": round(s, 2),
                    "type": n.get("file_type"), "community": n.get("community"),
                    "author": n.get("author"), "url": n.get("source_url"),
                    "source_file": n.get("source_file"),
                    "degree": len(ADJ.get(nid, [])),
                    "neighbors": [NODES[nb]["label"] for nb, _ in ADJ.get(nid, [])[:6] if nb in NODES],
                })
            self._json(200, {"results": results})
            return
        super().do_GET()

    def _json(self, code, obj):
        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args))


if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8777
    host = "0.0.0.0" if os.environ.get("VIEWER_BIND_ALL") else "127.0.0.1"
    srv = ThreadingHTTPServer((host, port), Handler)
    print(f"viewer server on http://127.0.0.1:{port}", flush=True)
    srv.serve_forever()