#!/usr/bin/env python3
"""wiki-notify-gchat — wiki.adom.inc notification emails become a live Google Chat board.

Polls Gmail for wiki notification emails, folds them into per-item state
(issue opened / commented / mentioned / closed), renders ONE card message — a
table of items, newest first — into your Chat space via your incoming webhook,
then sweeps the emails out of the inbox (trash / archive / label).

The card is replaced on every change: the webhook POSTs the fresh card, then
the previous card is deleted via adom-google (you created the space, so you
are a space manager and may delete the webhook's messages). Closed items show
CLOSED until 6am the next day, then drop off the board.

Gmail + Chat auth is delegated entirely to the adom-google CLI; the only
secret this tool holds is your webhook URL, in your private config (0600).
"""
import json
import os
import re
import subprocess
import sys
import time
import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path

CONFIG_PATH = Path.home() / ".config" / "wiki-notify-gchat" / "config.json"
STATE_DIR = Path.home() / ".local" / "state" / "wiki-notify-gchat"
STATE_PATH = STATE_DIR / "state.json"
LOG_PATH = STATE_DIR / "activity.log"
GMAIL = "https://gmail.googleapis.com/gmail/v1/users/me"
CHAT = "https://chat.googleapis.com/v1"
CRON_MARKER = "# wiki-notify-gchat poller"
MAX_PROCESSED = 2000

DEFAULT_CONFIG = {
    "webhook": "",
    "query": "from:no-reply@adom.inc in:inbox",
    "requireInBody": "wiki.adom.inc",
    "action": "trash",
    "labelName": "wiki-notify",
    "pollSeconds": 120,
    "purgeHour": 6,
    "timezone": "",
    "maxRows": 40,
    "boardTitle": "Wiki Notifications",
    "style": "table",
    "meName": "You",
    "fullName": "",
}

OPEN_COLOR, CLOSED_COLOR = "#22c55e", "#94a3b8"


def emit(line):
    print(line)


def die(msg, hints=(), as_json=False):
    if as_json:
        print(json.dumps({"status": "error", "error": msg, "hints": list(hints)}))
    else:
        emit(f"ERROR: {msg}")
        for h in hints:
            emit(f"Hint: {h}")
    sys.exit(1)


def now_iso():
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def log_line(text):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    with LOG_PATH.open("a") as f:
        f.write(f"{now_iso()} {text}\n")


def load_config(as_json=False):
    if not CONFIG_PATH.exists():
        die("no config found.",
            ["Run `wiki-notify-gchat setup` to write a starter config.",
             f"Config lives at {CONFIG_PATH}."], as_json)
    try:
        cfg = {**DEFAULT_CONFIG, **json.loads(CONFIG_PATH.read_text())}
    except Exception as e:
        die(f"config is not valid JSON: {e}", [f"Fix {CONFIG_PATH} and re-run."], as_json)
    return cfg


def load_state():
    try:
        return json.loads(STATE_PATH.read_text())
    except Exception:
        return {"items": {}, "processed": {}, "boardMsg": "", "lastRenderHash": ""}


def save_state(state):
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    proc = state.get("processed", {})
    if len(proc) > MAX_PROCESSED:
        state["processed"] = dict(sorted(proc.items(), key=lambda kv: kv[1])[-MAX_PROCESSED:])
    STATE_PATH.write_text(json.dumps(state))


def tzinfo_of(cfg):
    name = cfg.get("timezone") or ""
    if name:
        try:
            from zoneinfo import ZoneInfo
            return ZoneInfo(name)
        except Exception:
            pass
    return datetime.now().astimezone().tzinfo


def run_cmd(args, stdin_text=None):
    # cron runs with a bare PATH; resolve helper CLIs like adom-google explicitly
    import shutil
    if "/" not in args[0] and not shutil.which(args[0]):
        for d in (Path.home() / ".local" / "bin", Path("/usr/local/bin")):
            if (d / args[0]).exists():
                args = [str(d / args[0])] + list(args[1:])
                break
    try:
        p = subprocess.run(args, input=stdin_text, capture_output=True, text=True, timeout=120)
        return p.returncode, p.stdout, p.stderr
    except FileNotFoundError:
        return 127, "", f"{args[0]}: not found"
    except subprocess.TimeoutExpired:
        return 124, "", f"{args[0]}: timed out"


def gapi(url, method=None, body=None, params=None):
    """Call any Google API via adom-google (user auth); return parsed JSON."""
    args = ["adom-google", "api", url]
    if method:
        args += ["-X", method]
    if body is not None:
        args += ["-d", json.dumps(body)]
    for k, v in (params or {}).items():
        args += ["-q", f"{k}={v}"]
    code, out, err = run_cmd(args)
    if code != 0:
        raise RuntimeError(f"adom-google api failed ({code}): {(err or out).strip()[:300]}")
    m = re.search(r"^HTTP (\d+) ", out + "\n" + err, re.M)
    if m and not m.group(1).startswith("2"):
        raise RuntimeError(f"HTTP {m.group(1)}: {out.strip()[:300]}")
    brace = out.find("{")
    return json.loads(out[brace:]) if brace >= 0 else {}


