#!/usr/bin/env python3
"""board — a shared blackboard for coordinating multiple AI agents.

Agents (Claude, agy/Gemini, humans) post tasks, claim them, and record
results on a common board so parallel work converges without collisions.

Source of truth is `.agents/board.json`; a human-readable `.agents/board.md`
is re-rendered on every change (git-friendly, visible in the Paseo sidebar).

Usage:
  board add "Refactor get_model caching" [--owner any] [--tag perf] [--note ...]
  board list [--status open|claimed|done|blocked] [--owner claude]
  board claim <id> <owner>
  board note <id> "found the bug in _build_openrouter_model"
  board block <id> "waiting on API key"
  board done <id> [--result <text-or-path>]
  board show <id>
  board render          # rewrite board.md from board.json

Owners are free-form (claude, agy, gemini-pro, human, ...). IDs are t1, t2, ...
"""
from __future__ import annotations

import argparse
import fcntl
import json
import os
import sys
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
BOARD_DIR = ROOT / ".agents"
JSON_PATH = BOARD_DIR / "board.json"
MD_PATH = BOARD_DIR / "board.md"
LOCK_PATH = BOARD_DIR / ".board.lock"

STATUSES = ("open", "claimed", "done", "blocked")
STATUS_ICON = {"open": "○", "claimed": "◐", "done": "●", "blocked": "✕"}


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


def _load() -> dict:
    if not JSON_PATH.exists():
        return {"counter": 0, "tasks": []}
    try:
        return json.loads(JSON_PATH.read_text())
    except json.JSONDecodeError as e:
        backup = JSON_PATH.with_suffix(".json.corrupt")
        JSON_PATH.replace(backup)
        sys.exit(f"board: board.json is corrupt ({e}); moved to {backup.name}")


@contextmanager
def _transaction():
    """Hold an exclusive lock across load→mutate→save so parallel agents
    can't clobber each other's writes (claim collisions, lost notes)."""
    BOARD_DIR.mkdir(parents=True, exist_ok=True)
    with open(LOCK_PATH, "w") as lock:
        fcntl.flock(lock, fcntl.LOCK_EX)
        try:
            data = _load()
            yield data
            _save(data)
        finally:
            fcntl.flock(lock, fcntl.LOCK_UN)


def _atomic_write(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            f.write(text)
        os.replace(tmp, path)
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)


def _save(data: dict) -> None:
    _atomic_write(JSON_PATH, json.dumps(data, indent=2) + "\n")
    _render(data)


def _find(data: dict, tid: str) -> dict:
    for t in data["tasks"]:
        if t["id"] == tid:
            return t
    sys.exit(f"board: task {tid!r} not found")


def _cell(s: object) -> str:
    """Escape a value for safe inclusion in a markdown table/line."""
    return str(s).replace("|", "\\|").replace("\n", " ").strip()


def _render(data: dict) -> None:
    lines = ["# Agent board", "", f"_Updated {_now()}_", ""]
    active = [t for t in data["tasks"] if t["status"] != "done"]
    done = [t for t in data["tasks"] if t["status"] == "done"]

    def section(title: str, tasks: list[dict]) -> None:
        lines.append(f"## {title} ({len(tasks)})")
        lines.append("")
        if not tasks:
            lines.append("_none_")
            lines.append("")
            return
        lines.append("| | ID | Owner | Task | Tags |")
        lines.append("|---|---|---|---|---|")
        for t in tasks:
            icon = STATUS_ICON.get(t["status"], "?")
            tags = _cell(", ".join(t.get("tags", [])) or "—")
            owner = _cell(t.get("owner") or "—")
            lines.append(f"| {icon} | {t['id']} | {owner} | {_cell(t['title'])} | {tags} |")
        lines.append("")
        for t in tasks:
            if t.get("notes") or t.get("result"):
                lines.append(f"### {t['id']} — {_cell(t['title'])}")
                for n in t.get("notes", []):
                    lines.append(f"- _{n['at']}_ ({_cell(n['by'])}): {_cell(n['text'])}")
                if t.get("result"):
                    lines.append(f"- **result:** {_cell(t['result'])}")
                lines.append("")

    section("Active", active)
    section("Done", done)
    lines.append("---")
    lines.append("_Managed by `bin/board`. Edit via the CLI, not by hand._")
    _atomic_write(MD_PATH, "\n".join(lines) + "\n")


