#!/usr/bin/python3
"""bd-ready — trim a list of open Beads issues down to the "ready" ones.

INPUT
    The JSON produced by ``bd list --status=open --json --prefix=<prefix>``.
    By default this script runs that command itself; if JSON is piped on
    stdin it is used instead (handy for testing / replay).

RULES
    For each issue *A* in the list, look at its ``dependencies``. Two kinds
    of edge can cause an issue to be excluded:

      * ``type == "blocks"`` with ``depends_on_id == B`` (A is blocked by B):
        A is excluded when B is **not closed**. Blockers absent from the
        incoming list are fetched on demand with ``bd show --json B`` so
        their status can be checked. A blocker that cannot be retrieved
        (deleted/purged) is treated as *not* blocking — we refuse to drop
        A on an unverifiable premise — and a warning is printed on stderr.

      * ``type == "parent-child"`` with ``depends_on_id == B`` (B is A's
        parent): B is excluded when A is **not closed**. The child A is
        one of the issues supplied as input (typically the open list), so
        its status is already known and no fetch is needed; parents that
        are not themselves in the input are simply absent and need no
        action.

OUTPUT
    By default a human-readable list of the kept ("ready") issues,
    one per line, on stdout. Pass ``--json`` to emit the trimmed list
    as JSON instead (same item dicts) — handy for piping into other
    tools. A short diagnostic report is written to stderr (--verbose).

NOTE
    Both rules are applied as a single pass over the input edges (no
    recursion): an issue is dropped iff one of ITS OWN edges triggers a
    rule, never merely because something it depended on was itself dropped
    — that would conflate "blocked/is-a-parent" with "trimmed", which is a
    different question.
"""

from __future__ import annotations

import json
import subprocess
import sys
import os
from typing import Any

import click

# Statuses that count as "resolved / no longer blocking".
# Literal reading of the spec: only "closed". Add "superseded" here if
# your project treats superseded issues as resolved.
CLOSED_STATUSES = frozenset({"closed"})

os.environ["BD_JSON_ENVELOPE"]="1"

def _normalize(payload: Any) -> list[dict]:
    """Return a list of issue dicts from a `bd ... --json` payload.

    Handles both shapes observed in the wild:
      * a bare JSON list  -> use as-is
      * a mapping with a "data" key -> use payload["data"]
    """
    if isinstance(payload, dict) and "data" in payload:
        payload = payload["data"]
    if isinstance(payload, dict):
        msg = payload.get("error") if "error" in payload else payload
        raise ValueError("unexpected bd payload (not a list): %r" % (msg,))
    if not isinstance(payload, list):
        raise ValueError("expected a JSON list of issues, got %r" % type(payload))
    return payload


def run_bd(args: list[str]) -> Any:
    """Invoke `bd ...` and return parsed JSON stdout (errors -> None)."""
    proc = subprocess.run(
        args,
        capture_output=True,
        text=True,
    )
    if proc.returncode != 0 or not proc.stdout.strip():
        # Human-readable hint lives on stderr; surface it for debugging.
        if proc.stderr.strip():
            print("[bd] %s" % proc.stderr.strip().splitlines()[0], file=sys.stderr)
        return None
    try:
        return json.loads(proc.stdout)
    except json.JSONDecodeError as exc:
        print("[bd] bad JSON from %r: %s" % (" ".join(args), exc), file=sys.stderr)
        return None


class Resolver:
    """Resolves an issue id to its record, fetching missing ones via bd."""

    def __init__(self, known: dict[str, dict]):
        self._cache: dict[str, dict | None] = dict(known)
        self.fetched: list[tuple[str, str]] = []     # (id, status) we had to fetch
        self.missing: list[str] = []                 # ids we could not resolve

    def get(self, bid: str) -> dict | None:
        if bid in self._cache:
            return self._cache[bid]
        rec = self._fetch(bid)
        self._cache[bid] = rec
        if rec is None:
            self.missing.append(bid)
        else:
            self.fetched.append((bid, rec.get("status", "?")))
        return rec

    @staticmethod
    def _fetch(bid: str) -> dict | None:
        payload = run_bd(["bd", "show", "--json", bid])
        if payload is None:
            return None
        try:
            items = _normalize(payload)
        except ValueError:
            return None
        if not items:
            return None
        return items[0]


def collect_depends_on(issue: dict, dep_type: str) -> list[str]:
    """Return depends_on_ids of this issue's dependencies of ``dep_type``."""
    out = []
    for dep in issue.get("dependencies", []) or []:
        if dep.get("type") != dep_type:
            continue
        bid = dep.get("depends_on_id")
        if not bid or bid == issue.get("id"):
            # defensive: skip missing ids and self-loops
            continue
        out.append(bid)
    return out


def _parse_json(text: str, source: str):
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        sys.exit("bd-ready: invalid JSON from %s: %s" % (source, exc))


def load_issues(input_file: str | None, status: str,
                 prefix: str | None) -> list[dict]:
    if input_file:
        with open(input_file, "r", encoding="utf-8") as fh:
            return _normalize(_parse_json(fh.read(), input_file))

    args = ["bd", "list", f"--status={status}", "--json", "-n0"]
    if prefix:
        args.append(f"--prefix={prefix}")
    payload = run_bd(args)
    if payload is None:
        sys.exit("bd-ready: failed to run `%s`" % " ".join(args))
    return _normalize(payload)


