"""Resident design agent — the headless brains, automating DEPLOY.md §7.

The bot and server are transport; this daemon is the part that reads a
customer's free text, authors a RingSpec (via `claude -p` with prompts/design.md
as the manual), builds it through the deterministic `ringcli` compiler, and
flips the request so the bot delivers the studio link. Studio revision notes are
handled the same way through harness/revise.py.

Run (systemd, see ring-agent.service):
  .cad-venv/bin/python harness/design_agent.py            # poll forever
  .cad-venv/bin/python harness/design_agent.py --once     # one cycle, for tests

Scope is deliberately narrow (Ahmed's rule): author -> build -> deliver, and
revise on notes. It NEVER edits code, opens PRs, restarts services, or bypasses
a gate. A request becomes "done" only when the build returned ok:true AND the
STEP artifact exists on disk — never on faith, never lie to the customer.

The model's ONLY output is a spec; the compiler + gates are the backstop against
bad geometry. Untrusted customer text reaches `claude -p`, so the Telegram
allowlist (TELEGRAM_ALLOWED_CHATS) is the real bound on who can spend calls.
"""

from __future__ import annotations

import json
import subprocess
import sys
import time
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PYBIN = str(ROOT / ".cad-venv" / "bin" / "python")
REQUESTS = ROOT / "artifacts" / "review" / "requests.json"
COMMENTS = ROOT / "artifacts" / "review" / "comments.json"
DESIGNS = ROOT / "designs"
DESIGN_MANUAL = (ROOT / "prompts" / "design.md").read_text()

POLL_SECONDS = 45
CLAUDE_TIMEOUT = 180          # per claude -p call
BUILD_TIMEOUT = 1800          # ringcli build can grind on this small box
MAX_SPEC_ATTEMPTS = 3         # 1 author + 2 self-corrections
CLAIM_STALE_MIN = 20          # a "building" request older than this = crashed

# .env -> environment (same pattern as serve.py); the daemon also needs
# claude's OpenRouter auth + IS_SANDBOX, which systemd supplies via the unit.
for _line in (ROOT / ".env").read_text().splitlines() if (ROOT / ".env").exists() else []:
    if "=" in _line and not _line.strip().startswith("#"):
        import os
        _k, _, _v = _line.partition("=")
        os.environ.setdefault(_k.strip(), _v.strip())

sys.path.insert(0, str(ROOT / "harness"))
from triage import triage  # noqa: E402


def _log(msg: str) -> None:
    ts = datetime.now(timezone.utc).isoformat(timespec="seconds")
    print(f"[{ts}] {msg}", flush=True)


# ---- requests.json helpers (same shape as telegram_bot.py) ------------------

def _load_requests() -> dict:
    if REQUESTS.exists():
        return json.loads(REQUESTS.read_text())
    return {"requests": []}


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


def _set_request(rid: str, **fields) -> dict | None:
    """Patch one request in place; returns the updated entry (or None)."""
    data = _load_requests()
    hit = None
    for r in data["requests"]:
        if r["id"] == rid:
            r.update(fields)
            hit = r
    if hit is not None:
        _save_requests(data)
    return hit


