#!/opt/bux/venv/bin/python
"""agency-report — record + post an Agency suggestion to Telegram.

Always:
  1. records the suggestion in /var/lib/bux/agency.db
  2. posts the body to TG with inline-keyboard buttons (default 3 in a 2+1 grid)
  3. wires the message_id back into the row so a button tap can record
     the user's decision against the right suggestion.

Default buttons: ✅ Yes · ⏭ Skip · ✏️ Edit
Default layout: row 1 = [Yes][Skip], row 2 = [Edit].

Each default button has a *kind* the bot uses to pick semantics:
  • Yes  → kind=action  → bot opens a fresh forum topic, dispatches the
                          suggestion's --prompt there as a new lane.
  • Skip → kind=dismiss → bot records the dismissal, posts a 1-line ack,
                          does NOT call the LLM. (Cheap "saw it, move on".)
  • Edit → kind=refine  → bot opens a fresh forum topic with the question
                          "What would you change?" — agent waits for the
                          user's reply, no immediate LLM dispatch.

Callback data shape: `agcy:<thread>:<idx>:<kind>` where kind is one of
{action, dismiss, refine, custom}. Custom button sets passed via --button
all get kind=custom (existing dispatch-in-same-topic behavior).

Custom buttons: pass --button (repeatable). Layout wraps in pairs of two.

Canonical card layout (HTML parse mode). Order is locked; the *number*
of expandable blocks is variable (0, 1, 2, N — your call):

  [optional image — skip when text alone is clearer]
  <emoji> <b>headline</b>           ← write the specific action here:
                                       "Reply to <user> on Slack: …" or
                                       "Merge PR #347" — *not* "Agency 95"
                                       or a generic source description.
  <optional subhead>

  <blockquote expandable>…</blockquote>   ← zero or more
  <blockquote expandable>…</blockquote>
  …

  <i>source: <link></i>             ← at the very end, italic, optional.

  [Yes] [Skip]
  [Edit]

Blocks are specified one of two ways:
  • Legacy 2-block: --draft (becomes "📝 Drafted action") and --reasoning
    (becomes "📎 Context"). Backward-compatible with the original PR.
  • Flexible: --block (repeatable, JSON object). Each --block becomes
    one expandable. Use this when you have a different shape — e.g.
    three variants A/B/C, or a single "context" block, or zero blocks
    (--info-only with just headline + source link).

Yes-tap routing — auto-default by thread context:
  • If the card is posted inside any forum topic, --spawn-topic defaults
    to OFF — Yes/Edit dispatch in the same goal/topic. The agent may
    create subtopics later if the work genuinely needs fan-out.
  • If there is no current forum topic, --spawn-topic defaults to ON so
    the accepted work gets a lane.

Routing policy:
  • Default in forum topics: in-place "✅ Yes" for short work.
  • Use --spawn-topic / "🧵 Yes (new thread)" only for bigger projects:
    recurring monitors, multi-step investigations, work likely to take
    >10 tool calls, or anything that will produce multiple follow-ups.

Override the auto-default with:
  • --spawn-topic    — force spawn (always create a new topic on Yes/Edit)
  • --no-spawn-topic — force in-place (always run in the current thread)

Default-button labels reflect the chosen routing so the user can tell
from the button alone whether tapping forks a topic:
  • spawn:    "🧵 Yes (new thread)" / "⏭ Skip" / "🧵 Edit (new thread)"
  • in-place: "✅ Yes" / "⏭ Skip" / "✏️ Edit"

If --image (URL), --image-file (local path), or --image-text (auto-rendered
placeholder) is given, the image renders above the body. For bodies that
fit Telegram's 1024-char caption budget the card is sent via sendPhoto;
otherwise it falls back to sendMessage with a large link-preview-image
above the text (visually identical, no length cap).

`--image-file` uploads a local file via multipart so the model never has
to know the bot token or build a public URL. Auto-detects content type
from the file extension (png/jpg/jpeg/gif/webp).

`--info-only` strips the inline keyboard entirely. Use for FYI cards
(weekly stats, observations) where there's nothing to act on. The row
is still recorded in the DB so dedup works.

Field mapping:
  --emoji              prepended to the bold headline
  --title              the headline (required)
  --source-label       short clickable label like "GitHub #347"
  --source-url         URL the source label links to
  --subhead            optional one-line under the headline
  --image              direct image URL
  --image-file         path to a local image file (multipart upload)
  --image-text         alt: shorthand text → auto-generated local card image
  --block              repeatable; each value is a JSON object
                       {emoji, title, body[, body_html]} → one expandable.
                       When given, fully overrides --draft / --reasoning.
                       Use for 3-variant pickers, info-only cards, or any
                       non-2-block shape.
  --draft              legacy: actionable content (first expandable).
                       Falls back to --prompt when omitted.
  --reasoning          context block (second expandable, "📎 Context").
                       Falls back to --description when omitted.
  --description        legacy alias for --reasoning (kept for back-compat)
  --prompt             exact action that runs if user taps Yes — also fills
                       draft when --draft is not given.
  --importance         high|med|low (default med)
  --source             stable slug for dedupe
  --skip-if-exists     suppress posting if the source slug already has a
                       non-pending row
  --button             repeatable; overrides the default 3-button set.
                       Custom buttons all get kind=custom.
  --info-only          omit the inline keyboard entirely (FYI cards).
  --thread-id          forum topic to post into (defaults to $TG_THREAD_ID)

Inputs are HTML-escaped by default. To embed raw HTML in a field use the
`--<field>-html` long form (e.g. `--draft-html '<code>...</code>'`).
"""
from __future__ import annotations

