#!/opt/bux/venv/bin/python
"""agency-report — record + post one card to Telegram.

Three things every call: insert a row in `/var/lib/bux/agency.db`, post
the card to TG (image + expandable blocks + inline-keyboard buttons),
and wire the resulting message_id back into the DB row so a button tap
finds the right suggestion.

Default buttons: ✅ Yes · 🔁 More · ⏭ Skip. All taps dispatch in the
card's own topic (one-goal-one-topic; if the agent needs a fresh lane
for a big new project, it spawns one explicitly with `tg-schedule
"+1 minute" --fresh`). Yes runs --prompt as a new agent turn; More asks
the agent to regenerate with a different angle; Skip records dismissal,
no LLM call.

Callback data shape: `agcy:<thread>:<idx>:<kind>` where kind ∈
{action, more, dismiss, custom}. Custom --button entries get
kind=custom and dispatch a synthesized `[agency-button] <label>`
turn in the same topic.

Blocks are specified via --block (repeatable, JSON object). Each block
becomes one expandable. Typical copilot card is two (option A + option
B). If no --block is given and --prompt is set, a single auto-generated
draft block is created so the user can see what'll fire on Yes-tap.

Run `agency-report --help` for the flag reference. Image rendering uses
PIL (1080×540 gradient with emoji + caps headline + sentence-case why);
agency-report falls back to sendMessage + large link-preview when the
body would exceed Telegram's 1024-char caption cap.
"""
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.
# One canonical set — one goal, one topic, no spawn-topic label variants.
# `more` asks the agent to regenerate this card with a different angle.
DEFAULT_BUTTONS: list[tuple[str, str]] = [
    ("✅ Yes", "action"),
    ("🔁 More", "more"),
    ("⏭ Skip", "dismiss"),
]
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

    # Callers write `\n` in shell args (see image-text rules); 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)

    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'."""
    if args.button:
        return [(_coerce_button_label(label), "custom") for label in args.button]
    return list(DEFAULT_BUTTONS)


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.

    Pass --block JSON (repeatable). Each value is a JSON object:
      {"emoji": "📝", "title": "Variant A", "body": "..."}
    or with raw HTML: {..., "body": "<b>X</b>", "body_html": true}.

    If no --block is passed and --prompt is set, a single block with the
    prompt as the body is auto-generated so the user can see what'll run
    on Yes-tap.

    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

    # No --block: auto-generate one from --prompt so the user always sees
    # what'll fire on Yes-tap. Cards with no prompt + no block + no
    # info-only flag are caught by the validation in main().
    if (args.prompt or "").strip():
        return [{
            "emoji": "📝",
            "title": "Drafted action",
            "body_html": f"<pre>{html.escape(args.prompt, quote=False)}</pre>",
        }]
    return []


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,
        }
        if reply_markup_json:
            fields["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":"..."}\'. '
        "Pass any number of --block flags for 0/1/2/N expandables.",
    )
    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(
        "--prompt",
        help="Exact action that runs if user taps Yes — also fills the auto-generated draft block when no --block is given.",
    )
    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. Plain text only — e.g. --button \"❌ No\". NOT JSON.",
    )
    p.add_argument(
        "--info-only",
        action="store_true",
        help="Drop the inline keyboard entirely (FYI cards with no action).",
    )
    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()

    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

    # DB description was historically the legacy --reasoning text. After v7
    # the blocks fully replace it; we store the concatenated block bodies so
    # search / dedup still see something searchable.
    db_description = "\n\n".join(
        (b.get("body_html") or "").strip() for b in blocks
    ).strip() or (args.prompt or "")
    sugg_id = agency_db.insert(
        db,
        title=args.title,
        description=db_description,
        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,
    )

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