"""Serve the ring studio: dashboard, review UI, 3D viewer, and the product API.

Run: .cad-venv/bin/python harness/serve.py [--port 8770]
  /viewer/design.html?name=<x>   THE customer page (bot sends this link):
                                 3D + notes + approve->production + email
  /viewer/review.html            internal review: all designs + comments
  /viewer/eval.html              judge dashboard
  /viewer/index.html?model=...   raw 3D viewer

API:
  GET  /api/design?name=x        spec + file map + notes for one design
  POST /api/comment              {design, text}        -> note saved
  POST /api/resolve              {id, note?}           -> note addressed
  POST /api/produce              {name}                -> production build (async)
  GET  /api/produce-status?name=x                      -> {status, result}
  POST /api/email                {name, to}            -> AgentMail the files
                                 needs AGENTMAIL_API_KEY (+ AGENTMAIL_INBOX)
                                 in .env / environment
"""

from __future__ import annotations

import argparse
import base64
import functools
import http.server
import json
import os
import socketserver
import subprocess
import threading
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PYBIN = str(ROOT / ".cad-venv" / "bin" / "python")
COMMENTS = ROOT / "artifacts" / "review" / "comments.json"
JOBS = ROOT / "artifacts" / "review" / "jobs.json"
_jobs_lock = threading.Lock()

# .env is the shop's config file (never committed)
for _line in (ROOT / ".env").read_text().splitlines() if (ROOT / ".env").exists() else []:
    if "=" in _line and not _line.strip().startswith("#"):
        _k, _, _v = _line.partition("=")
        os.environ.setdefault(_k.strip(), _v.strip())

import sys as _sys
_sys.path.insert(0, str(ROOT / "harness"))
from authtok import make_token, token_ok  # noqa: E402

if not os.environ.get("STUDIO_SECRET"):
    import secrets as _secrets
    _sec = _secrets.token_urlsafe(24)
    with open(ROOT / ".env", "a") as _f:
        _f.write(f"\n# studio link auth (server-generated)\nSTUDIO_SECRET={_sec}\n")
    os.environ["STUDIO_SECRET"] = _sec


def _load(path: Path, default: dict) -> dict:
    if path.exists():
        return json.loads(path.read_text())
    return default


def _save(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2))


def _design_files(name: str) -> dict:
    """Every artifact we can link for a design, concept + production."""
    out: dict = {}
    for base, tag in ((name, "concept"), (f"{name}-prod", "prod")):
        d = ROOT / "artifacts" / "cli" / base
        a = ROOT / "artifacts" / "authored" / base
        entry: dict = {}
        for root in (d, a):
            if (root / f"{base}.parts.glb").exists():   # colored, preferred
                entry.setdefault("glb", f"/{(root / f'{base}.parts.glb').relative_to(ROOT)}")
            if (root / f".{base}.step.glb").exists():
                entry.setdefault("glb", f"/{(root / f'.{base}.step.glb').relative_to(ROOT)}")
            if (root / f"{base}.png").exists():
                entry.setdefault("png", f"/{(root / f'{base}.png').relative_to(ROOT)}")
            if (root / f"{base}-head.png").exists():
                entry.setdefault("head_png", f"/{(root / f'{base}-head.png').relative_to(ROOT)}")
        for suffix, key in (("-workorder.html", "workorder"), ("-coupon.stl", "coupon"),
                            ("-cast.stl", "cast")):
            f = d / f"{base}{suffix}"
            if f.exists():
                entry[key] = f"/{f.relative_to(ROOT)}"
        prints = sorted(d.glob(f"{base}-print*.stl"))
        if prints:
            entry["print"] = f"/{prints[-1].relative_to(ROOT)}"
        if entry:
            out[tag] = entry
    gen = ROOT / "designs" / f"{name}.py"
    if gen.exists():
        for line in gen.read_text().splitlines():
            if line.startswith("SPEC = "):
                try:
                    out["spec"] = eval(line[7:], {"__builtins__": {}})  # literal dict
                except Exception:
                    pass
                break
    return out


