#!/usr/bin/env python3
"""The running list for rian: add, resolve, list and export items, atomically.

Sources of truth: import/items.json (this script is the only writer; the container reads it
live for the /plan "Issues and to-dos" tab) and the app's owner_item_states table (what rian
did on the page; read here through the local API). The two markdown files sessions used to
edit by hand, .logs/decisions-for-rian.md and .logs/issues.md, are now GENERATED by `export`
from the merged view; never edit them directly.

    python3 main/scripts/items.py add --kind decide --by "Stream Q" --blocks R5 --weight costly \
        --title "Which Shilla price tier is the price?" \
        --detail "The page shows list, discount and member prices." \
        --option "discount (assumed)" --option "member" --assumption "discount"
    python3 main/scripts/items.py add --kind issue --owner A --priority P2 --by "Stream Q" \
        --title "A number in a beauty name is read as a size" --detail "..."
    python3 main/scripts/items.py add --kind do --owner rian --due 2026-09-08 --by "Stream A2" \
        --title "Hand-check the Changi departure price" --detail "..." --link ".logs/runs/x.md"
    python3 main/scripts/items.py resolve <id> --by "Stream A" --note "fixed in a1b2c3d"
    python3 main/scripts/items.py list            # open items, merged with rian's state
    python3 main/scripts/items.py list --decisions  # what rian decided since the item was raised
    python3 main/scripts/items.py sweep           # the checkpoint duty: open issues by owner, ages
    python3 main/scripts/items.py export          # regenerate the two markdown mirrors

Kinds: decide (only rian can answer) · do (rian does, checks, reviews) · issue (to address;
--owner names the stream, or nobody). Every add and resolve re-runs export.

Urgency is read from the plan, not from a date: --blocks names the plan task ids (progress.json)
that cannot proceed correctly until the item is settled; --weight is blocking (the gated work
must not start without it), costly (work can proceed under the assumption, reversing it is
expensive) or info (nothing waits on it). A decide or do item with no --blocks is info-weight
unless you say --weight costly; name the task whenever one exists.
"""
import argparse, datetime, fcntl, json, pathlib, re, subprocess, sys, urllib.request

ROOT = pathlib.Path(__file__).resolve().parents[2]
FILE = ROOT / "import" / "items.json"
DECISIONS_MD = ROOT / ".logs" / "decisions-for-rian.md"
ISSUES_MD = ROOT / ".logs" / "issues.md"
KINDS = ("decide", "do", "issue")
PRIORITIES = ("P1", "P2", "P3")
WEIGHTS = ("blocking", "costly", "info")
PLAN = ROOT / "import" / "progress.json"
sys.path.insert(0, str(ROOT / "main"))
from app.services.items import merge, counts  # noqa: E402  (pure functions, no app import chain)


def plan_tasks() -> dict[str, dict]:
    if not PLAN.exists():
        return {}
    d = json.loads(PLAN.read_text(encoding="utf-8"))
    return {t["id"]: t for s in d.get("streams", []) for t in s.get("tasks", [])}


def today() -> str:
    return datetime.date.today().isoformat()


def slug(text: str) -> str:
    s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
    return s[:56].rstrip("-")


def load() -> dict:
    if not FILE.exists():
        return {"items": []}
    return json.loads(FILE.read_text(encoding="utf-8"))