def webhook_post(cfg, payload):
    """POST a message through the incoming webhook; returns the message name."""
    import urllib.request
    req = urllib.request.Request(
        cfg["webhook"], data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json; charset=UTF-8"}, method="POST")
    with urllib.request.urlopen(req, timeout=30) as r:
        resp = json.loads(r.read().decode())
    if resp.get("error"):
        raise RuntimeError(f"webhook post failed: {resp['error'].get('message')}")
    return resp.get("name", "")


# ---------- email parsing ----------

def headers_of(msg):
    return {h["name"].lower(): h["value"] for h in msg.get("payload", {}).get("headers", [])}


def plain_body(msg):
    import base64

    def walk(part):
        if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):
            return base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", "replace")
        for p in part.get("parts", []) or []:
            got = walk(p)
            if got:
                return got
        return None

    return walk(msg.get("payload", {})) or ""


EVENT_PATTERNS = [
    (re.compile(r"^New issue:\s*(.+)$"), "opened", "open"),
    (re.compile(r"^Reopened:\s*(.+)$"), "reopened", "open"),
    (re.compile(r"^Closed:\s*(.+)$"), "closed", "closed"),
    (re.compile(r"^New comment on:\s*(.+)$"), "comment", None),
    (re.compile(r"^(.+?) mentioned you on:\s*(.+)$"), "mention", None),
]


def page_author(page, state):
    """Author of a wiki page via `adom-wiki page stats` (cached in state)."""
    cache = state.setdefault("pageAuthors", {})
    if page in cache:
        return cache[page]
    author = ""
    code, out, _ = run_cmd(["adom-wiki", "page", "stats", page, "--json"])
    if code == 0:
        try:
            j = json.loads(out)
            author = ((j.get("data", j) or {}).get("page") or {}).get("author_name") or ""
        except Exception:
            pass
    cache[page] = author
    save_state(state)
    return author


def annotate_page_ownership(state, me_full):
    """Mark items whose wiki page the user authored (drives 'owns this page')."""
    for it in state.get("items", {}).values():
        author = page_author(it["page"], state)
        it["ownsPage"] = bool(me_full) and author == me_full


def parse_notification(msg):
    """Return an event dict for a wiki notification email, or None."""
    import html
    h = headers_of(msg)
    subject = html.unescape(h.get("subject", ""))
    body = html.unescape(plain_body(msg))
    m = re.match(r"^\[([^\]]+)\]\s*(.+?)(?:\s*\(#(\d+)\))?$", subject)
    if not m:
        return None
    page, rest, num = m.group(1), m.group(2), m.group(3)
    event, status, title, actor = "activity", None, rest, None
    for pat, ev, st in EVENT_PATTERNS:
        em = pat.match(rest)
        if em:
            event, status = ev, st
            if ev == "mention":
                actor, title = em.group(1), em.group(2)
            else:
                title = em.group(1)
            break
    if not actor:
        am = re.search(r"Hi [^,]+,\s+(.+?)\s+(?:opened|commented|closed|reopened|merged|replied|mentioned)", body)
        if am:
            actor = am.group(1).strip("*")
    url = None
    for u in re.findall(r"\(\s*(https://wiki\.adom\.inc/[^\s)]+)\s*\)", body):
        url = u
        break
    ts = int(msg.get("internalDate", "0"))
    reason = None
    rm = re.search(r"You're receiving this because (.+?)[.\n]", body)
    if rm:
        reason = rm.group(1).strip()
    return {
        "key": f"{page}#{num or title[:40]}",
        "page": page, "num": num, "title": title.strip(),
        "event": event, "status": status, "actor": actor, "url": url, "ts": ts,
        "reason": reason,
    }


def involvement_of(ev):
    """Rank the user's involvement from the mail's 'You're receiving this
    because ...' footer (and mention events). Higher rank wins on an item."""
    r = (ev.get("reason") or "").lower()
    if "assign" in r or "own" in r:
        return 4, "owns this fix"
    if "you opened" in r or "author" in r or "you created" in r:
        return 3, "opened this"
    if ev["event"] == "mention" or "mention" in r:
        return 2, "was mentioned"
    if "you commented" in r:
        return 2, "commented on this"
    if "you closed" in r:
        return 2, "closed this"
    if "watch" in r:
        return 1, "watches this page"
    if r:
        return 1, r
    return 0, ""


def apply_event(items, ev):
    it = items.get(ev["key"])
    if not it:
        it = {"page": ev["page"], "num": ev["num"], "title": ev["title"],
              "status": "open", "openedAt": ev["ts"], "lastAt": 0,
              "lastEvent": "", "actor": ev["actor"] or "", "creator": "",
              "url": ev["url"] or "", "closedAt": None, "events": 0,
              "invRank": 0, "invLabel": ""}
        items[ev["key"]] = it
    it["events"] += 1
    rank, label = involvement_of(ev)
    if rank > it.get("invRank", 0):
        it["invRank"], it["invLabel"] = rank, label
    if ev["ts"] <= it["openedAt"] and ev["actor"]:
        it["creator"] = ev["actor"]  # actor of the earliest event we have
    it["openedAt"] = min(it["openedAt"], ev["ts"])
    if ev["ts"] >= it["lastAt"]:
        it["lastAt"] = ev["ts"]
        it["lastEvent"] = ev["event"]
        it["title"] = ev["title"] or it["title"]
        if ev["actor"]:
            it["actor"] = ev["actor"]
        if ev["status"]:
            it["status"] = ev["status"]
            it["closedAt"] = ev["ts"] if ev["status"] == "closed" else None
    if ev["url"] and not it["url"]:
        it["url"] = ev["url"]