def cmd_add(args) -> None:
    author = args.owner if args.owner not in (None, "any") else "system"
    with _transaction() as data:
        data["counter"] += 1
        tid = f"t{data['counter']}"
        task = {
            "id": tid,
            "title": args.title,
            "status": "open",
            "owner": None if args.owner in (None, "any") else args.owner,
            "tags": args.tag or [],
            "notes": [],
            "result": None,
            "created": _now(),
        }
        if args.note:
            task["notes"].append({"at": _now(), "by": author, "text": args.note})
        data["tasks"].append(task)
    print(f"{tid}  added: {args.title}")


def cmd_list(args) -> None:
    data = _load()
    tasks = data["tasks"]
    if args.status:
        tasks = [t for t in tasks if t["status"] == args.status]
    if args.owner:
        tasks = [t for t in tasks if t.get("owner") == args.owner]
    if not tasks:
        print("(no matching tasks)")
        return
    for t in tasks:
        icon = STATUS_ICON.get(t["status"], "?")
        owner = t.get("owner") or "-"
        tags = f" [{','.join(t['tags'])}]" if t.get("tags") else ""
        print(f"{icon} {t['id']:<4} {t['status']:<8} {owner:<10} {t['title']}{tags}")


def cmd_claim(args) -> None:
    with _transaction() as data:
        t = _find(data, args.id)
        t["status"] = "claimed"
        t["owner"] = args.owner
        t["notes"].append({"at": _now(), "by": args.owner, "text": "claimed"})
    print(f"{args.id}  claimed by {args.owner}")


def cmd_note(args) -> None:
    with _transaction() as data:
        t = _find(data, args.id)
        t["notes"].append({"at": _now(), "by": args.by, "text": args.text})
    print(f"{args.id}  note added")


def cmd_block(args) -> None:
    with _transaction() as data:
        t = _find(data, args.id)
        t["status"] = "blocked"
        t["notes"].append({"at": _now(), "by": args.by, "text": f"BLOCKED: {args.text}"})
    print(f"{args.id}  blocked")


def _looks_like_path(s: str) -> bool:
    # Guard against os.stat on inline result text (long strings / newlines
    # raise OSError: File name too long).
    return len(s) < 200 and "\n" not in s and "\x00" not in s


def cmd_done(args) -> None:
    with _transaction() as data:
        t = _find(data, args.id)
        t["status"] = "done"
        if args.result:
            if _looks_like_path(args.result) and Path(args.result).exists():
                t["result"] = f"see {args.result}"
            else:
                t["result"] = args.result
        t["notes"].append({"at": _now(), "by": t.get("owner") or "system", "text": "done"})
    print(f"{args.id}  done")


def cmd_rm(args) -> None:
    with _transaction() as data:
        before = len(data["tasks"])
        data["tasks"] = [t for t in data["tasks"] if t["id"] not in args.id]
        removed = before - len(data["tasks"])
    print(f"removed {removed} task(s)")


def cmd_show(args) -> None:
    t = _find(_load(), args.id)
    print(json.dumps(t, indent=2))


def cmd_render(args) -> None:
    _render(_load())
    print(f"rendered {MD_PATH}")


def main() -> None:
    p = argparse.ArgumentParser(prog="board", description=__doc__)
    sub = p.add_subparsers(dest="cmd", required=True)

    a = sub.add_parser("add", help="add a task")
    a.add_argument("title")
    a.add_argument("--owner", default=None)
    a.add_argument("--tag", action="append")
    a.add_argument("--note", default=None)
    a.set_defaults(func=cmd_add)

    a = sub.add_parser("list", help="list tasks")
    a.add_argument("--status", choices=STATUSES)
    a.add_argument("--owner", default=None)
    a.set_defaults(func=cmd_list)

    a = sub.add_parser("claim", help="claim a task")
    a.add_argument("id")
    a.add_argument("owner")
    a.set_defaults(func=cmd_claim)

    a = sub.add_parser("note", help="add a note")
    a.add_argument("id")
    a.add_argument("text")
    a.add_argument("--by", default="system")
    a.set_defaults(func=cmd_note)

    a = sub.add_parser("block", help="mark blocked")
    a.add_argument("id")
    a.add_argument("text")
    a.add_argument("--by", default="system")
    a.set_defaults(func=cmd_block)

    a = sub.add_parser("done", help="mark done")
    a.add_argument("id")
    a.add_argument("--result", default=None)
    a.set_defaults(func=cmd_done)

    a = sub.add_parser("rm", help="remove one or more tasks")
    a.add_argument("id", nargs="+")
    a.set_defaults(func=cmd_rm)

    a = sub.add_parser("show", help="show a task as JSON")
    a.add_argument("id")
    a.set_defaults(func=cmd_show)

    a = sub.add_parser("render", help="rewrite board.md")
    a.set_defaults(func=cmd_render)

    args = p.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