import argparse
import html
import json
import mimetypes
import os
import secrets
import sys
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path

REPO_AGENT = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO_AGENT))

import agency_db  # noqa: E402

# (label, kind) tuples. Kind drives the bot's callback semantics.
# Two label variants — picked at post time based on --spawn-topic so
# the user can tell from the button alone whether tapping will run
# in-place or fork a new forum topic.
DEFAULT_BUTTONS_INPLACE: list[tuple[str, str]] = [
    ("✅ Yes", "action"),
    ("⏭ Skip", "dismiss"),
    ("✏️ Edit", "refine"),
]
DEFAULT_BUTTONS_SPAWN: list[tuple[str, str]] = [
    ("🧵 Yes (new thread)", "action"),
    ("⏭ Skip", "dismiss"),
    ("🧵 Edit (new thread)", "refine"),
]
TG_CAPTION_LIMIT = 1024


def _read_kv(path: Path) -> dict:
    out: dict[str, str] = {}
    try:
        for line in path.read_text().splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, v = line.split("=", 1)
            out[k.strip()] = v.strip().strip('"').strip("'")
    except FileNotFoundError:
        pass
    return out


def bot_token() -> str:
    tok = os.environ.get("TG_BOT_TOKEN")
    if tok:
        return tok
    tok = _read_kv(Path("/etc/bux/tg.env")).get("TG_BOT_TOKEN")
    if not tok:
        sys.exit("agency-report: TG_BOT_TOKEN missing (env or /etc/bux/tg.env)")
    return tok


def chat_id() -> int:
    raw = Path("/etc/bux/tg-allowed.txt").read_text().splitlines()
    for line in raw:
        line = line.strip()
        if line:
            return int(line)
    sys.exit("agency-report: no bound chat (run /start in TG first)")


def _esc(value: str | None) -> str | None:
    if value is None:
        return None
    return html.escape(value, quote=False)


def _resolve_field(plain: str | None, raw_html: str | None) -> str | None:
    """Prefer raw HTML if provided, else escape the plain value."""
    if raw_html is not None:
        return raw_html
    return _esc(plain)


def _wrap_image_text(text: str, max_per_line: int = 22) -> str:
    """Word-wrap image labels so each line stays short."""
    words = text.split()
    lines: list[str] = []
    current = ""
    for word in words:
        if not current:
            current = word
        elif len(current) + 1 + len(word) <= max_per_line:
            current += " " + word
        else:
            lines.append(current)
            current = word
    if current:
        lines.append(current)
    return "\n".join(lines)


def _font(path: str, size: int):
    from PIL import ImageFont

    return ImageFont.truetype(path, size)