def purge_items(items, cfg):
    """Drop closed items once local time passes purgeHour on the day after closing."""
    tz = tzinfo_of(cfg)
    now = datetime.now(tz)
    dropped = []
    for key, it in list(items.items()):
        if it["status"] == "closed" and it.get("closedAt"):
            closed = datetime.fromtimestamp(it["closedAt"] / 1000, tz)
            deadline = (closed + timedelta(days=1)).replace(
                hour=int(cfg.get("purgeHour", 6)), minute=0, second=0, microsecond=0)
            if now >= deadline:
                dropped.append(key)
                del items[key]
    return dropped


# ---------- card rendering ----------

def fmt_date(ts, tz):
    return datetime.fromtimestamp(ts / 1000, tz).strftime("%b %-d")


def fmt_datetime(ts, tz):
    return datetime.fromtimestamp(ts / 1000, tz).strftime("%b %-d %H:%M")


EVENT_LABEL = {"opened": "opened", "comment": "new comment", "mention": "mentioned you",
               "closed": "closed", "reopened": "reopened", "activity": "activity"}


def render_card(items, cfg):
    tz = tzinfo_of(cfg)
    rows = sorted(items.values(), key=lambda it: -it["lastAt"])
    n_open = sum(1 for it in rows if it["status"] == "open")
    n_closed = len(rows) - n_open
    max_rows = int(cfg.get("maxRows", 40))
    shown, overflow = rows[:max_rows], max(0, len(rows) - max_rows)
    style = cfg.get("style", "table")
    if style == "blocks":
        widgets = render_block_widgets(shown, tz)
    else:
        widgets = render_table_widgets(shown, tz, me=cfg.get("meName", "You"))
    if overflow:
        widgets.append({"textParagraph": {"text": f"<i>+{overflow} older item(s) not shown</i>"}})
    if not shown:
        widgets = [{"textParagraph": {"text": "No wiki notifications. Inbox clean."}}]
    subtitle = f"{n_open} open · {n_closed} closed · updated {datetime.now(tz).strftime('%b %-d %H:%M')}"
    return {"cardsV2": [{"cardId": "wiki-notify-board", "card": {
        "header": {"title": cfg.get("boardTitle", "Wiki Notifications"), "subtitle": subtitle},
        "sections": [{"widgets": widgets}],
    }}]}


EVENT_CAP = {"opened": "Opened", "comment": "Commented", "mention": "Mentioned",
             "closed": "Closed", "reopened": "Reopened", "activity": "Activity"}


def render_table_widgets(shown, tz, me="You"):
    """A table built from columns widgets, one divider-ruled row per item.
    Left column: the item ref (page + issue #) with the colored status
    beneath it. Right column: description with the View link |'d at its end,
    Created-by line, last-event line, and the user's involvement. Chat cards
    have no native table widget and the columns widget caps at 2; this is
    the closest readable equivalent."""
    import html as html_mod
    widgets = []
    for i, it in enumerate(shown):
        if i:
            widgets.append({"divider": {}})
        is_open = it["status"] == "open"
        status = (f'<font color="{OPEN_COLOR}"><b>OPEN</b></font>' if is_open
                  else f'<font color="{CLOSED_COLOR}"><b>CLOSED</b></font>')
        item = it["page"] + (f" #{it['num']}" if it["num"] else "")
        title = it["title"][:110] + ("…" if len(it["title"]) > 110 else "")
        title = html_mod.escape(title, quote=False)
        dim = lambda s: f'<font color="#8a94a6">{s}</font>'
        creator = it.get("creator") or it.get("actor") or "-"
        if it.get("invLabel") == "opened this":
            creator = me  # the mail footer ('you opened the issue') outranks our earliest-email guess
        actor = it.get("actor") or "-"
        event = EVENT_CAP.get(it["lastEvent"], it["lastEvent"] or "Activity")
        view = f'<a href="{it["url"]}">View</a>' if it["url"] else dim("-")
        fields = [
            f"{title} | {view}",
            dim(f"Created by {creator} on {fmt_datetime(it['openedAt'], tz)}"),
            dim(f"{event} by {actor} on {fmt_datetime(it['lastAt'], tz)}"),
        ]
        inv = it.get("invLabel", "")
        if it.get("ownsPage") and it.get("invRank", 0) < 3:
            inv = "owns this page"  # page authorship beats mention/comment/watch, not opened/assigned
        if inv:
            fields.append(dim(f"{me} {inv}"))
        widgets.append({"columns": {"columnItems": [
            {"horizontalSizeStyle": "FILL_MINIMUM_SPACE", "verticalAlignment": "TOP",
             "widgets": [{"textParagraph": {"text": f"<b>{item}</b><br>{status}"}}]},
            {"horizontalSizeStyle": "FILL_AVAILABLE_SPACE", "verticalAlignment": "TOP",
             "widgets": [{"textParagraph": {"text": "<br>".join(fields)}}]},
        ]}})
    return widgets


