"""Telegram intake bot: transport only, the agent loop is the brains.

One chat message = one ring request:
  customer text -> artifacts/review/requests.json (status: "new")
  bot replies "designing…" immediately.
The agent loop (Claude on the server) turns each new request into a RingSpec,
builds it, and flips the entry to status "done" (or "failed"). This bot
watches for that and sends the studio link:
  {STUDIO_BASE_URL}/viewer/design.html?name=<name>

Run:  .cad-venv/bin/python harness/telegram_bot.py          (long-poll loop)
      .cad-venv/bin/python harness/telegram_bot.py --selftest
Config (.env): TELEGRAM_BOT_TOKEN=...   STUDIO_BASE_URL=http://127.0.0.1:8770
No third-party deps: urllib against api.telegram.org.
"""

from __future__ import annotations

import json
import os
import re
import sys
import time
import urllib.parse
import urllib.request
import uuid
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
REQUESTS = ROOT / "artifacts" / "review" / "requests.json"

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())

TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
BASE = os.environ.get("STUDIO_BASE_URL", "http://127.0.0.1:8770")
API = f"https://api.telegram.org/bot{TOKEN}"
# private studio: comma-separated chat ids in TELEGRAM_ALLOWED_CHATS; empty
# means open (development)
ALLOWED = {c.strip() for c in
           os.environ.get("TELEGRAM_ALLOWED_CHATS", "").split(",") if c.strip()}
# the owner can mint invite links; invitees who open them get auto-allowlisted
OWNER = os.environ.get("TELEGRAM_OWNER_CHAT", "").strip()
BOT_USERNAME = os.environ.get("TELEGRAM_BOT_USERNAME", "").strip()
ENV_FILE = ROOT / ".env"

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


def invite_token() -> str:
    """One shared, unforgeable invite token (HMAC under STUDIO_SECRET). Anyone
    who opens the deep link and taps Start presents it and is allowlisted."""
    return make_token("__invite__")


def invite_link() -> str:
    tok = invite_token()
    if not BOT_USERNAME or not tok:
        return ""
    return f"https://t.me/{BOT_USERNAME}?start=invite_{tok}"


def _persist_allowed() -> None:
    """Rewrite TELEGRAM_ALLOWED_CHATS in .env from the in-memory set, so a bot
    restart keeps everyone who accepted an invite."""
    line = "TELEGRAM_ALLOWED_CHATS=" + ",".join(sorted(ALLOWED))
    if not ENV_FILE.exists():
        return
    lines = ENV_FILE.read_text().splitlines()
    for i, l in enumerate(lines):
        if l.startswith("TELEGRAM_ALLOWED_CHATS="):
            lines[i] = line
            break
    else:
        lines.append(line)
    ENV_FILE.write_text("\n".join(lines) + "\n")


def allow_chat(chat_id: int) -> None:
    ALLOWED.add(str(chat_id))
    _persist_allowed()


def studio_link(design: str) -> str:
    tok = make_token(design)
    link = f"{BASE}/viewer/design.html?name={urllib.parse.quote(design)}"
    return f"{link}&token={tok}" if tok else link


DENIED = ("This is a private studio. Ask the owner for access and give them "
          "this code: {chat_id}")

WELCOME = ("💍 Tell me about the ring you want: cut, carat, metal, size, "
           "style: or send a reference description. One message per design.\n"
           "Commands: /status: where your latest design is at.")
ACK = ("🛠 Designing your ring: I'll send a link here in a few minutes where "
       "you can spin it in 3D, leave notes, and approve it for printing.")
MAX_TEXT = 800


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


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


def _slug(text: str) -> str:
    words = re.findall(r"[a-z]+", text.lower())[:3]
    return "-".join(words + [uuid.uuid4().hex[:4]]) or f"ring-{uuid.uuid4().hex[:6]}"


def _tg(method: str, **params):
    req = urllib.request.Request(f"{API}/{method}",
                                 data=urllib.parse.urlencode(params).encode())
    with urllib.request.urlopen(req, timeout=70) as r:
        return json.loads(r.read())


UPLOADS = ROOT / "artifacts" / "review" / "uploads"


def download_photo(file_id: str, rid: str) -> str | None:
    """Fetch a Telegram photo to a local path so the design agent can Read it.
    Returns the saved path, or None on any failure (caller falls back to text)."""
    try:
        info = _tg("getFile", file_id=file_id)
        fpath = (info.get("result") or {}).get("file_path")
        if not fpath:
            return None
        ext = Path(fpath).suffix or ".jpg"
        UPLOADS.mkdir(parents=True, exist_ok=True)
        dest = UPLOADS / f"{rid}{ext}"
        url = f"https://api.telegram.org/file/bot{TOKEN}/{fpath}"
        with urllib.request.urlopen(url, timeout=70) as r, open(dest, "wb") as f:
            f.write(r.read())
        return str(dest)
    except Exception as e:
        print("photo download failed:", e)
        return None