def _status_symbol(status: str) -> str:
    """Map a status to a compact glyph for the human-readable view."""
    table = {
        "open": "OPEN",
        "in_progress": "WIP",
        "ready": "READY",
        "waiting": "WAIT",
        "blocked": "BLOCK",
        "deferred": "DEFER",
        "paused": "PAUSE",
        "closed": "DONE",
    }
    return table.get(status, status.upper()[:6] if status else "?")


def _print_human(issues: list[dict]) -> None:
    """Print a compact, human-friendly list of ready issues to stdout."""
    if not issues:
        print("No ready issues.")
        return

    # Sort: highest priority first (lower numeric = higher), then by id.
    ordered = sorted(
        issues,
        key=lambda i: (int(i.get("priority", 99)) if i.get("priority") is not None else 99,
                       str(i.get("id", ""))),
    )

    width = max(len(str(i.get("id", ""))) for i in ordered)
    for it in ordered:
        iid = str(it.get("id", "?")).ljust(width)
        prio = it.get("priority")
        ptag = "P%s" % prio if prio is not None else "P?"
        stag = _status_symbol(str(it.get("status", "")))
        title = (it.get("title") or "").strip()
        # owner = it.get("owner") or it.get("created_by") or ""
        # otap = "  <%s>" % owner.split("@", 1)[0] if owner else ""
        print("%s  [%s·%-5s]  %s" % (iid, ptag, stag, title))


CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}


@click.command(context_settings=CONTEXT_SETTINGS)
@click.option("--prefix", default=None,
              help="bd --prefix value (default: flow)")
@click.option("--status", default="open",
              help="bd --status value (default: open)")
@click.option("--input-file", "input_file", metavar="PATH",
              type=click.Path(exists=True, dir_okay=False, readable=True),
              default=None,
              help="read the bd list JSON from PATH instead of `bd list`")
@click.option("-j", "--json", "as_json", is_flag=True, default=False,
              help="emit the trimmed list as JSON instead of a human-readable list")
@click.option("-v", "--verbose", is_flag=True, default=False,
              help="print a short report to stderr")
def cli(prefix: str | None, status: str,
        input_file: str | None, as_json: bool, verbose: bool) -> None:
    """bd-ready — trim a list of open Beads issues down to the "ready" ones."""
    issues = load_issues(input_file, status, prefix)

    # De-duplicate by id (last wins).
    by_id: dict[str, dict] = {}
    for it in issues:
        by_id[it["id"]] = it

    resolver = Resolver({k: v for k, v in by_id.items()})

    # Build the exclusion set from both rules, accumulating a human
    # explanation per excluded id so the stderr report stays useful.
    #   reason tag -> short verb shown in the report
    REASON_BLOCKED_BY_OPEN = "blocked-by-not-closed"
    REASON_PARENT_OF_OPEN  = "parent-of-not-closed"

    excluded: dict[str, list[tuple[str, str, str]]] = {}
    #   excluded[id] = list of (other_id, other_status, reason_tag)

    def mark_excluded(victim: str, other: str, ostatus: str, reason: str) -> None:
        excluded.setdefault(victim, []).append((other, ostatus, reason))

    # Rule 1 — `blocks`: A is excluded when a blocker B is not-closed.
    for it in by_id.values():
        aid = it["id"]
        for bid in collect_depends_on(it, "blocks"):
            brec = resolver.get(bid)
            if brec is None:
                # Couldn't verify B is not-closed -> don't exclude on it.
                continue
            bstatus = brec.get("status", "?")
            if bstatus not in CLOSED_STATUSES:
                mark_excluded(aid, bid, bstatus, REASON_BLOCKED_BY_OPEN)

    # Rule 2 — `parent-child`: B (the parent) is excluded when its child A
    # is not-closed. A is always one of the input issues, so its status is
    # already in hand; parents outside the input set are simply irrelevant.
    for it in by_id.values():
        aid = it["id"]
        astatus = it.get("status", "?")
        if astatus in CLOSED_STATUSES:
            continue
        for pid in collect_depends_on(it, "parent-child"):
            if pid in by_id:
                mark_excluded(pid, aid, astatus, REASON_PARENT_OF_OPEN)

    kept = [it for it in by_id.values() if it["id"] not in excluded]

    # ---- stdout -------------------------------------------------------
    if as_json:
        json.dump(kept, sys.stdout, indent=2)
        sys.stdout.write("\n")
    else:
        _print_human(kept)

    # ---- stderr: short report -----------------------------------------
    if verbose:
        print("", file=sys.stderr)
        print("bd-ready: %d open issue(s) in, %d kept, %d excluded."
            % (len(by_id), len(kept), len(excluded)), file=sys.stderr)
        if resolver.fetched:
            print("blockers fetched via `bd show` (%d):" % len(resolver.fetched),
                file=sys.stderr)
            for bid, st in resolver.fetched:
                if st == "closed":
                    continue
                print("    %-20s status=%s" % (bid, st), file=sys.stderr)
        if resolver.missing:
            print("WARN: could not retrieve %d blocker(s); their dependents were kept:"
                % len(resolver.missing), file=sys.stderr)
            for bid in resolver.missing:
                print("    %s" % bid, file=sys.stderr)
        if excluded:
            print("excluded:", file=sys.stderr)
            for vid in by_id:
                if vid not in excluded:
                    continue
                parts = []
                for oid, ostat, reason in excluded[vid]:
                    verb = ("blocked by" if reason == REASON_BLOCKED_BY_OPEN
                            else "parent of")
                    parts.append("%s %s[%s]" % (verb, oid, ostat))
                print("    %-20s %s" % (vid, "; ".join(parts)), file=sys.stderr)


if __name__ == "__main__":
    cli()