def render_block_widgets(shown, tz):
    widgets = []
    for it in shown:
        is_open = it["status"] == "open"
        status_html = (f'<font color="{OPEN_COLOR}"><b>OPEN</b></font>' if is_open
                       else f'<font color="{CLOSED_COLOR}"><b>CLOSED</b></font>')
        num = f" #{it['num']}" if it["num"] else ""
        top = f"{it['page']}{num} · opened {fmt_date(it['openedAt'], tz)}" + (
            f" · by {it['actor']}" if it["actor"] else "")
        bottom = f"last: {EVENT_LABEL.get(it['lastEvent'], it['lastEvent'])} · {fmt_datetime(it['lastAt'], tz)}"
        w = {"decoratedText": {
            "topLabel": top,
            "text": f"{status_html} {it['title'][:200]}",
            "bottomLabel": bottom,
            "wrapText": True,
        }}
        if it["url"]:
            w["decoratedText"]["button"] = {
                "text": "View", "onClick": {"openLink": {"url": it["url"]}}}
        widgets.append(w)
    return widgets


def items_hash(items):
    key = json.dumps(items, sort_keys=True)
    return hashlib.sha256(key.encode()).hexdigest()


def push_board(cfg, state, dry_run=False, force=False):
    """Re-render the board; post + swap the card if content changed."""
    h = items_hash(state["items"])
    if h == state.get("lastRenderHash") and not force:
        return False
    card = render_card(state["items"], cfg)
    if dry_run:
        return True
    old = state.get("boardMsg", "")
    name = webhook_post(cfg, card)
    state["boardMsg"] = name
    state["lastRenderHash"] = h
    save_state(state)
    if old:
        try:
            gapi(f"{CHAT}/{old}", "DELETE")
        except RuntimeError as e:
            log_line(f"WARN could not delete old board card {old}: {e}")
    log_line(f"OK board updated ({len(state['items'])} item(s)) {name}")
    return True


# ---------- sweeping ----------

def label_id(name):
    listing = gapi(f"{GMAIL}/labels")
    for lb in listing.get("labels", []):
        if lb["name"].lower() == name.lower():
            return lb["id"]
    created = gapi(f"{GMAIL}/labels", "POST", {
        "name": name, "labelListVisibility": "labelShow", "messageListVisibility": "show"})
    return created["id"]


def sweep(cfg, msg_id):
    action = cfg.get("action", "trash")
    if action == "trash":
        gapi(f"{GMAIL}/messages/{msg_id}/trash", "POST", {})
    elif action == "archive":
        gapi(f"{GMAIL}/messages/{msg_id}/modify", "POST",
             {"removeLabelIds": ["INBOX", "UNREAD"]})
    elif action == "label":
        lid = label_id(cfg.get("labelName", "wiki-notify"))
        gapi(f"{GMAIL}/messages/{msg_id}/modify", "POST",
             {"removeLabelIds": ["INBOX", "UNREAD"], "addLabelIds": [lid]})
    elif action == "keep":
        pass
    else:
        raise RuntimeError(f"unknown action '{action}' (use trash | archive | label | keep)")


# ---------- ingest ----------

def ingest(cfg, state, query, sweep_mail=True, dry_run=False):
    """Pull matching emails, fold into item state. Returns (ingested, skipped, errors)."""
    ingested, skipped, errors = [], 0, []
    page_token = None
    while True:
        params = {"q": query, "maxResults": 100}
        if page_token:
            params["pageToken"] = page_token
        try:
            listing = gapi(f"{GMAIL}/messages", params=params)
        except Exception as e:
            errors.append(f"list: {e}")
            log_line(f"ERROR list {e}")
            break
        for m in listing.get("messages", []) or []:
            mid = m["id"]
            if mid in state["processed"]:
                continue
            try:
                msg = gapi(f"{GMAIL}/messages/{mid}")
                need = cfg.get("requireInBody")
                if need and need not in plain_body(msg):
                    skipped += 1
                    continue
                ev = parse_notification(msg)
                if not ev:
                    skipped += 1
                    continue
                if not state.get("meFullName"):
                    tm = re.match(r'\s*"?([^"<]+?)"?\s*<', headers_of(msg).get("to", ""))
                    if tm:
                        state["meFullName"] = tm.group(1).strip()
                if not dry_run:
                    apply_event(state["items"], ev)
                    if sweep_mail:
                        sweep(cfg, mid)
                    state["processed"][mid] = now_iso()
                    save_state(state)
                    log_line(f"OK ingest {ev['event']} {ev['key']} ({cfg.get('action') if sweep_mail else 'no-sweep'}) {mid}")
                ingested.append(ev)
            except Exception as e:
                errors.append(f"{mid}: {e}")
                log_line(f"ERROR ingest {mid} {e}")
        page_token = listing.get("nextPageToken")
        if not page_token:
            break
    return ingested, skipped, errors


# ---------- commands ----------