def _text_width(draw, text: str, font) -> int:
    box = draw.textbbox((0, 0), text, font=font)
    return box[2] - box[0]


def _fit_font(draw, text: str, max_width: int, start_size: int, min_size: int):
    bold = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
    for size in range(start_size, min_size - 1, -4):
        font = _font(bold, size)
        if _text_width(draw, text, font) <= max_width:
            return font
    return _font(bold, min_size)


def _render_image_text_file(args: argparse.Namespace) -> str | None:
    """Render --image-text as the canonical gradient card image."""
    if not args.image_text:
        return None
    try:
        from PIL import Image, ImageDraw, ImageFont
    except Exception:
        return None

    # AGENCY.md tells callers to write `\n` in shell args; bash double-quotes
    # don't expand the escape, so argv carries the literal 2-char sequence.
    # Translate it (and `\\n`) into real newlines before splitting, otherwise
    # the renderer draws a literal "\n" glyph in the image.
    text = args.image_text.replace("\\\\n", "\n").replace("\\n", "\n")
    raw_lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    if not raw_lines:
        raw_lines = [text.strip()]
    if len(raw_lines) == 1:
        raw_lines = _wrap_image_text(raw_lines[0], max_per_line=22).splitlines()
    lines = raw_lines[:3]

    width, height = 1080, 540
    top = (31, 41, 55)
    bottom = (20, 184, 166)
    if args.importance == "high":
        top, bottom = (76, 29, 149), (236, 72, 153)
    elif args.importance == "low":
        top, bottom = (51, 65, 85), (100, 116, 139)

    img = Image.new("RGB", (width, height), top)
    pix = img.load()
    for y in range(height):
        t = y / (height - 1)
        color = tuple(int(top[i] * (1 - t) + bottom[i] * t) for i in range(3))
        for x in range(width):
            pix[x, y] = color

    draw = ImageDraw.Draw(img)
    draw.rectangle((0, 0, 10, height), fill=(255, 255, 255))

    emoji = args.emoji or ""
    if emoji:
        try:
            emoji_font = ImageFont.truetype(
                "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf", 109
            )
            draw.text((70, 55), emoji, font=emoji_font, embedded_color=True)
        except Exception:
            fallback = _font("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 88)
            draw.text((70, 60), emoji, font=fallback, fill=(255, 255, 255))

    y = 230 if emoji else 170
    max_w = width - 140
    for i, line in enumerate(lines):
        if i == 0:
            font = _fit_font(draw, line, max_w, 104, 58)
        else:
            font = _fit_font(draw, line, max_w, 68, 42)
        draw.text((70, y), line, font=font, fill=(255, 255, 255))
        y += 115 if i == 0 else 78

    out = Path(tempfile.gettempdir()) / f"agency-card-{secrets.token_hex(8)}.png"
    img.save(out)
    return str(out)


def _resolve_image_url(args: argparse.Namespace) -> str | None:
    if args.image:
        return args.image
    if args.image_text:
        # Emergency fallback only. Normal --image-text is rendered locally
        # and uploaded via --image-file.
        wrapped = _wrap_image_text(args.image_text, max_per_line=22)
        encoded = urllib.parse.quote(wrapped)
        return f"https://placehold.co/1200x630/2a0a3a/ff66cc/png?text={encoded}&font=montserrat"
    return None


def _coerce_button_label(raw: str) -> str:
    """Defensively coerce a --button value to a label string.

    Some agents call --button with a JSON object like
    `{"text":"❌ No","value":"no"}` (mistaking the API for --block, which
    *does* take JSON). Without this guard the entire JSON blob ends up
    as the rendered button label — visible noise in TG, broken card.
    Coerce: if the raw value parses as a dict with a `text`/`label`/
    `name` field, use that. Otherwise pass through unchanged."""
    if not raw or raw[:1] not in ("{", "["):
        return raw
    try:
        parsed = json.loads(raw)
    except Exception:
        return raw
    if isinstance(parsed, dict):
        for key in ("text", "label", "name"):
            v = parsed.get(key)
            if isinstance(v, str) and v.strip():
                return v
    return raw


def _resolve_buttons(args: argparse.Namespace) -> list[tuple[str, str]]:
    """Returns list of (label, kind). Custom buttons all get kind='custom'.
    Default-set labels reflect --spawn-topic so the user can tell from
    the button alone whether tapping will run here or fork a topic."""
    if args.button:
        return [(_coerce_button_label(label), "custom") for label in args.button]
    return list(DEFAULT_BUTTONS_SPAWN if args.spawn_topic else DEFAULT_BUTTONS_INPLACE)


def _build_keyboard(
    buttons: list[tuple[str, str]], thread: int
) -> list[list[dict]]:
    """Wrap buttons in rows of 2 (singleton last row when count is odd)."""
    cells = [
        {
            "text": label,
            "callback_data": f"agcy:{thread}:{i}:{kind}",
        }
        for i, (label, kind) in enumerate(buttons)
    ]
    rows: list[list[dict]] = []
    for i in range(0, len(cells), 2):
        rows.append(cells[i : i + 2])
    return rows


def _resolve_blocks(args: argparse.Namespace) -> list[dict]:
    """Build the ordered list of expandable blocks rendered in the card.

    Two ways to specify them:
      • --block JSON (repeatable): full control. Each value is a JSON
        object {"emoji": "📝", "title": "Variant A", "body": "..."}.
        When ANY --block is passed, the legacy --draft / --reasoning
        flags are ignored — --block fully overrides them.
      • Legacy --draft / --reasoning (each renders one block): the
        original 2-block shape, useful for the common case.

    Body content is HTML-passed-through when --block is used; the caller
    is responsible for escaping (use the helper to wrap raw text). For
    --draft / --reasoning, the existing escape rules apply (raw HTML via
    --draft-html / --reasoning-html, otherwise auto-escaped).

    Returns a list of {emoji, title, body_html} dicts.
    """
    if args.block:
        out: list[dict] = []
        for raw in args.block:
            try:
                spec = json.loads(raw)
            except Exception as e:
                sys.exit(f"agency-report: bad --block JSON: {e}")
            if not isinstance(spec, dict) or "body" not in spec:
                sys.exit("agency-report: --block must be a JSON object with at least 'body'.")
            out.append({
                "emoji": spec.get("emoji") or "",
                "title": spec.get("title") or "",
                "body_html": (
                    spec["body"]
                    if spec.get("body_html")
                    else html.escape(spec["body"], quote=False)
                ),
            })
        return out

    blocks: list[dict] = []
    draft = _resolve_field(args.draft, args.draft_html)
    if not draft and args.prompt:
        draft = f"<pre>{html.escape(args.prompt, quote=False)}</pre>"
    if draft:
        blocks.append({
            "emoji": args.draft_emoji or "📝",
            "title": args.draft_title or "Drafted action",
            "body_html": draft,
        })
    reasoning = _resolve_field(args.reasoning, args.reasoning_html)
    if not reasoning and args.description:
        reasoning = _esc(args.description)
    if reasoning:
        blocks.append({
            "emoji": args.reasoning_emoji or "📎",
            "title": args.reasoning_title or "Context",
            "body_html": reasoning,
        })
    return blocks


def _build_body(args: argparse.Namespace) -> str:
    """Canonical layout — order is locked:
        1. headline (emoji + bold title)
        2. optional one-line subhead
        3. zero-or-more expandable blocks
        4. source link, italic, at the very end (if provided)
    """
    parts: list[str] = []

    emoji = (args.emoji or "").strip()
    headline = _resolve_field(args.title, args.title_html)
    parts.append(f"{emoji} <b>{headline}</b>".lstrip())

    subhead = _resolve_field(args.subhead, args.subhead_html)
    if subhead:
        parts.append(subhead)

    blocks = _resolve_blocks(args)
    for i, block in enumerate(blocks):
        title = html.escape(block["title"], quote=False) if block["title"] else ""
        e = block["emoji"]
        head = f"{e} <b>{title}</b>" if title else (e or "")
        head_line = head.strip()
        body = block["body_html"]
        # Blank line above the first block to separate it from headline/subhead.
        prefix = "\n" if i == 0 else ""
        if head_line:
            parts.append(f"{prefix}<blockquote expandable>{head_line}\n{body}</blockquote>")
        else:
            parts.append(f"{prefix}<blockquote expandable>{body}</blockquote>")

    src_label = _resolve_field(args.source_label, args.source_label_html)
    if src_label and args.source_url:
        url = html.escape(args.source_url, quote=True)
        parts.append(f'\n<i>source: <a href="{url}">{src_label}</a></i>')
    elif src_label:
        parts.append(f"\n<i>source: {src_label}</i>")

    return "\n".join(parts)


def _multipart_post(token: str, method: str, fields: dict, files: dict) -> dict:
    """Build a multipart/form-data POST. files = {name: (filename, bytes, ctype)}."""
    boundary = "----buxform" + secrets.token_hex(12)
    body = bytearray()
    for name, value in fields.items():
        if value is None:
            continue
        body += f"--{boundary}\r\n".encode()
        body += f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode()
        body += str(value).encode("utf-8")
        body += b"\r\n"
    for name, (filename, data, ctype) in files.items():
        body += f"--{boundary}\r\n".encode()
        body += (
            f'Content-Disposition: form-data; name="{name}"; '
            f'filename="{filename}"\r\n'
        ).encode()
        body += f"Content-Type: {ctype}\r\n\r\n".encode()
        body += data
        body += b"\r\n"
    body += f"--{boundary}--\r\n".encode()

    req = urllib.request.Request(
        f"https://api.telegram.org/bot{token}/{method}",
        data=bytes(body),
        headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())


def _json_post(token: str, method: str, payload: dict) -> dict:
    req = urllib.request.Request(
        f"https://api.telegram.org/bot{token}/{method}",
        data=json.dumps(payload).encode("utf-8"),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=15) as r:
        return json.loads(r.read())


def send_card(
    *,
    token: str,
    chat: int,
    thread: int,
    body: str,
    image_url: str | None,
    image_file: str | None,
    keyboard: list[list[dict]] | None,
) -> int:
    base_fields = {
        "chat_id": chat,
        "parse_mode": "HTML",
    }
    if thread > 0:
        base_fields["message_thread_id"] = thread
    if keyboard is not None:
        reply_markup_json = json.dumps({"inline_keyboard": keyboard})
    else:
        reply_markup_json = None

    if image_file:
        path = Path(image_file)
        if not path.is_file():
            sys.exit(f"agency-report: --image-file not found: {image_file}")
        ctype, _ = mimetypes.guess_type(path.name)
        if not ctype:
            ctype = "image/png"
        fields = {
            **base_fields,
            "caption": body,
            "reply_markup": reply_markup_json,
        }
        files = {"photo": (path.name, path.read_bytes(), ctype)}
        method = "sendPhoto"
        # sendPhoto caption max is 1024 chars. If we'd exceed, truncate
        # the body — the alternative (link-preview fallback) doesn't
        # apply when uploading a local file.
        if len(body) > TG_CAPTION_LIMIT:
            sys.stderr.write(
                f"agency-report: warning — body {len(body)} chars exceeds "
                f"caption cap {TG_CAPTION_LIMIT}; truncating.\n"
            )
            fields["caption"] = body[: TG_CAPTION_LIMIT - 1] + "…"
        resp = _multipart_post(token, method, fields, files)
    elif image_url and len(body) <= TG_CAPTION_LIMIT:
        method = "sendPhoto"
        payload = {
            **base_fields,
            "photo": image_url,
            "caption": body,
        }
        if reply_markup_json:
            payload["reply_markup"] = json.loads(reply_markup_json)
        resp = _json_post(token, method, payload)
    elif image_url:
        method = "sendMessage"
        anchor = html.escape(image_url, quote=True)
        text = f'<a href="{anchor}">​</a>{body}'
        payload = {
            **base_fields,
            "text": text,
            "link_preview_options": {
                "url": image_url,
                "prefer_large_media": True,
                "show_above_text": True,
            },
        }
        if reply_markup_json:
            payload["reply_markup"] = json.loads(reply_markup_json)
        resp = _json_post(token, method, payload)
    else:
        method = "sendMessage"
        payload = {
            **base_fields,
            "text": body,
            "disable_web_page_preview": True,
        }
        if reply_markup_json:
            payload["reply_markup"] = json.loads(reply_markup_json)
        resp = _json_post(token, method, payload)

    if not resp.get("ok"):
        sys.exit(f"agency-report: {method} failed: {resp}")
    return int(resp["result"]["message_id"])


def main() -> int:
    p = argparse.ArgumentParser(
        description="Record + post an Agency suggestion to Telegram (canonical card layout).",
    )
    p.add_argument("--title", required=True, help="Short scannable headline.")
    p.add_argument("--emoji", default="", help="Optional emoji prefixed to the headline.")
    p.add_argument("--source-label", help="Short clickable label like 'GitHub #347'.")
    p.add_argument("--source-url", help="URL the source label links to.")
    p.add_argument("--subhead", help="Optional one-line subhead under the headline.")
    p.add_argument("--image", help="Direct image URL.")
    p.add_argument(
        "--image-file",
        help="Local image file path; uploaded as multipart.",
    )
    p.add_argument(
        "--image-text",
        help="Shorthand: auto-generates a placehold.co card with this text.",
    )
    p.add_argument(
        "--block",
        action="append",
        default=None,
        help='Repeatable expandable block as JSON: '
        '\'{"emoji":"📝","title":"Variant A","body":"..."}\'. '
        "When given, overrides --draft / --reasoning. Pass any number "
        "of --block flags for 0/1/2/N expandables.",
    )
    p.add_argument("--draft", help="Actionable content (first expandable).")
    p.add_argument("--draft-title", default="Drafted action", help="Title of the draft block.")
    p.add_argument("--draft-emoji", default="📝", help="Emoji prefix on the draft block.")
    p.add_argument(
        "--reasoning",
        help="Context block (second expandable). Provenance, why-now, "
        "related threads, anything supporting the decision but not "
        "the action itself.",
    )
    p.add_argument("--reasoning-title", default="Context", help="Title of the context block.")
    p.add_argument("--reasoning-emoji", default="📎", help="Emoji prefix on the context block.")
    p.add_argument("--title-html", help="Raw HTML for the headline (skips escaping).")
    p.add_argument("--source-label-html", help="Raw HTML for the source label.")
    p.add_argument("--subhead-html", help="Raw HTML for the subhead.")
    p.add_argument("--draft-html", help="Raw HTML for the draft block body.")
    p.add_argument("--reasoning-html", help="Raw HTML for the why block body.")
    p.add_argument(
        "--description",
        default="",
        help="Legacy alias: used as reasoning if --reasoning is not given.",
    )
    p.add_argument(
        "--prompt",
        help="Exact action that runs if user taps Yes — also fills draft when --draft is not given.",
    )
    p.add_argument(
        "--importance",
        choices=("high", "med", "low"),
        default="med",
        help="Priority bucket for triage. Default: med.",
    )
    p.add_argument(
        "--source",
        help="Stable slug for dedupe (e.g. slack-c-foo, gmail-thread-19df, gh-pr-78).",
    )
    p.add_argument(
        "--button",
        action="append",
        default=None,
        help="Custom button LABEL (plain string). Repeatable. Each gets "
        "kind=custom. Pass the visible text only — e.g. --button \"❌ No\". "
        "NOT JSON. (--block takes JSON; --button does not.) "
        "Defensive: a JSON object with a `text` field is coerced to its "
        "text, but write plain strings to be safe.",
    )
    p.add_argument(
        "--info-only",
        action="store_true",
        help="Drop the inline keyboard entirely (FYI cards with no action).",
    )
    spawn_grp = p.add_mutually_exclusive_group()
    spawn_grp.add_argument(
        "--spawn-topic",
        dest="spawn_topic",
        action="store_const",
        const=True,
        default=None,
        help="On Yes/Edit tap, spawn a fresh forum topic and dispatch "
        "the lane there. Use for agency-loop cards (each cycle's cards "
        "get their own topics) or any case where the work warrants a "
        "dedicated thread. Overrides the auto-default.",
    )
    spawn_grp.add_argument(
        "--no-spawn-topic",
        dest="spawn_topic",
        action="store_const",
        const=False,
        help="On Yes/Edit tap, dispatch in the same thread the card "
        "lives in. Overrides the auto-default. Use when the agent is "
        "already deep in one topic and follow-up cards should keep the "
        "conversation in-place.",
    )
    p.add_argument(
        "--thread-id",
        type=int,
        default=int(os.environ.get("TG_THREAD_ID", "0")),
        help="TG forum thread to post into. Defaults to $TG_THREAD_ID or 0 (general).",
    )
    p.add_argument(
        "--skip-if-exists",
        action="store_true",
        help="If a suggestion with this --source already exists and isn't pending, "
        "skip posting (exit 0).",
    )
    args = p.parse_args()

    if args.info_only and args.button:
        sys.exit("agency-report: --info-only and --button are mutually exclusive.")

    # --prompt is required for cards that use the default Yes/Skip/Edit
    # buttons. Without it, a Yes-tap dispatches the button LABEL itself
    # ("🧵 Yes (new thread)") as the agent prompt, which is useless. Fail
    # loudly here so the broken card never gets posted.
    # --info-only cards have no buttons at all → no dispatch → no prompt
    # needed. --button (custom) cards use synthesized "[agency-button]
    # LABEL" dispatch instead of args.prompt → still works without a
    # prompt, but it's good practice to set one.
    if not args.info_only and not args.button and not (args.prompt or "").strip():
        sys.exit(
            "agency-report: --prompt is required for default-button cards.\n"
            "It's the action the agent runs when the user taps Yes/Edit.\n"
            "Without it, the Yes-tap dispatches the button label itself\n"
            "(e.g. '🧵 Yes (new thread)') as the prompt — useless to the\n"
            "spawned agent.\n\n"
            "Pass one of:\n"
            "  --prompt \"<the action to run on yes-tap>\"  (typical)\n"
            "  --info-only                                 (FYI cards, no action)\n"
            "  --button \"...\" --button \"...\"             (custom buttons)"
        )

    db = agency_db.conn()

    # spawn_topic auto-default: forum topics are goal/session lanes, so
    # accepted cards stay in that lane unless the card explicitly asks
    # to spawn. A non-topic chat still gets a worker topic.
    if args.spawn_topic is None:
        args.spawn_topic = not bool(args.thread_id)

    buttons = [] if args.info_only else _resolve_buttons(args)
    button_labels_for_db = [label for label, _ in buttons]
    blocks = _resolve_blocks(args)
    generated_image_file = None
    if args.image_text and not args.image and not args.image_file:
        generated_image_file = _render_image_text_file(args)
    image_url = None if generated_image_file else _resolve_image_url(args)
    stored_image_file = args.image_file or generated_image_file

    if args.skip_if_exists and args.source:
        prior = agency_db.exists(db, args.source)
        if prior and prior.get("status") != "pending":
            print(
                f"agency-report: source={args.source!r} already exists "
                f"(id={prior['id']}, status={prior['status']}). Skipping.",
                file=sys.stderr,
            )
            return 0

    sugg_id = agency_db.insert(
        db,
        title=args.title,
        description=args.description or args.reasoning or "",
        importance=args.importance,
        source=args.source,
        prompt=args.prompt,
        buttons=button_labels_for_db,
        blocks=blocks,
        image_url=image_url,
        image_file=stored_image_file,
        source_label=args.source_label,
        source_url=args.source_url,
        chat_id=chat_id(),
        thread_id=args.thread_id,
        spawn_topic=args.spawn_topic,
    )

    body = _build_body(args)
    keyboard = (
        None
        if args.info_only
        else _build_keyboard(buttons, args.thread_id)
    )

    msg_id = send_card(
        token=bot_token(),
        chat=chat_id(),
        thread=args.thread_id,
        body=body,
        image_url=image_url,
        image_file=stored_image_file,
        keyboard=keyboard,
    )
    agency_db.update_message(db, sugg_id, msg_id)
    print(sugg_id)
    return 0


if __name__ == "__main__":
    sys.exit(main())