def save(data: dict) -> None:
    data["updated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="minutes")
    FILE.write_text(json.dumps(data, indent=1, ensure_ascii=False) + "\n", encoding="utf-8")


def locked(fn):
    FILE.parent.mkdir(parents=True, exist_ok=True)
    FILE.touch(exist_ok=True)
    with FILE.open("r+", encoding="utf-8") as fh:
        fcntl.flock(fh, fcntl.LOCK_EX)
        raw = fh.read().strip()
        data = json.loads(raw) if raw else {"items": []}
        result = fn(data)
        fh.seek(0); fh.truncate()
        data["updated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="minutes")
        fh.write(json.dumps(data, indent=1, ensure_ascii=False) + "\n")
    return result


def api_states() -> dict[str, dict] | None:
    """rian's state from the running app, or None when it is unreachable."""
    port = None
    env = ROOT / ".env"
    if env.exists():
        m = re.search(r"^PORT=(\d+)", env.read_text(), flags=re.M)
        port = m.group(1) if m else None
    if not port:
        return None
    try:
        with urllib.request.urlopen(f"http://172.17.0.1:{port}/api/items", timeout=5) as r:
            payload = json.load(r)
    except Exception:
        return None
    # The API already merged; lift the state back out so file items not yet visible merge too.
    states = {}
    for i in payload.get("items", []):
        if i.get("status") in ("done", "dismissed", "archived") or (i.get("status") == "open" and i.get("acted_by")):
            states[i["id"]] = {"status": i["status"], "decision": i.get("decision"), "acted_by": i.get("acted_by"),
                               "acted_at": i.get("closed_on")}
    return states


def db_states() -> dict[str, dict] | None:
    """rian's state read straight from the database, or None when that fails.

    Since members-only mode (R1, 10 Sep) the API refuses an anonymous caller, so a
    script on the dev host reads owner_item_states through the database container
    instead: no session, no token, nothing new to keep secret. The API path stays as
    the fallback for a host without docker access.
    """
    try:
        out = subprocess.run(
            ["docker", "exec", "dutyfreeprofessor-db", "psql", "-U", "dfp", "dfp", "-tA", "-c",
             "select coalesce(json_agg(json_build_object('item_id', item_id, 'status', status, "
             "'decision', decision, 'acted_by', acted_by, 'acted_at', acted_at)), '[]') "
             "from owner_item_states"],
            capture_output=True, text=True, timeout=10, check=True,
        ).stdout.strip()
    except Exception:
        return None
    states = {}
    for row in json.loads(out or "[]"):
        if row.get("status") in ("done", "dismissed", "archived") or (row.get("status") == "open" and row.get("acted_by")):
            states[row["item_id"]] = {"status": row["status"], "decision": row.get("decision"),
                                      "acted_by": row.get("acted_by"), "acted_at": row.get("acted_at")}
    return states


def merged_view() -> tuple[list[dict], bool]:
    states = db_states()
    if states is None:
        states = api_states()
    live = states is not None
    return merge(load().get("items", []), states or {}, plan_tasks=plan_tasks()), live


def cmd_add(a) -> int:
    if a.kind not in KINDS:
        print(f"kind must be one of {KINDS}", file=sys.stderr); return 2
    if a.priority and a.priority not in PRIORITIES:
        print(f"priority must be one of {PRIORITIES}", file=sys.stderr); return 2
    if a.weight and a.weight not in WEIGHTS:
        print(f"weight must be one of {WEIGHTS}", file=sys.stderr); return 2
    known = plan_tasks()
    for b in a.blocks or []:
        if known and b not in known:
            print(f"--blocks {b}: no such task id in import/progress.json", file=sys.stderr); return 2
    weight = a.weight or ("costly" if a.blocks else "info")
    detail = "\n\n".join(a.detail or [])
    if detail == "-":
        detail = sys.stdin.read().strip()
    item_id = a.id or f"{a.kind}-{slug(a.title)}"

    def do(data):
        by_id = {i["id"]: i for i in data["items"]}
        if item_id in by_id and not a.update:
            raise SystemExit(f"{item_id} exists; pass --update to replace its text, or --id for a new id")
        item = by_id.get(item_id) or {"id": item_id, "kind": a.kind, "created": today(), "by": a.by}
        item.update({
            "kind": a.kind, "title": a.title.strip(), "detail": detail, "owner": a.owner,
            "priority": a.priority, "options": a.option or [], "assumption": a.assumption,
            "links": a.link or [], "due": a.due, "blocks": a.blocks or [], "weight": weight, "updated": today(),
        })
        if item_id not in by_id:
            data["items"].append(item)
        return item_id

    out = locked(do)
    print(f"added {out}" if not a.update else f"updated {out}")
    cmd_export(a, quiet=True)
    return 0


def cmd_resolve(a) -> int:
    def do(data):
        for i in data["items"]:
            if i["id"] == a.item_id:
                i["resolved"] = {"by": a.by, "on": today(), "note": a.note or ""}
                return True
        return False
    if not locked(do):
        print(f"no item {a.item_id}", file=sys.stderr); return 2
    print(f"resolved {a.item_id}")
    cmd_export(a, quiet=True)
    return 0


def _line(i: dict) -> str:
    tag = i["kind"] + (f"/{i['priority']}" if i.get("priority") else "") + (f" [{i['owner']}]" if i.get("owner") else "")
    urg = (i.get("urgency") or i["status"])[:9]
    reason = f"\n           {i['reason']}" if i.get("reason") else ""
    return f"{urg:9} {tag:16} {i['id']}\n           {i['title']}{reason}"


def cmd_list(a) -> int:
    view, live = merged_view()
    if not live:
        print("(app unreachable: showing the file only, without rian's page actions)", file=sys.stderr)
    for i in view:
        if a.kind and i["kind"] != a.kind:
            continue
        if a.owner and (i.get("owner") or "") != a.owner:
            continue
        if a.decisions:
            if i["status"] in ("done", "dismissed", "archived"):
                print(f"{i['closed_on'] or '?':10} {i['status']:9} {i['id']}\n           {i['title']}\n           -> {i.get('decision') or '(no text)'}  ({i.get('acted_by') or '?'})")
            continue
        if not a.all and i["status"] != "open":
            continue
        print(_line(i))
    return 0


def cmd_sweep(a) -> int:
    view, live = merged_view()
    if not live:
        print("(app unreachable: rian's page actions not included)", file=sys.stderr)
    open_items = [i for i in view if i["status"] == "open"]
    hot = [i for i in open_items if i["urgency"] in ("now", "overtaken")]
    if hot:
        print(f"## Waiting on rian before the work can proceed cleanly: {len(hot)}")
        for i in hot:
            print(f"  {i['urgency']:9} {i['kind']:6} {i['id']}  {i['title']}\n           {i['reason']}")
        print()
    by_owner: dict[str, list] = {}
    for i in open_items:
        by_owner.setdefault(i.get("owner") or "(unowned)", []).append(i)
    t = datetime.date.fromisoformat(today())
    for owner, items in sorted(by_owner.items()):
        print(f"## {owner}: {len(items)} open")
        for i in items:
            age = (t - datetime.date.fromisoformat(i["created"])).days if i.get("created") else "?"
            print(f"  {i['kind']:6} {i.get('priority') or '  '} {age:>3}d  {i['id']}  {i['title']}")
    print(f"\n{len(open_items)} open; decided/done on the page: {sum(1 for i in view if i['status'] in ('done','dismissed'))}; archived: {sum(1 for i in view if i['status'] == 'archived')}; "
          f"resolve yours with: python3 main/scripts/items.py resolve <id> --by \"Stream X\" --note \"...\"")
    return 0


def _md_item(i: dict) -> str:
    head = f"- **{i['title']}**"
    meta = []
    if i.get("priority"): meta.append(i["priority"])
    if i.get("owner"): meta.append(f"owner {i['owner']}")
    if i.get("due"): meta.append(f"due {i['due']}")
    meta.append(f"raised {i.get('created','?')} by {i.get('by','?')}")
    meta.append(f"id `{i['id']}`")
    lines = [head + " (" + "; ".join(meta) + ")"]
    for para in (i.get("detail") or "").split("\n\n"):
        if para.strip():
            lines.append("  " + para.strip().replace("\n", "\n  "))
    if i.get("options"):
        lines.append("  Options: " + " · ".join(i["options"]))
    if i.get("assumption"):
        lines.append(f"  *Proceeding under:* {i['assumption']}")
    if i.get("blocks"):
        lines.append(f"  Gates: {', '.join(i['blocks'])} ({i.get('weight') or 'costly'})" + (f" — {i['reason']}" if i.get("reason") else ""))
    for l in i.get("links") or []:
        lines.append(f"  See `{l}`")
    if i["status"] != "open":
        who = i.get("acted_by") or "?"
        lines.append(f"  **{i['status'].upper()} {i.get('closed_on') or ''} by {who}:** {i.get('decision') or '(no text)'}")
    return "\n".join(lines)


def cmd_export(a, quiet: bool = False) -> int:
    view, live = merged_view()
    stamp = today()
    src = "the running app and import/items.json" if live else "import/items.json only (the app was unreachable, so page actions since the last live export may be missing)"
    banner = (f"<!-- GENERATED by main/scripts/items.py export on {stamp} from {src}. Do not edit: add or resolve items with the script; "
              f"rian acts on them on /plan (Issues and to-dos). -->\n")
    dec = [i for i in view if i["kind"] in ("decide", "do")]
    iss = [i for i in view if i["kind"] == "issue"]
    d = ["# Decisions and actions for rian", banner, f"Last swept {stamp}.", ""]
    d += ["## Open: decide or answer", ""] + ([_md_item(i) for i in dec if i["kind"] == "decide" and i["status"] == "open"] or ["- (none)"]) + [""]
    d += ["## Open: do, check, review", ""] + ([_md_item(i) for i in dec if i["kind"] == "do" and i["status"] == "open"] or ["- (none)"]) + [""]
    d += ["## Decided or done", ""] + ([_md_item(i) for i in dec if i["status"] != "open"] or ["- (none)"]) + [""]
    DECISIONS_MD.write_text("\n".join(d), encoding="utf-8")
    s = ["# Issues register", banner, f"Last swept {stamp}.", ""]
    for p in PRIORITIES + (None,):
        rows = [i for i in iss if i["status"] == "open" and i.get("priority") == p]
        title = {"P1": "P1: blocks the launch", "P2": "P2: silently corrupting data or blocking a page", "P3": "P3: should fix before Cannes"}.get(p, "Open: watch, parked, later")
        s += [f"## {title}", ""] + ([_md_item(i) for i in rows] or ["- (none)"]) + [""]
    s += ["## Resolved", ""] + ([_md_item(i) for i in iss if i["status"] != "open"] or ["- (none)"]) + [""]
    ISSUES_MD.write_text("\n".join(s), encoding="utf-8")
    if not quiet:
        c = counts(view)
        print(f"exported: {c['decide']} to decide, {c['do']} to do, {c['issue']} issues open; {c['closed']} recently closed"
              + ("" if live else " (file only)"))
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("add"); p.add_argument("--kind", required=True); p.add_argument("--title", required=True)
    p.add_argument("--detail", action="append", help="a paragraph; repeat for more, or '-' to read stdin")
    p.add_argument("--owner"); p.add_argument("--priority"); p.add_argument("--option", action="append")
    p.add_argument("--assumption"); p.add_argument("--link", action="append"); p.add_argument("--due")
    p.add_argument("--by", required=True); p.add_argument("--id"); p.add_argument("--update", action="store_true")
    p.add_argument("--blocks", action="append", help="a plan task id this item gates; repeat for more")
    p.add_argument("--weight", help="blocking | costly | info (default: costly with --blocks, else info)")
    p.set_defaults(fn=cmd_add)
    p = sub.add_parser("resolve"); p.add_argument("item_id"); p.add_argument("--by", required=True); p.add_argument("--note")
    p.set_defaults(fn=cmd_resolve)
    p = sub.add_parser("list"); p.add_argument("--all", action="store_true"); p.add_argument("--kind"); p.add_argument("--owner")
    p.add_argument("--decisions", action="store_true"); p.set_defaults(fn=cmd_list)
    p = sub.add_parser("sweep"); p.set_defaults(fn=cmd_sweep)
    p = sub.add_parser("export"); p.set_defaults(fn=cmd_export)
    a = ap.parse_args()
    return a.fn(a)


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