def cmd_setup(as_json):
    if CONFIG_PATH.exists():
        emit(f"OK: config already exists at {CONFIG_PATH} (left untouched).")
        emit("Hint: run `wiki-notify-gchat health` to validate it.")
        return
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(DEFAULT_CONFIG, indent=2) + "\n")
    os.chmod(CONFIG_PATH, 0o600)
    emit(f"OK: wrote starter config to {CONFIG_PATH}")
    emit("Hint: create your own Chat space (e.g. 'Wiki Notifications'), then add an incoming webhook:")
    emit("Hint:   space name > Apps & integrations > Webhooks > Add webhook, copy the URL, then run:")
    emit("Hint:   wiki-notify-gchat config set webhook '<url>'   (YOUR secret; lives only in this 0600 config)")
    emit("Hint: wiki-notify-gchat config set timezone America/Chicago   (IANA zone; 6am purge follows it)")
    emit("Hint: wiki-notify-gchat config set meName Drew ; config set fullName 'Drew Owens'   (involvement line)")
    emit("Hint: wiki-notify-gchat config set action trash|archive|label|keep   (what happens to swept emails)")
    emit("Hint: then `health`, `run --dry-run`, `backfill --days 30`, and `install-cron`.")


def cmd_health(as_json):
    problems, hints = [], []
    code, out, _ = run_cmd(["adom-google", "status"])
    if code != 0:
        problems.append("adom-google is not installed")
        hints.append("Install/authorize adom-google first (wiki.adom.inc/adom/adom-google).")
    elif "Refresh token: present" not in out:
        problems.append("adom-google is not authorized")
        hints.append("Run `adom-google auth` — gmail.modify + chat.messages scopes are required.")
    cfg = None
    if not CONFIG_PATH.exists():
        problems.append("no config")
        hints.append("Run `wiki-notify-gchat setup`.")
    else:
        try:
            cfg = {**DEFAULT_CONFIG, **json.loads(CONFIG_PATH.read_text())}
            if not re.match(r"https://chat\.googleapis\.com/v1/spaces/[^/]+/messages\?", cfg.get("webhook", "")):
                problems.append("webhook is not set (or not a Chat incoming-webhook URL)")
                hints.append("Create a webhook in YOUR space (Apps & integrations > Webhooks) and paste it into config.")
            if cfg.get("action") not in ("trash", "archive", "label", "keep"):
                problems.append(f"unknown action '{cfg.get('action')}'")
                hints.append("Use trash | archive | label | keep.")
        except Exception as e:
            problems.append(f"config unreadable: {e}")
            hints.append(f"Fix {CONFIG_PATH}.")
    in_cron = CRON_MARKER in (run_cmd(["crontab", "-l"])[1] or "")
    if as_json:
        print(json.dumps({"status": "error" if problems else "ok",
                          "data": {"config": str(CONFIG_PATH), "cron": in_cron, "problems": problems},
                          "hints": hints}))
        sys.exit(1 if problems else 0)
    if problems:
        emit("ERROR: " + "; ".join(problems))
        for h in hints:
            emit(f"Hint: {h}")
        sys.exit(1)
    emit(f"OK: authorized, config valid, action={cfg['action']}, cron {'installed' if in_cron else 'NOT installed'}.")
    if not in_cron:
        emit("Hint: run `wiki-notify-gchat install-cron` so the board keeps updating without a live session.")