def _claim(rid: str) -> bool:
    """Atomically move a request new -> building so a restart can't double-run
    it. 'building' is invisible to the bot (delivers only done/failed) and to
    triage's new_requests, so a claimed item won't be re-listed. Returns False
    if someone else already moved it off 'new'."""
    data = _load_requests()
    for r in data["requests"]:
        if r["id"] == rid:
            if r.get("status") != "new":
                return False
            r["status"] = "building"
            r["claimed"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
            _save_requests(data)
            return True
    return False


def _reset_stale_claims() -> None:
    """On startup / each cycle, return crashed 'building' requests to 'new'."""
    data = _load_requests()
    changed = False
    cutoff = datetime.now(timezone.utc) - timedelta(minutes=CLAIM_STALE_MIN)
    for r in data["requests"]:
        if r.get("status") == "building":
            try:
                claimed = datetime.fromisoformat(r.get("claimed", ""))
            except ValueError:
                claimed = None
            if claimed is None or claimed < cutoff:
                r["status"] = "new"
                r.pop("claimed", None)
                changed = True
                _log(f"reset stale build claim on {r.get('name')} ({r['id']})")
    if changed:
        _save_requests(data)


# ---- the brain: text -> RingSpec via claude -p ------------------------------

def _extract_json(text: str) -> dict | None:
    """Pull the first balanced {...} object out of claude's stdout."""
    start = text.find("{")
    if start < 0:
        return None
    depth = 0
    for i in range(start, len(text)):
        if text[i] == "{":
            depth += 1
        elif text[i] == "}":
            depth -= 1
            if depth == 0:
                try:
                    return json.loads(text[start:i + 1])
                except json.JSONDecodeError:
                    return None
    return None


# Force a single, tool-free turn: design.md reads like project docs, so with
# tools enabled the model burns turns "exploring the repo" and ends with empty
# text (num_turns:2, result:""). --max-turns 1 + no tools makes it answer
# directly with the spec in ~5s instead of ~85s, and avoids the repo-cache cost.
_NO_TOOLS = ["Bash", "Read", "Glob", "Grep", "WebSearch", "WebFetch",
             "Edit", "Write", "Task", "TodoWrite", "NotebookEdit"]


def _claude(user_prompt: str, image: str | None = None) -> str:
    """One headless claude -p call. design.md is the SYSTEM prompt (feeding it
    as a user turn makes the model treat it as docs to react to — flaky/empty
    output); the customer directive is the short user turn. Env (IS_SANDBOX +
    OpenRouter auth) comes from the systemd unit / inherited environment.

    With `image`, the model needs the Read tool to view the file, so we allow
    Read only and give it a few turns (Read, then answer) instead of the
    tool-free single turn used for pure text."""
    if image:
        cmd = ["claude", "-p", user_prompt, "--system-prompt", DESIGN_MANUAL,
               "--max-turns", "4", "--allowed-tools", "Read"]
    else:
        cmd = ["claude", "-p", user_prompt, "--system-prompt", DESIGN_MANUAL,
               "--max-turns", "1", "--disallowed-tools", ",".join(_NO_TOOLS)]
    r = subprocess.run(cmd, cwd=str(ROOT), capture_output=True, text=True,
                       timeout=CLAUDE_TIMEOUT, stdin=subprocess.DEVNULL)
    if r.returncode != 0:
        raise RuntimeError(f"claude -p failed ({r.returncode}): "
                           f"{(r.stderr or r.stdout).strip()[-300:]}")
    return r.stdout.strip()


def _author_prompt(instruction: str) -> str:
    return ("You are running HEADLESS: there is no interactive turn, so NEVER "
            "ask a clarifying question — assume the documented defaults instead "
            "(round cut, US sizing, a sensible metal). Every spec MUST have "
            'either "archetype" (preset) OR a "shank" (v3) — never omit both.\n'
            "If — and ONLY if — the request is not a ring description at all "
            "(gibberish, empty, spam, or clearly off-topic), do NOT invent a "
            'ring: output exactly {"reject": "<short reason>"} instead. A vague '
            "but real ring request (e.g. just \"a gold ring\") is NOT a reject — "
            "fill defaults and design it.\n"
            + instruction + "\nOutput ONLY the JSON object (a RingSpec, or the "
            "reject object): no prose, no explanation, no markdown code fence.")


def author_spec(instruction: str, name: str,
                image: str | None = None) -> tuple[dict | None, dict]:
    """Author a spec and build it, self-correcting on validation/build errors.
    Returns (build_result_or_None, last_spec). build_result is the ringcli
    result dict; None means we never got parseable JSON out of the model.
    With `image`, the first model call reads the photo as a design reference."""
    errors_ctx = ""
    last_spec: dict = {}
    for attempt in range(1, MAX_SPEC_ATTEMPTS + 1):
        prompt = _author_prompt(instruction)
        if errors_ctx:
            prompt += ("\n\nYour previous spec FAILED. Fix exactly these and "
                       "re-emit the full corrected JSON:\n" + errors_ctx)
        out = _claude(prompt, image=image)
        spec = _extract_json(out)
        if spec is None:
            _log(f"  attempt {attempt}: no JSON in model output")
            errors_ctx = "Your last output contained no valid JSON object."
            continue
        if "reject" in spec and "name" not in spec:
            _log(f"  rejected: {str(spec['reject'])[:120]}")
            return {"ok": False, "reject": True,
                    "errors": [str(spec.get("reject"))[:200]]}, {}
        spec["name"] = name          # force the queue name (same studio link)
        last_spec = spec
        result = _build(spec)
        if result.get("ok"):
            return result, spec
        errs = "; ".join(result.get("errors", ["unknown build failure"]))
        _log(f"  attempt {attempt} build failed: {errs[:200]}")
        errors_ctx = errs
    return (result if last_spec else None), last_spec


def _validate_spec(spec: dict) -> list[str]:
    """Run ringcli's own validator (the same check the build uses) without a
    full build — lets the revision path reject an invalid spec before showing
    the customer a preview that would then fail its gate."""
    r = subprocess.run(
        [PYBIN, "-c",
         "import sys,json; sys.path.insert(0,'.'); from ringcli.spec import "
         "validate; print(json.dumps(validate(json.loads(sys.argv[1]))))",
         json.dumps(spec)],
        cwd=str(ROOT), capture_output=True, text=True, timeout=60)
    try:
        return json.loads(r.stdout.strip().splitlines()[-1])
    except Exception:
        return ["validator failed to run"]


def _author_valid_spec(instruction: str, name: str) -> dict | None:
    """Author a spec and self-correct until it PASSES ringcli validation (not a
    full build — used by the revision path, which then hands off to revise.py).
    Returns the valid spec, or None if it never validated."""
    errors_ctx = ""
    for attempt in range(1, MAX_SPEC_ATTEMPTS + 1):
        prompt = _author_prompt(instruction)
        if errors_ctx:
            prompt += ("\n\nYour previous spec was INVALID. Fix exactly these "
                       "and re-emit the full corrected JSON:\n" + errors_ctx)
        spec = _extract_json(_claude(prompt))
        if spec is None:
            errors_ctx = "Your last output contained no valid JSON object."
            continue
        spec["name"] = name
        errs = _validate_spec(spec)
        if not errs:
            return spec
        _log(f"  revise attempt {attempt} invalid: {'; '.join(errs)[:200]}")
        errors_ctx = "; ".join(errs)
    return None


# ---- build + render (reuse ringcli exactly as serve.py does) ----------------

def _build(spec: dict) -> dict:
    r = subprocess.run([PYBIN, "-m", "ringcli.cli", "build", "--spec-json",
                        json.dumps(spec)], cwd=str(ROOT), capture_output=True,
                       text=True, timeout=BUILD_TIMEOUT)
    out = r.stdout.strip()
    if "{" in out:
        try:
            return json.loads(out[out.index("{"):])
        except json.JSONDecodeError:
            pass
    return {"ok": False, "errors": [(out or r.stderr).strip()[-300:]]}


def _render_previews(name: str) -> None:
    """Studio previews + parts GLB, best-effort (same commands as serve.py)."""
    gen = DESIGNS / f"{name}.py"
    outdir = ROOT / "artifacts" / "cli" / name
    for args in ([str(outdir / f"{name}.png"), "25:20", "--supports"],
                 [str(outdir / f"{name}-head.png"), "25:20", "--head"]):
        subprocess.run([PYBIN, "-m", "ringcli.render", str(gen)] + args,
                       cwd=str(ROOT), capture_output=True, timeout=300)
    subprocess.run([PYBIN, "-m", "ringcli.glb", str(gen),
                    str(outdir / f"{name}.parts.glb")],
                   cwd=str(ROOT), capture_output=True, timeout=300)


def _step_ok(name: str) -> bool:
    """The done-guard: a real, non-trivial STEP must exist on disk."""
    step = ROOT / "artifacts" / "cli" / name / f"{name}.step"
    return step.exists() and step.stat().st_size > 50_000


# ---- current spec of an existing design (for revisions) ---------------------

def _current_spec(design: str) -> dict:
    gen = DESIGNS / f"{design}.py"
    if gen.exists():
        for line in gen.read_text().splitlines():
            if line.startswith("SPEC = "):
                try:
                    return eval(line[7:], {"__builtins__": {}})  # literal dict
                except Exception:
                    return {}
    return {}


# ---- work items -------------------------------------------------------------

FRIENDLY_FAIL = ("I couldn't turn that into a ring I can build. Try describing "
                 "the cut (round, oval, emerald…), metal, ring size, and a "
                 "carat or style — one ring per message.")


def _classify_revision(text: str, design: str) -> bool:
    """Is this follow-up text a REVISION of `design`, or a request for a NEW
    ring? Returns True for revise. Defaults to new on any ambiguity/error."""
    current = _current_spec(design)
    if not current:
        return False
    try:
        out = _claude(
            f'The customer already has ring design "{design}" with this spec:\n'
            f'{json.dumps(current)}\n\n'
            f'Their new message is: "{text}"\n\n'
            "Is this a REVISION of that existing ring, or a request for a "
            "brand-NEW, different ring? A tweak to metal/stone/band/size is a "
            "revision; describing a different ring is new. Answer with ONLY one "
            "word: revise OR new.")
    except Exception as e:
        _log(f"  classifier error ({e!r}) — defaulting to new")
        return False
    return out.strip().lower().startswith("revise")


def handle_new_request(req: dict) -> None:
    rid, name, text = req["id"], req["name"], req.get("text") or ""
    image = req.get("image")
    reply_to = req.get("reply_to")
    if not _claim(rid):
        return                       # someone else took it / not new anymore

    # A follow-up text after a finished design may be a revision, not a new
    # ring — let the model decide, then reuse the existing note/revise path.
    if reply_to and not image and text:
        _log(f"request {rid}: classifying vs existing '{reply_to}'")
        if _classify_revision(text, reply_to):
            _log(f"  -> revision of {reply_to}")
            _route_as_revision(rid, reply_to, text)
            return
        _log(f"  -> new ring")

    kind = "photo" if image else "text"
    _log(f"new {kind} request {rid} '{name}': {text[:80]!r}")
    try:
        if image:
            instruction = (
                f'The customer sent the reference image at: {image}\n'
                f'Read that image — it is the ring they want as a REFERENCE. '
                f'Compose ONE RingSpec matching what you see'
                + (f', then apply this modification: "{text}"' if text else "")
                + f'. A photo cannot show ring size, so use size 6.5. Assume '
                f'documented defaults for anything not visible. Use exactly '
                f'this name: "{name}".')
        else:
            instruction = (f'Translate this customer request into ONE RingSpec. '
                           f'Use exactly this name: "{name}".\n\n'
                           f'Customer request: {text}')
        result, spec = author_spec(instruction, name, image=image)
        if result and result.get("ok") and _step_ok(name):
            _render_previews(name)
            # image builds guessed the size -> tell the customer in delivery
            extra = {"size_assumed": True} if image else {}
            _set_request(rid, status="done", **extra)  # notified False -> bot delivers
            _log(f"  done: {name} built + previews; bot will deliver")
        else:
            errs = "; ".join((result or {}).get("errors", ["no buildable spec"]))
            _set_request(rid, status="failed", error=FRIENDLY_FAIL)
            _log(f"  failed: {name} — {errs[:200]}")
    except Exception as e:
        # never leave it stuck on 'building'; tell the customer honestly
        _set_request(rid, status="failed", error=FRIENDLY_FAIL)
        _log(f"  error on {name}: {e!r}")


def _route_as_revision(rid: str, design: str, text: str) -> None:
    """Convert a Telegram follow-up into a studio note against `design` and run
    the existing revise path, then retire the request so it never builds as a
    new ring. The customer already has the (same) studio link; revise.py keeps
    the same name so that link just updates — no new bot delivery needed."""
    note = {"id": uuid.uuid4().hex[:8], "design": design,
            "text": text, "kind": "revision", "status": "open",
            "created": datetime.now(timezone.utc).isoformat(timespec="seconds")}
    data = json.loads(COMMENTS.read_text()) if COMMENTS.exists() else {"comments": []}
    data["comments"].append(note)
    COMMENTS.parent.mkdir(parents=True, exist_ok=True)
    COMMENTS.write_text(json.dumps(data, indent=2))
    handle_note({"design": design, "id": note["id"], "text": text})
    # retire the routed request: done+notified so neither the daemon nor the
    # bot acts on it again (the revision itself updates the existing design).
    _set_request(rid, status="done", notified=True, routed_revision=design)


def handle_note(note: dict) -> None:
    design, note_id, text = note["design"], note["id"], note["text"]
    base = design[:-5] if design.endswith("-prod") else design
    _log(f"note {note_id} on {base}: {text[:80]!r}")
    current = _current_spec(base)
    if not current:
        _log(f"  skip: no current spec for {base}")
        return
    try:
        instruction = (
            f'Here is the CURRENT RingSpec for design "{base}":\n'
            f'{json.dumps(current)}\n\n'
            f'The customer left this revision note: "{text}"\n'
            f'Apply ONLY that change, keeping everything else the same, and '
            f're-emit the COMPLETE updated spec. Use exactly this name: '
            f'"{base}". Reminder: a spec must have EITHER "archetype" OR '
            f'"shank"/"head" (v3), never both — if you add v3 fields, drop '
            f'"archetype" (expand it into shank/head first).')
        spec = _author_valid_spec(instruction, base)
        if spec is None:
            _log(f"  skip: no valid revised spec for note {note_id}")
            return
        r = subprocess.run(
            [PYBIN, "harness/revise.py", base, "--spec-json", json.dumps(spec),
             "--resolve", note_id, "--note", f"applied: {text[:120]}"],
            cwd=str(ROOT), capture_output=True, text=True, timeout=BUILD_TIMEOUT)
        if r.returncode == 0:
            _log(f"  revised {base}, resolved note {note_id}")
        else:
            _log(f"  revise.py failed for {base}: "
                 f"{(r.stderr or r.stdout).strip()[-200:]}")
    except Exception as e:
        _log(f"  error revising {base}: {e!r}")


def _note_kinds() -> dict:
    """id -> kind for open notes. System notes (kind:'system') are gate-failure
    breadcrumbs meant for a human — the daemon must NOT try to 'revise' them."""
    if not COMMENTS.exists():
        return {}
    try:
        data = json.loads(COMMENTS.read_text())
    except json.JSONDecodeError:
        return {}
    return {c["id"]: c.get("kind", "revision") for c in data.get("comments", [])}


def cycle() -> None:
    _reset_stale_claims()
    t = triage()
    for req in t.get("new_requests", []):
        handle_new_request(req)
    kinds = _note_kinds()
    for note in t.get("open_notes", []):
        if kinds.get(note["id"]) == "system":
            continue                 # gate-failure note — leave for the human
        handle_note(note)


def main() -> int:
    once = "--once" in sys.argv
    _log(f"design agent up (poll {POLL_SECONDS}s{' — ONCE' if once else ''})")
    if once:
        cycle()
        return 0
    while True:
        try:
            cycle()
        except Exception as e:
            _log(f"cycle error: {e!r}")
        time.sleep(POLL_SECONDS)


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