#!/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",
}

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):
    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 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"))
    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,
    }


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 "", "url": ev["url"] or "",
              "closedAt": None, "events": 0}
        items[ev["key"]] = it
    it["events"] += 1
    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)
    if cfg.get("style", "table") == "table":
        widgets = render_table_widgets(shown, tz)
    else:
        widgets = render_block_widgets(shown, tz)
    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}],
    }}]}


def render_table_widgets(shown, tz):
    """One text block: a pipe-separated table, header row + one line per item.
    Chat cards have no native table widget (columns caps at 2), so consistent
    pipe columns are the closest faithful table rendering."""
    import html as html_mod
    cells = ["STATUS", "ITEM", "TITLE", "BY", "OPENED", "LAST", "LINK"]
    lines = ["<b>" + " | ".join(cells) + "</b>"]
    for it in shown:
        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"][:48] + ("…" if len(it["title"]) > 48 else "")
        title = html_mod.escape(title, quote=False)
        by = it["actor"] or "-"
        opened = fmt_date(it["openedAt"], tz)
        last = f"{EVENT_LABEL.get(it['lastEvent'], it['lastEvent'])} {fmt_datetime(it['lastAt'], tz)}"
        link = f'<a href="{it["url"]}">View</a>' if it["url"] else "-"
        lines.append(" | ".join([status, item, title, by, opened, last, link]))
    return [{"textParagraph": {"text": "<br>".join(lines)}}]


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
        listing = gapi(f"{GMAIL}/messages", params=params)
        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 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.")
    emit(f"Hint: paste that URL into \"webhook\" in {CONFIG_PATH} — it is YOUR secret; never commit it.")
    emit("Hint: action is 'trash' (default, recoverable 30 days), 'archive', 'label' (uses labelName), or 'keep'.")
    emit("Hint: set \"timezone\" (IANA, e.g. America/New_York) so the 6am closed-item purge matches your morning.")
    emit("Hint: then `wiki-notify-gchat 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):
    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:
            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:
            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
    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}"
    code, out, _ = run_cmd(["crontab", "-l"])
    lines = [l for l in (out if code == 0 else "").splitlines() if CRON_MARKER not in l]
    lines.append(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)
    emit(f"OK: cron installed — polls every {interval_min} min.")
    c, out, _ = run_cmd(["bash", "-c", "service cron status 2>&1 || true"])
    if "running" not in out:
        emit("Hint: cron service may not be running — `sudo service cron start` (needed after cold boot).")
    emit(f"Hint: activity log at {LOG_PATH}; remove with `wiki-notify-gchat uninstall-cron`.")


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 ""))
    emit("OK: cron entry removed.")


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)
  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 (survives session restarts)
  uninstall-cron  remove the cron entry
  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
    if "--days" in args:
        try:
            days = int(args[args.index("--days") + 1])
        except (IndexError, ValueError):
            die("--days needs a number", ["e.g. `wiki-notify-gchat backfill --days 14`"], as_json)
    positional = [a for a in args if not a.startswith("--") and not a.isdigit()]
    cmd = positional[0] if positional else "help"
    table = {
        "setup": lambda: cmd_setup(as_json),
        "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),
        "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__":
    main()