def cmd_run(as_json, dry_run=False, quiet=False):
    # single-flight: cron, watch fallback, and ensure may all fire — never overlap
    import fcntl
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    lock = open(STATE_DIR / "run.lock", "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        if not quiet:
            emit("OK: another pass is already running; skipped.")
        return
    cfg = load_config(as_json)
    state = load_state()
    ingested, skipped, errors = ingest(cfg, state, cfg["query"], sweep_mail=True, dry_run=dry_run)
    dropped = [] if dry_run else purge_items(state["items"], cfg)
    if dropped:
        save_state(state)
        log_line(f"OK purged {len(dropped)} closed item(s): {', '.join(dropped)}")
    board_changed = False
    if not dry_run:
        try:
            annotate_page_ownership(state, cfg.get("fullName") or state.get("meFullName", ""))
            board_changed = push_board(cfg, state)
        except Exception as e:
            errors.append(f"board: {e}")
            log_line(f"ERROR board {e}")
    data = {"ingested": [f"{e['event']} {e['key']}" for e in ingested], "skipped": skipped,
            "purged": dropped, "boardUpdated": board_changed, "errors": errors}
    if as_json:
        print(json.dumps({"status": "error" if errors else "ok", "data": data, "hints": []}))
        sys.exit(1 if errors else 0)
    verb = "would ingest" if dry_run else "ingested"
    if not quiet or ingested or dropped or errors:
        emit(f"{'ERROR' if errors else 'OK'}: {verb} {len(ingested)}, purged {len(dropped)}, "
             f"board {'updated' if board_changed else 'unchanged'}, {len(errors)} error(s).")
    for e in ingested:
        emit(f"ITEM: {e['event']:8s} {e['key']}  {e['title'][:70]}")
    for err in errors:
        emit(f"ERROR: {err}")
    if errors:
        emit("Hint: failed emails stay in the inbox and retry next pass; check `wiki-notify-gchat health`.")
        sys.exit(1)
    if dry_run and ingested:
        emit("Hint: run `wiki-notify-gchat run` (no --dry-run) to ingest + sweep + update the board.")


def cmd_backfill(as_json, days=30, dry_run=False):
    cfg = load_config(as_json)
    state = load_state()
    base = re.sub(r"\s*in:inbox\s*", " ", cfg["query"]).strip()
    query = f"{base} newer_than:{days}d in:anywhere"
    ingested, skipped, errors = ingest(cfg, state, query, sweep_mail=False, dry_run=dry_run)
    board_changed = False
    if not dry_run:
        try:
            annotate_page_ownership(state, cfg.get("fullName") or state.get("meFullName", ""))
            board_changed = push_board(cfg, state)
        except Exception as e:
            errors.append(f"board: {e}")
    if as_json:
        print(json.dumps({"status": "error" if errors else "ok",
                          "data": {"ingested": len(ingested), "skipped": skipped,
                                   "boardUpdated": board_changed, "errors": errors}, "hints": []}))
        sys.exit(1 if errors else 0)
    emit(f"{'ERROR' if errors else 'OK'}: backfilled {len(ingested)} event(s) from the last {days}d "
         f"({skipped} skipped), board {'updated' if board_changed else 'unchanged'}.")
    for e in ingested:
        emit(f"ITEM: {e['event']:8s} {e['key']}  {e['title'][:70]}")
    for err in errors:
        emit(f"ERROR: {err}")
    if errors:
        sys.exit(1)


def cmd_board(as_json, dry_run=False):
    cfg = load_config(as_json)
    state = load_state()
    if dry_run:
        card = render_card(state["items"], cfg)
        print(json.dumps(card, indent=2)[:6000])
        return
    annotate_page_ownership(state, cfg.get("fullName") or state.get("meFullName", ""))
    changed = push_board(cfg, state, force=True)
    emit(f"OK: board {'reposted' if changed else 'unchanged'} ({len(state['items'])} item(s)).")


def cmd_status(as_json):
    state = load_state()
    items = state.get("items", {})
    n_open = sum(1 for it in items.values() if it["status"] == "open")
    in_cron = CRON_MARKER in (run_cmd(["crontab", "-l"])[1] or "")
    tail = "\n".join(LOG_PATH.read_text().splitlines()[-5:]) if LOG_PATH.exists() else ""
    if as_json:
        print(json.dumps({"status": "ok", "data": {
            "items": len(items), "open": n_open, "closed": len(items) - n_open,
            "processed": len(state.get("processed", {})), "boardMsg": state.get("boardMsg", ""),
            "cron": in_cron, "logTail": tail}, "hints": []}))
        return
    emit(f"OK: {len(items)} item(s) on board ({n_open} open, {len(items) - n_open} closed) · "
         f"{len(state.get('processed', {}))} email(s) processed all-time · cron: {'yes' if in_cron else 'no'}")
    if tail:
        emit(tail)
    if not in_cron:
        emit("Hint: run `wiki-notify-gchat install-cron` to keep the board live.")


def cmd_watch(as_json):
    cfg = load_config(as_json)
    interval = int(cfg.get("pollSeconds", 120))
    emit(f"OK: watching every {interval}s (Ctrl-C to stop).")
    while True:
        try:
            cmd_run(False, dry_run=False, quiet=True)
        except SystemExit:
            pass
        time.sleep(interval)


def cmd_install_cron(as_json):
    me = os.path.realpath(sys.argv[0])
    cfg = load_config(as_json)
    interval_min = max(1, int(cfg.get("pollSeconds", 120)) // 60)
    entry = f"*/{interval_min} * * * * {me} run --quiet >> {STATE_DIR}/cron.log 2>&1 {CRON_MARKER}"
    reboot_entry = f"@reboot {me} ensure --quiet --force >> {STATE_DIR}/cron.log 2>&1 {CRON_MARKER}"
    code, out, _ = run_cmd(["crontab", "-l"])
    lines = [l for l in (out if code == 0 else "").splitlines() if CRON_MARKER not in l]
    lines += [entry, reboot_entry]
    code, _, err = run_cmd(["crontab", "-"], stdin_text="\n".join(lines) + "\n")
    if code != 0:
        die(f"could not install crontab: {err.strip()}",
            ["Check that cron is available in this container."], as_json)
    wired = ["cron */%d min" % interval_min, "@reboot ensure"]
    if wire_bashrc(me):
        wired.append("bashrc recovery snippet")
    if wire_claude_hook(me):
        wired.append("Claude prompt hook")
    emit(f"OK: installed — {', '.join(wired)}.")
    emit("Hint: cold boots self-heal: `ensure` restarts cron via passwordless sudo (or runs a watch")
    emit("Hint: fallback) the next time a terminal opens or a Claude prompt fires — no manual sudo needed.")
    cmd_ensure(as_json, quiet=True, force=True)
    emit(f"Hint: activity log at {LOG_PATH}; remove everything with `wiki-notify-gchat uninstall-cron`.")


ENSURE_STAMP = STATE_DIR / "ensure.stamp"
WATCH_PID = STATE_DIR / "watch.pid"
BASHRC_MARKER = "# wiki-notify-gchat cold-boot recovery"


def cron_running():
    code, out, _ = run_cmd(["bash", "-c", "pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1"])
    return code == 0


def watch_alive():
    try:
        pid = int(WATCH_PID.read_text().strip())
        os.kill(pid, 0)
        return pid
    except Exception:
        return None


def cmd_ensure(as_json, quiet=False, force=False):
    """Self-heal after a cold boot: start cron (passwordless sudo), or fall
    back to a detached watch loop; then run one pass. Cheap + throttled, so
    it is safe to call from bashrc / a Claude prompt hook on every trigger."""
    STATE_DIR.mkdir(parents=True, exist_ok=True)
    if not force and ENSURE_STAMP.exists():
        if time.time() - ENSURE_STAMP.stat().st_mtime < 900:
            if not quiet:
                emit("OK: checked recently; skipped (use --force to override).")
            return
    ENSURE_STAMP.write_text(str(int(time.time())))
    healed = []
    up = cron_running()
    if not up:
        run_cmd(["sudo", "-n", "service", "cron", "start"])
        up = cron_running()
        if up:
            healed.append("started cron")
            log_line("OK ensure: started cron after cold boot")
    if up:
        pid = watch_alive()
        if pid:
            try:
                os.kill(pid, 15)
            except Exception:
                pass
            WATCH_PID.unlink(missing_ok=True)
            healed.append("stopped watch fallback (cron is back)")
    else:
        if not watch_alive():
            me = os.path.realpath(sys.argv[0])
            p = subprocess.Popen(
                ["nohup", me, "watch"],
                stdout=open(STATE_DIR / "watch.log", "a"), stderr=subprocess.STDOUT,
                start_new_session=True)
            WATCH_PID.write_text(str(p.pid))
            healed.append(f"cron unavailable - started detached watch loop (pid {p.pid})")
            log_line(f"OK ensure: watch fallback started pid {p.pid}")
    try:
        cmd_run(False, dry_run=False, quiet=True)
    except SystemExit:
        pass
    if healed:
        emit(f"OK: wiki-notify-gchat recovered: {'; '.join(healed)}.")
    elif not quiet:
        emit(f"OK: healthy - cron {'running' if up else 'down'}, board current.")


def wire_bashrc(me):
    rc = Path.home() / ".bashrc"
    content = rc.read_text() if rc.exists() else ""
    if BASHRC_MARKER in content:
        return False
    with rc.open("a") as f:
        f.write(f"\n{BASHRC_MARKER}\n({me} ensure --quiet >/dev/null 2>&1 &)\n")
    return True


def wire_claude_hook(me):
    settings = Path.home() / ".claude" / "settings.json"
    try:
        data = json.loads(settings.read_text()) if settings.exists() else {}
    except Exception:
        return False
    if "wiki-notify-gchat" in json.dumps(data):
        return False
    hooks = data.setdefault("hooks", {}).setdefault("UserPromptSubmit", [])
    hooks.append({"hooks": [{"type": "command", "command": f"{me} ensure --quiet", "timeout": 15}]})
    settings.parent.mkdir(parents=True, exist_ok=True)
    settings.write_text(json.dumps(data, indent=2) + "\n")
    return True


def cmd_uninstall_cron(as_json):
    code, out, _ = run_cmd(["crontab", "-l"])
    lines = [l for l in (out if code == 0 else "").splitlines() if CRON_MARKER not in l]
    run_cmd(["crontab", "-"], stdin_text="\n".join(lines) + ("\n" if lines else ""))
    pid = watch_alive()
    if pid:
        try:
            os.kill(pid, 15)
        except Exception:
            pass
        WATCH_PID.unlink(missing_ok=True)
    rc = Path.home() / ".bashrc"
    if rc.exists() and BASHRC_MARKER in rc.read_text():
        kept, skip = [], False
        for l in rc.read_text().splitlines():
            if l.strip() == BASHRC_MARKER:
                skip = True
                continue
            if skip:
                skip = False
                continue
            kept.append(l)
        rc.write_text("\n".join(kept) + "\n")
    settings = Path.home() / ".claude" / "settings.json"
    try:
        data = json.loads(settings.read_text())
        ups = data.get("hooks", {}).get("UserPromptSubmit", [])
        data["hooks"]["UserPromptSubmit"] = [
            h for h in ups
            if "wiki-notify-gchat" not in json.dumps(h)]
        settings.write_text(json.dumps(data, indent=2) + "\n")
    except Exception:
        pass
    emit("OK: cron entries, watch fallback, bashrc snippet, and Claude hook removed.")


INT_KEYS = {"pollSeconds", "purgeHour", "maxRows"}


def mask_webhook(url):
    return re.sub(r"(token=)[^&]+", r"\1***", url) if url else "(not set)"


def cmd_config(as_json, args):
    if not CONFIG_PATH.exists():
        die("no config found.", ["Run `wiki-notify-gchat setup` first."], as_json)
    cfg_raw = json.loads(CONFIG_PATH.read_text())
    if not args or args[0] == "show":
        cfg = {**DEFAULT_CONFIG, **cfg_raw}
        if as_json:
            shown = dict(cfg, webhook=mask_webhook(cfg.get("webhook", "")))
            print(json.dumps({"status": "ok", "data": shown, "hints": []}))
            return
        emit(f"OK: config at {CONFIG_PATH}")
        for k in DEFAULT_CONFIG:
            v = cfg.get(k, "")
            emit(f"  {k} = {mask_webhook(v) if k == 'webhook' else v!r}")
        emit("Hint: change any value with `wiki-notify-gchat config set <key> <value>`.")
        emit("Hint: after changes run `wiki-notify-gchat run --dry-run`, then `board` to re-render.")
        return
    if args[0] == "set":
        if len(args) < 3:
            die("usage: config set <key> <value>",
                [f"keys: {', '.join(DEFAULT_CONFIG)}"], as_json)
        key, value = args[1], " ".join(args[2:])
        if key not in DEFAULT_CONFIG:
            die(f"unknown key '{key}'", [f"keys: {', '.join(DEFAULT_CONFIG)}"], as_json)
        if key in INT_KEYS:
            try:
                value = int(value)
            except ValueError:
                die(f"{key} needs a number", [f"e.g. `config set {key} 120`"], as_json)
        if key == "action" and value not in ("trash", "archive", "label", "keep"):
            die(f"invalid action '{value}'", ["Use trash | archive | label | keep."], as_json)
        if key == "style" and value not in ("table", "blocks"):
            die(f"invalid style '{value}'", ["Use table | blocks."], as_json)
        if key == "webhook" and not re.match(
                r"https://chat\.googleapis\.com/v1/spaces/[^/]+/messages\?", str(value)):
            die("that does not look like a Chat incoming-webhook URL.",
                ["Copy it from your space: name > Apps & integrations > Webhooks."], as_json)
        cfg_raw[key] = value
        CONFIG_PATH.write_text(json.dumps(cfg_raw, indent=2) + "\n")
        os.chmod(CONFIG_PATH, 0o600)
        shown = mask_webhook(value) if key == "webhook" else value
        emit(f"OK: {key} = {shown}")
        emit("Hint: run `wiki-notify-gchat health` to validate, then `run --dry-run` to preview.")
        return
    die(f"unknown config subcommand '{args[0]}'", ["Use `config show` or `config set <key> <value>`."], as_json)


def cmd_install(as_json):
    target = Path.home() / ".local" / "bin" / "wiki-notify-gchat"
    target.parent.mkdir(parents=True, exist_ok=True)
    src = Path(os.path.realpath(sys.argv[0]))
    if target != src:
        if target.exists() or target.is_symlink():
            target.unlink()
        target.symlink_to(src)
    emit(f"OK: installed at {target}")
    emit("Hint: run `wiki-notify-gchat setup`, then `health`, then `run --dry-run`.")


USAGE = """wiki-notify-gchat — wiki notification emails become a live Google Chat board
Usage: wiki-notify-gchat <command> [--json] [--dry-run] [--quiet] [--days N]
Commands:
  setup           write a starter config (~/.config/wiki-notify-gchat/config.json)
  config          show config, or `config set <key> <value>` (webhook, action, timezone, ...)
  health          check auth, config, and cron
  run             one poll pass: ingest + sweep emails, purge, update the board card
  backfill        seed the board from past emails (default 30 days, incl. trash) - no sweeping
  board           force-repost the board card (--dry-run prints the card JSON)
  status          board contents summary + recent activity
  watch           poll continuously in the foreground
  install-cron    poll from cron + wire cold-boot self-healing (@reboot, bashrc, Claude hook)
  ensure          self-heal now: start cron (passwordless sudo) or a watch fallback, then run a pass
  uninstall-cron  remove cron entries, watch fallback, bashrc snippet, Claude hook
  install         symlink onto PATH
"""


def main():
    args = sys.argv[1:]
    as_json = "--json" in args
    dry = "--dry-run" in args
    quiet = "--quiet" in args
    days = 30
    days_val_idx = -1
    if "--days" in args:
        days_val_idx = args.index("--days") + 1
        try:
            days = int(args[days_val_idx])
        except (IndexError, ValueError):
            die("--days needs a number", ["e.g. `wiki-notify-gchat backfill --days 14`"], as_json)
    positional = [a for i, a in enumerate(args) if not a.startswith("--") and i != days_val_idx]
    cmd = positional[0] if positional else "help"
    table = {
        "setup": lambda: cmd_setup(as_json),
        "config": lambda: cmd_config(as_json, positional[1:]),
        "health": lambda: cmd_health(as_json),
        "run": lambda: cmd_run(as_json, dry, quiet),
        "backfill": lambda: cmd_backfill(as_json, days, dry),
        "board": lambda: cmd_board(as_json, dry),
        "status": lambda: cmd_status(as_json),
        "watch": lambda: cmd_watch(as_json),
        "install-cron": lambda: cmd_install_cron(as_json),
        "ensure": lambda: cmd_ensure(as_json, quiet, "--force" in sys.argv),
        "uninstall-cron": lambda: cmd_uninstall_cron(as_json),
        "install": lambda: cmd_install(as_json),
    }
    if cmd not in table:
        print(USAGE)
        if cmd != "help":
            die(f"unknown command '{cmd}'", ["See usage above."], as_json)
        return
    table[cmd]()


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except KeyboardInterrupt:
        sys.exit(130)
    except Exception as e:
        log_line(f"ERROR unhandled {e}")
        emit(f"ERROR: unhandled: {e}")
        emit("Hint: run `wiki-notify-gchat health`; details logged to "
             f"{LOG_PATH}.")
        sys.exit(1)