def latest_done_design(chat_id: int) -> str | None:
    """The customer's most recent finished design name — used to route a
    follow-up message as a revision rather than a brand-new ring."""
    mine = [r for r in _load()["requests"]
            if r["chat_id"] == chat_id and r["status"] == "done"]
    return mine[-1]["name"] if mine else None


def enqueue(chat_id: int, text: str, image: str | None = None,
            reply_to: str | None = None) -> dict:
    data = _load()
    entry = {"id": uuid.uuid4().hex[:8], "chat_id": chat_id, "text": text,
             "name": _slug(text or "photo ring"), "status": "new",
             "notified": False,
             "created": datetime.now(timezone.utc).isoformat(timespec="seconds")}
    if image:
        entry["image"] = image
    if reply_to:
        entry["reply_to"] = reply_to        # daemon classifies revise-vs-new
    data["requests"].append(entry)
    _save(data)
    return entry


def pending_notifications() -> list[dict]:
    return [r for r in _load()["requests"]
            if r["status"] in ("done", "failed") and not r.get("notified")]


def mark_notified(rid: str) -> None:
    data = _load()
    for r in data["requests"]:
        if r["id"] == rid:
            r["notified"] = True
    _save(data)


def notify_text(r: dict) -> str:
    if r["status"] == "done":
        size_note = ("\n\n📏 I assumed size 6.5 — reply with your ring size to "
                     "adjust." if r.get("size_assumed") else "")
        return (f"✨ Your ring is ready!\n\n"
                f"{studio_link(r['name'])}\n\n"
                "Spin it in 3D, leave notes if you want changes, and hit "
                "Approve when you love it: that prepares the print files."
                + size_note)
    return ("😔 I couldn't build that one: " + (r.get("error") or "the request "
            "needs more detail (cut, carat, metal). Try rephrasing?"))


def handle_message(chat_id: int, text: str,
                   image: str | None = None) -> list[tuple[int, str]]:
    """Pure handler: returns [(chat_id, reply_text), ...] and mutates only the
    queue. Testable without network. `image` is an already-downloaded local
    path (poll_loop does the network fetch); text may be a photo caption."""
    text = (text or "").strip()
    if chat_id is None or (not text and not image):
        return []
    # invite acceptance runs BEFORE the allowlist gate: an invitee is not yet
    # allowed. Telegram deep links deliver "/start invite_<token>".
    if text.startswith("/start invite_"):
        tok = text.split("invite_", 1)[1].strip()
        if token_ok("__invite__", tok):
            allow_chat(chat_id)
            return [(chat_id, "✅ You're in! " + WELCOME)]
        return [(chat_id, "That invite link isn't valid or has changed. Ask "
                          "the owner for a fresh one.")]
    if ALLOWED and str(chat_id) not in ALLOWED:
        return [(chat_id, DENIED.format(chat_id=chat_id))]
    if text.startswith("/invite"):
        if OWNER and str(chat_id) != OWNER:
            return [(chat_id, "Only the studio owner can create invites.")]
        link = invite_link()
        if not link:
            return [(chat_id, "Invite links aren't configured "
                              "(need STUDIO_SECRET + TELEGRAM_BOT_USERNAME).")]
        return [(chat_id, "🔗 Share this link to invite someone — they tap "
                          "Start and get instant access:\n\n" + link)]
    if text.startswith("/start"):
        return [(chat_id, WELCOME)]
    if text.startswith("/status"):
        mine = [r for r in _load()["requests"] if r["chat_id"] == chat_id]
        if not mine:
            return [(chat_id, "No designs yet: describe a ring to start!")]
        r = mine[-1]
        stage = {"new": "🛠 being designed", "done": "✨ ready",
                 "failed": "😔 needs a rephrase"}.get(r["status"], r["status"])
        link = f"\n{studio_link(r['name'])}" if r["status"] == "done" else ""
        return [(chat_id, f"Your latest design '{r['name']}' is {stage}.{link}")]
    if len(text) > MAX_TEXT:
        return [(chat_id, f"That's a lot! Keep it under {MAX_TEXT} characters "
                          "- cut, carat, metal, size, style.")]
    # a follow-up text after a finished design may be a revision — let the
    # daemon classify it against that design (image requests always start fresh)
    reply_to = None if image else latest_done_design(chat_id)
    enqueue(chat_id, text, image=image, reply_to=reply_to)
    if image:
        return [(chat_id, "🛠 Designing from your photo — I'll send a 3D link "
                          "here in a few minutes.")]
    return [(chat_id, ACK)]