def _produce(name: str) -> None:
    """Background production build: same spec + production flag, '-prod' name."""
    with _jobs_lock:
        jobs = _load(JOBS, {})
        jobs[name] = {"status": "building",
                      "started": datetime.now(timezone.utc).isoformat(timespec="seconds")}
        _save(JOBS, jobs)
    try:
        files = _design_files(name)
        spec = dict(files.get("spec") or {})
        if not spec:
            raise RuntimeError(f"no design spec for {name}")
        spec["name"] = f"{name}-prod"
        spec["production"] = True
        r = subprocess.run([PYBIN, "-m", "ringcli.cli", "build", "--spec-json",
                            json.dumps(spec)], cwd=ROOT, capture_output=True,
                           text=True, timeout=1800)
        out = r.stdout.strip()
        result = json.loads(out[out.index("{"):]) if "{" in out else {"ok": False,
                                                                      "errors": [out[-300:]]}
        status = "done" if result.get("ok") else "failed"
        if result.get("ok"):
            # 2D previews for the studio page (best-effort)
            gen = ROOT / "designs" / f"{spec['name']}.py"
            outdir_p = ROOT / "artifacts" / "cli" / spec["name"]
            for args in ([str(outdir_p / f"{spec['name']}.png"), "25:18"],
                         [str(outdir_p / f"{spec['name']}-head.png"), "25:18",
                          "--head"]):
                subprocess.run([PYBIN, "-m", "ringcli.render", str(gen)] + args,
                               cwd=ROOT, capture_output=True, timeout=300)
            subprocess.run([PYBIN, "-m", "ringcli.glb", str(gen),
                            str(outdir_p / f"{spec['name']}.parts.glb")],
                           cwd=ROOT, capture_output=True, timeout=300)
        with _jobs_lock:
            jobs = _load(JOBS, {})
            jobs[name] = {"status": status, "result": result,
                          "finished": datetime.now(timezone.utc).isoformat(timespec="seconds")}
            _save(JOBS, jobs)
    except Exception as e:
        with _jobs_lock:
            jobs = _load(JOBS, {})
            jobs[name] = {"status": "failed", "error": str(e)[:500]}
            _save(JOBS, jobs)