def poll_loop() -> int:
    if not TOKEN:
        print("TELEGRAM_BOT_TOKEN not set in .env: add it, then rerun.\n"
              "Get a token from @BotFather on Telegram.")
        return 1
    print(f"bot up: studio base {BASE}")
    offset = 0
    while True:
        # 1) deliver finished designs
        for r in pending_notifications():
            try:
                _tg("sendMessage", chat_id=r["chat_id"], text=notify_text(r))
                mark_notified(r["id"])
                print(f"notified {r['chat_id']} about {r['name']} ({r['status']})")
            except Exception as e:
                print("notify failed:", e)
        # 2) intake
        try:
            upd = _tg("getUpdates", timeout=50, offset=offset)
        except Exception as e:
            print("poll error:", e)
            time.sleep(5)
            continue
        for u in upd.get("result", []):
            offset = u["update_id"] + 1
            msg = u.get("message") or {}
            cid_in = (msg.get("chat") or {}).get("id")
            # a photo message: text lives in "caption"; download the largest size
            image = None
            text = msg.get("text")
            photo = msg.get("photo")
            if photo and cid_in is not None:
                # allowlist gate before spending a download on a stranger
                if not ALLOWED or str(cid_in) in ALLOWED:
                    image = download_photo(photo[-1]["file_id"],
                                           uuid.uuid4().hex[:8])
                text = msg.get("caption")
            for cid, reply in handle_message(cid_in, text, image=image):
                _tg("sendMessage", chat_id=cid, text=reply)


def selftest() -> int:
    """No network: exercise handler + queue contract end to end."""
    global ALLOWED
    ALLOWED = {"1", "0", "987654"}
    denied = handle_message(31337, "hello")
    assert "private studio" in denied[0][1] and "31337" in denied[0][1], denied
    assert handle_message(1, "/start")[0][1].startswith("💍")
    assert "No designs yet" in handle_message(987654, "/status")[0][1]
    assert "under 800" in handle_message(1, "x" * 900)[0][1]
    replies = handle_message(0, "oval 1.5 carat rose gold twisted band size 6")
    assert replies[0][1] == ACK
    e = [r for r in _load()["requests"] if r["chat_id"] == 0][-1]
    st = handle_message(0, "/status")[0][1]
    assert "being designed" in st, st
    assert any(r["id"] == e["id"] and r["status"] == "new"
               for r in _load()["requests"])
    # simulate the agent loop finishing the design
    data = _load()
    for r in data["requests"]:
        if r["id"] == e["id"]:
            r["status"] = "done"
    _save(data)
    todo = pending_notifications()
    assert any(r["id"] == e["id"] for r in todo), "done entry must need notify"
    msg = notify_text([r for r in todo if r["id"] == e["id"]][0])
    assert "design.html?name=" in msg
    mark_notified(e["id"])
    assert not any(r["id"] == e["id"] for r in pending_notifications())
    # size-assumed note surfaces for image-authored designs
    fake = {"status": "done", "name": e["name"], "size_assumed": True}
    assert "assumed size 6.5" in notify_text(fake), notify_text(fake)
    # an image message enqueues an image request + photo ack (no net: pass a path)
    ir = handle_message(0, "this but rose gold", image="/tmp/ref.jpg")
    assert "photo" in ir[0][1].lower(), ir
    ie = [r for r in _load()["requests"] if r["chat_id"] == 0][-1]
    assert ie.get("image") == "/tmp/ref.jpg" and "reply_to" not in ie, ie
    # a follow-up text after e (now done) is stamped reply_to for the daemon
    fr = handle_message(0, "make the band thinner")
    fe = [r for r in _load()["requests"] if r["chat_id"] == 0][-1]
    assert fe.get("reply_to") == e["name"], fe
    # cleanup all test entries for chat 0
    data = _load()
    data["requests"] = [r for r in data["requests"] if r["chat_id"] != 0]
    _save(data)
    print("selftest ok: queue contract + image/revision routing sound.\n"
          "ready reply would be:\n" + msg)
    return 0


if __name__ == "__main__":
    sys.exit(selftest() if "--selftest" in sys.argv else poll_loop())