def _agentmail_send(name: str, to: str) -> dict:
    key = os.environ.get("AGENTMAIL_API_KEY")
    inbox = os.environ.get("AGENTMAIL_INBOX")
    if not key or not inbox:
        return {"error": "AgentMail not configured — set AGENTMAIL_API_KEY and "
                         "AGENTMAIL_INBOX in .env, then restart the server",
                "code": 501}
    files = _design_files(name)
    prod = files.get("prod") or {}
    if not prod:
        return {"error": "no production build yet — approve the design first",
                "code": 400}
    attachments = []
    total = 0
    for k in ("workorder", "print", "coupon"):
        rel = prod.get(k)
        if not rel:
            continue
        f = ROOT / rel.lstrip("/")
        raw = f.read_bytes()
        if total + len(raw) > 18_000_000:      # stay under mail size limits
            continue
        total += len(raw)
        attachments.append({"filename": f.name,
                            "content": base64.b64encode(raw).decode()})
    spec = files.get("spec") or {}
    brief = {k: spec[k] for k in ("cut", "carat", "size", "metal") if k in spec}
    body = (f"Your ring '{name}' is production-ready.\n\n"
            f"Spec: {json.dumps(brief)}\n\n"
            f"Attached: {', '.join(a['filename'] for a in attachments)}\n\n"
            "Print the -print STL in castable resin (upright, sprue down). "
            "The work order has the full print/cast/setting instructions.\n\n"
            "— ringagent")
    req = urllib.request.Request(
        f"https://api.agentmail.to/v0/inboxes/{urllib.parse.quote(inbox)}/messages/send",
        data=json.dumps({"to": [to], "subject": f"Ring files: {name}",
                         "text": body, "attachments": attachments}).encode(),
        headers={"Authorization": f"Bearer {key}",
                 "Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            return {"ok": True, "sent_to": to,
                    "attached": [a["filename"] for a in attachments],
                    "provider": json.loads(resp.read() or b"{}")}
    except urllib.error.HTTPError as e:
        return {"error": f"AgentMail HTTP {e.code}: {e.read().decode()[:300]}",
                "code": 502}
    except Exception as e:
        return {"error": f"AgentMail send failed: {e}", "code": 502}


class Handler(http.server.SimpleHTTPRequestHandler):
    extensions_map = {
        **http.server.SimpleHTTPRequestHandler.extensions_map,
        ".glb": "model/gltf-binary",
        ".gltf": "model/gltf+json",
        ".json": "application/json",
        ".stl": "application/octet-stream",
    }

    def log_message(self, *args):  # quiet
        pass

    def _json(self, code: int, payload: dict) -> None:
        body = json.dumps(payload).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _token_from_req(self, q) -> str:
        tok = (q.get("token") or [""])[0]
        if not tok:
            cookie = self.headers.get("Cookie", "")
            for part in cookie.split(";"):
                k, _, v = part.strip().partition("=")
                if k == "studio_token":
                    tok = v
        return tok

    def _authorized(self, design: str, q) -> bool:
        return token_ok(design, self._token_from_req(q))

    def _design_of_artifact(self, path: str) -> str | None:
        """/artifacts/{cli,authored}/<base>/... -> design name (-prod folded)."""
        parts = path.strip("/").split("/")
        if len(parts) >= 3 and parts[0] == "artifacts" and parts[1] in ("cli", "authored"):
            return parts[2][:-5] if parts[2].endswith("-prod") else parts[2]
        if len(parts) >= 2 and parts[0] == "artifacts":
            return "__admin__"           # dashboards, refs: admin only
        return None

    def do_GET(self):  # noqa: N802
        u = urllib.parse.urlparse(self.path)
        q = urllib.parse.parse_qs(u.query)
        guard = self._design_of_artifact(u.path)
        if guard and not self._authorized(guard, q):
            return self._json(401, {"error": "access token required"})
        if u.path == "/api/version":
            nm = (q.get("name") or [""])[0]
            if not self._authorized(nm, q):
                return self._json(401, {"error": "access token required"})
            stamps = []
            for base in (nm, f"{nm}-prod"):
                d = ROOT / "artifacts" / "cli" / base
                if d.exists():
                    stamps += [f.stat().st_mtime for f in d.iterdir()]
            for f2 in (COMMENTS, JOBS, ROOT / "designs" / f"{nm}.py"):
                if f2.exists():
                    stamps.append(f2.stat().st_mtime)
            return self._json(200, {"v": max(stamps) if stamps else 0})
        if u.path == "/api/design":
            name = (q.get("name") or [""])[0]
            if not name:
                return self._json(400, {"error": "name required"})
            if not self._authorized(name, q):
                return self._json(401, {"error": "access token required"})
            files = _design_files(name)
            notes = [c for c in _load(COMMENTS, {"comments": []})["comments"]
                     if c.get("design") in (name, f"{name}-prod")]
            jobs = _load(JOBS, {})
            return self._json(200, {"name": name, "files": files,
                                    "notes": notes,
                                    "production": jobs.get(name, {}),
                                    "revision": jobs.get(f"revision-{name}", {})})
        if u.path == "/api/produce-status":
            name = (q.get("name") or [""])[0]
            if not self._authorized(name, q):
                return self._json(401, {"error": "access token required"})
            return self._json(200, _load(JOBS, {}).get(name, {"status": "none"}))
        return super().do_GET()

    def do_POST(self):  # noqa: N802
        try:
            n = int(self.headers.get("Content-Length", 0))
            req = json.loads(self.rfile.read(n) or b"{}")
        except Exception:
            return self._json(400, {"error": "bad json"})
        u2 = urllib.parse.urlparse(self.path)
        path = u2.path
        q2 = urllib.parse.parse_qs(u2.query)
        target = req.get("design") or req.get("name") or ""
        if path in ("/api/comment", "/api/produce", "/api/email") and \
                not self._authorized(str(target).removesuffix("-prod"), q2):
            return self._json(401, {"error": "access token required"})
        if path == "/api/resolve" and not self._authorized("__admin__", q2):
            return self._json(401, {"error": "access token required"})
        if path == "/api/comment":
            data = _load(COMMENTS, {"comments": []})
            design, text = req.get("design"), (req.get("text") or "").strip()
            if not design or not text:
                return self._json(400, {"error": "design and text required"})
            c = {"id": uuid.uuid4().hex[:8], "design": design, "text": text,
                 "kind": req.get("kind", "revision"), "status": "open",
                 "created": datetime.now(timezone.utc).isoformat(timespec="seconds")}
            data["comments"].append(c)
            _save(COMMENTS, data)
            return self._json(200, c)
        if path == "/api/resolve":
            data = _load(COMMENTS, {"comments": []})
            for c in data["comments"]:
                if c["id"] == req.get("id"):
                    c["status"] = "addressed"
                    c["resolved"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
                    if req.get("note"):
                        c["note"] = req["note"]
                    _save(COMMENTS, data)
                    return self._json(200, c)
            return self._json(404, {"error": "no such comment"})
        if path == "/api/produce":
            name = req.get("name")
            if not name or not (ROOT / "designs" / f"{name}.py").exists():
                return self._json(404, {"error": f"no design named {name!r}"})
            job = _load(JOBS, {}).get(name, {})
            if job.get("status") == "building":
                return self._json(200, {"status": "building"})
            threading.Thread(target=_produce, args=(name,), daemon=True).start()
            return self._json(200, {"status": "building", "name": name})
        if path == "/api/email":
            name, to = req.get("name"), (req.get("to") or "").strip()
            if not name or "@" not in to:
                return self._json(400, {"error": "name and a valid 'to' email required"})
            out = _agentmail_send(name, to)
            return self._json(out.pop("code", 200) if "error" in out else 200, out)
        return self._json(404, {"error": "unknown endpoint"})


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--port", type=int, default=8770)
    args = p.parse_args()
    handler = functools.partial(Handler, directory=str(ROOT))

    class Srv(socketserver.ThreadingTCPServer):
        allow_reuse_address = True
        daemon_threads = True

    with Srv(("127.0.0.1", args.port), handler) as httpd:
        admin = make_token("__admin__")
        tok = f"?token={admin}" if admin else ""
        print(f"studio:    http://127.0.0.1:{args.port}/viewer/design.html?name=<design>&token=<per-design>")
        print(f"review:    http://127.0.0.1:{args.port}/viewer/review.html{tok}")
        print(f"dashboard: http://127.0.0.1:{args.port}/viewer/eval.html{tok}")
        httpd.serve_forever()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
