#!/usr/bin/env python3
"""bingo-light — AI-native fork maintenance tool.

CLI entry point. Delegates all business logic to bingo_core.Repo.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
from typing import Any, Dict, Optional

# ─── Ensure the directory containing this script is on sys.path ──────────────
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPT_DIR not in sys.path:
    sys.path.insert(0, _SCRIPT_DIR)

from bingo_core import VERSION, BingoError, Repo  # noqa: E402
from bingo_core.setup import run_setup  # noqa: E402

# ─── Colors ──────────────────────────────────────────────────────────────────

BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
DIM = "\033[2m"
RESET = "\033[0m"


def _colors_enabled() -> bool:
    """Return True if stdout is a TTY and NO_COLOR is not set."""
    return sys.stdout.isatty() and not os.environ.get("NO_COLOR", "")


def _c(code: str, text: str) -> str:
    """Wrap text in an ANSI code if colors are enabled."""
    if _colors_enabled():
        return f"{code}{text}{RESET}"
    return text


# ─── Output helpers ──────────────────────────────────────────────────────────


def _json_print(data: dict) -> None:
    """Print a dict as compact JSON to stdout."""
    print(json.dumps(data, default=str))


def _error_exit(msg: str, json_mode: bool) -> None:
    """Print error and exit 1."""
    if json_mode:
        _json_print({"ok": False, "error": str(msg)})
    else:
        print(f"{_c(RED, 'x')} {msg}", file=sys.stderr)
    sys.exit(1)


# ─── Human-readable formatters ──────────────────────────────────────────────


def _format_status(result: dict) -> str:
    """Format status result for human reading."""
    lines = []
    lines.append("")
    lines.append(f"  {_c(BOLD, 'Upstream:')}  {result.get('upstream_url', '?')}")
    lines.append(f"  {_c(BOLD, 'Branch:')}   {result.get('upstream_branch', '?')}")
    lines.append(f"  {_c(BOLD, 'Current:')}  {result.get('current_branch', '?')}")
    lines.append("")

    behind = result.get("behind", 0)
    if behind == 0:
        lines.append(f"  {_c(GREEN, 'Up to date with upstream')}")
    else:
        lines.append(f"  {_c(YELLOW, f'{behind} commit(s) behind upstream')}")
    lines.append("")

    patches = result.get("patches", [])
    lines.append(f"  {_c(BOLD, 'Patches:')} {len(patches)}")
    lines.append("")
    for i, p in enumerate(patches, 1):
        name = p.get("name", "?")
        subject = p.get("subject", "")
        h = p.get("hash", "")[:7]
        files = p.get("files", 0)
        desc = re.sub(r"^\[bl\] [^:]+:\s*", "", subject) if subject else ""
        lines.append(
            f"   {i} {_c(CYAN, name)}  {desc}   "
            f"{_c(DIM, h)}  {files} file(s)"
        )
    lines.append("")
    return "\n".join(lines)


def _format_patch_list(result: dict) -> str:
    """Format patch list for human reading."""
    patches = result.get("patches", [])
    lines = []
    lines.append("")
    lines.append(f"{_c(BOLD, 'Patch Stack')} (bottom = applied first)")
    lines.append("")
    for i, p in enumerate(patches, 1):
        name = p.get("name", "?")
        h = p.get("hash", "")[:7]
        lines.append(f"   {i} {_c(CYAN, name)} ({_c(DIM, h)})")
        # Show file details if available (verbose mode)
        file_details = p.get("file_details", [])
        if file_details:
            for fd in file_details:
                lines.append(f"     {fd}")
        else:
            stat = p.get("stat", "")
            if stat:
                lines.append(f"     {stat}")
            else:
                ins = p.get("insertions", 0)
                dele = p.get("deletions", 0)
                files = p.get("files", 0)
                fc = "file" if files == 1 else "files"
                lines.append(
                    f"     {files} {fc} changed, {ins} insertions(+), {dele} deletions(-)"
                )
    lines.append("")
    lines.append(f"  Total: {len(patches)} patch(es)")
    lines.append("")
    return "\n".join(lines)


def _format_sync(result: dict) -> str:
    """Format sync result for human reading."""
    if result.get("dry_run"):
        behind = result.get("behind", 0)
        patches = result.get("patches", 0)
        return (
            f"{_c(YELLOW, '!')} Dry run: {behind} new upstream commit(s), "
            f"{patches} patch(es) to rebase."
        )
    if result.get("up_to_date"):
        return f"{_c(GREEN, 'OK')} Already up to date."
    if result.get("conflict"):
        files = result.get("conflicted_files", [])
        lines = [f"{_c(RED, 'x')} Sync conflict! Rebase paused."]
        for f in files:
            lines.append(f"  {_c(YELLOW, '~')} {f}")
        lines.append("\n  Resolve conflicts, then: git add <file> && git rebase --continue")
        lines.append("  Or abort: git rebase --abort")
        return "\n".join(lines)
    patches = result.get("patches_rebased", result.get("patches", 0))
    return f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased cleanly."


def _format_doctor(result: dict) -> str:
    """Format doctor result for human reading."""
    lines = []
    checks = result.get("checks", [])
    for check in checks:
        name = check.get("name", "?")
        status = check.get("status", "")
        ok = status == "pass" or check.get("ok", False)
        is_warn = status == "warn"
        if ok:
            symbol = _c(GREEN, "OK")
        elif is_warn:
            symbol = _c(YELLOW, "WARN")
        else:
            symbol = _c(RED, "FAIL")
        detail = check.get("detail", check.get("message", ""))
        lines.append(f"  {symbol}  {name}")
        if detail and (not ok or is_warn):
            lines.append(f"       {_c(DIM, detail)}")
    return "\n".join(lines)


def _format_diff(result: dict) -> str:
    """Format diff result."""
    diff_text = result.get("diff", "")
    if not diff_text:
        return "No changes vs upstream."
    return diff_text


def _format_log(result: dict) -> str:
    """Format log result — compact one-line-per-sync."""
    syncs = result.get("syncs", result.get("history", result.get("entries", [])))
    if not syncs:
        return "No sync history."
    lines = []
    for e in syncs:
        ts = e.get("timestamp", "")
        n = e.get("upstream_commits_integrated", 0)
        patches = e.get("patches", [])
        before = e.get("upstream_before", "")[:7]
        after = e.get("upstream_after", "")[:7]
        summary = f"{n} commit(s) integrated"
        if before and after:
            summary += f"  {before} \u2192 {after}"
        if patches:
            summary += f"  ({len(patches)} patch(es) rebased)"
        lines.append(f"  {_c(DIM, ts)}  {summary}")
    return "\n".join(lines)


def _format_history(result: dict) -> str:
    """Format history result — verbose with per-patch hash mappings."""
    syncs = result.get("syncs", result.get("history", result.get("entries", [])))
    if not syncs:
        return "No sync history."
    lines = []
    for e in syncs:
        ts = e.get("timestamp", "")
        n = e.get("upstream_commits_integrated", 0)
        before = e.get("upstream_before", "")[:7]
        after = e.get("upstream_after", "")[:7]
        patches = e.get("patches", [])
        lines.append(f"  {_c(BOLD, 'Sync')} @ {ts}")
        lines.append(f"    Upstream: {before} \u2192 {after} ({n} commit(s))")
        if patches:
            lines.append("    Patches rebased:")
            for p in patches:
                name = p.get("name", "?")
                h = p.get("hash", "?")[:7]
                lines.append(f"      {name}  {_c(DIM, h)}")
        lines.append("")
    return "\n".join(lines).rstrip()


def _format_conflict_analyze(result: dict) -> str:
    """Format conflict analysis."""
    conflicts = result.get("conflicts", [])
    if not conflicts:
        return f"{_c(GREEN, 'OK')} No conflicts detected."
    lines = [f"{_c(YELLOW, '!')} {len(conflicts)} conflicting file(s):"]
    for c in conflicts:
        f = c.get("file", "?")
        hint = c.get("merge_hint", "")
        lines.append(f"  {_c(RED, f)}")
        if hint:
            lines.append(f"    hint: {hint}")
    return "\n".join(lines)


def _format_conflict_resolve(result: dict) -> str:
    """Format conflict-resolve result for human reading."""
    if result.get("ok") is False:
        return f"{_c(RED, 'x')} {result.get('error', 'Resolution failed.')}"

    resolved = result.get("resolved", "?")
    remaining = result.get("remaining", [])

    if remaining:
        n = len(remaining)
        files = ", ".join(remaining)
        return (
            f"{_c(GREEN, 'OK')} Resolved {resolved}. "
            f"{n} file(s) still in conflict: {files}"
        )

    if result.get("sync_complete"):
        return (
            f"{_c(GREEN, 'OK')} Resolved {resolved} "
            f"\u2014 sync complete!"
        )

    if result.get("conflict"):
        patch = result.get("current_patch", "")
        conflicts = result.get("conflicts", [])
        lines = [
            f"{_c(GREEN, 'OK')} Resolved {resolved} "
            f"\u2014 next patch has conflicts:"
        ]
        if patch:
            lines.append(f"  Patch: {patch}")
        for c in conflicts:
            f = c.get("file", "?")
            hint = c.get("merge_hint", "")
            lines.append(f"  {_c(YELLOW, '~')} {f}")
            if hint:
                lines.append(f"    hint: {hint}")
        return "\n".join(lines)

    if result.get("rebase_continued"):
        return (
            f"{_c(GREEN, 'OK')} Resolved {resolved} "
            f"\u2014 rebase continued."
        )

    return f"{_c(GREEN, 'OK')} Resolved {resolved}."


def _format_session(result: dict) -> str:
    """Format session output."""
    content = result.get("content", result.get("session", ""))
    if content:
        return content
    return result.get("message", "Session updated.")


def _format_config(result: dict) -> str:
    """Format config output."""
    # config list
    items = result.get("items", result.get("config", None))
    if items is not None:
        if isinstance(items, dict):
            lines = [f"  {k} = {v}" for k, v in items.items()]
            return "\n".join(lines) if lines else "No configuration set."
        if isinstance(items, list):
            lines = [f"  {item}" for item in items]
            return "\n".join(lines) if lines else "No configuration set."
    # config get
    value = result.get("value", None)
    if value is not None:
        return str(value)
    return result.get("message", "OK")


def _format_test(result: dict) -> str:
    """Format test result."""
    passed = result.get("test") == "pass" or result.get("passed", False)
    output = result.get("output", "")
    status = _c(GREEN, "PASS") if passed else _c(RED, "FAIL")
    lines = [f"Test: {status}"]
    if output:
        lines.append(output)
    return "\n".join(lines)


def _format_auto_sync(result: dict) -> str:
    """Format auto-sync result."""
    schedule = result.get("schedule", "")
    return f"{_c(GREEN, 'OK')} GitHub Actions workflow generated ({schedule})."


def _format_workspace(result: dict) -> str:
    """Format workspace result."""
    # workspace list / status
    repos = result.get("repos", None)
    if repos is not None:
        if not repos:
            return "No repos in workspace."
        lines = [f"{_c(BOLD, 'Workspace repos:')}"]
        for r in repos:
            alias = r.get("alias", "?")
            path = r.get("path", "?")
            status = r.get("status", "")
            behind = r.get("behind", None)
            patches = r.get("patches", None)
            line = f"  {_c(CYAN, alias)}  {_c(DIM, path)}"
            if behind is not None:
                if behind > 0:
                    line += f"  {_c(YELLOW, f'{behind} behind')}"
                else:
                    line += f"  {_c(GREEN, 'up to date')}"
                line += f"  {patches} patch(es)"
            elif status == "missing":
                line += f"  {_c(RED, 'missing')}"
            elif status == "error":
                line += f"  {_c(RED, r.get('error', 'error'))}"
            lines.append(line)
        return "\n".join(lines)
    # workspace sync
    synced = result.get("synced", None)
    if synced is not None:
        lines = []
        for s in synced:
            alias = s.get("alias", "?")
            st = s.get("status", "?")
            color = GREEN if st == "ok" else RED
            lines.append(f"  {_c(color, st):>12}  {alias}")
        return "\n".join(lines)
    return result.get("message", "OK")


def _format_patch_show(result: dict) -> str:
    """Format patch show."""
    patch = result.get("patch", {})
    diff_text = patch.get("diff", "") if isinstance(patch, dict) else ""
    if not diff_text:
        # fallback: top-level diff key
        diff_text = result.get("diff", "")
    return diff_text if diff_text else "Empty patch."


def _format_init(result: dict) -> str:
    """Format init result."""
    upstream = result.get("upstream", "")
    branch = result.get("branch", "")
    reinit = result.get("reinit", False)
    label = "Re-initialized (config updated)." if reinit else "Fork tracking initialized."
    return (
        f"{_c(GREEN, 'OK')} {label}\n"
        f"  Upstream: {upstream}\n"
        f"  Branch: {branch}\n"
        f"  Tracking: {result.get('tracking', '')}\n"
        f"  Patches: {result.get('patches', '')}"
    )


def _format_patch_new(result: dict) -> str:
    """Format patch new result."""
    name = result.get("patch", "")
    h = result.get("hash", "")
    desc = result.get("description", "")
    return (
        f"{_c(GREEN, 'OK')} Patch created: {_c(CYAN, name)} ({_c(DIM, h)})\n"
        f"  {desc}"
    )


def _format_patch_drop(result: dict) -> str:
    """Format patch drop result."""
    name = result.get("dropped", "")
    h = result.get("hash", "")
    return f"{_c(GREEN, 'OK')} Dropped patch: {_c(CYAN, name)} ({_c(DIM, h)})"


def _format_patch_export(result: dict) -> str:
    """Format patch export result."""
    count = result.get("count", 0)
    directory = result.get("directory", "")
    return f"{_c(GREEN, 'OK')} Exported {count} patch(es) to {directory}"


def _format_patch_import(result: dict) -> str:
    """Format patch import result."""
    count = result.get("patch_count", 0)
    return f"{_c(GREEN, 'OK')} Import complete. {count} patch(es) in stack."


def _format_smart_sync(result: dict) -> str:
    """Format smart-sync result for human reading."""
    if result.get("ok") is False:
        action = result.get("action", "")
        if action == "needs_human":
            conflicts = result.get("remaining_conflicts", [])
            auto = result.get("conflicts_auto_resolved", 0)
            patch = result.get("current_patch", "")
            lines = [f"{_c(RED, 'x')} Smart sync: unresolved conflict(s)."]
            if patch:
                lines.append(f"  Patch: {patch}")
            if auto:
                lines.append(f"  Auto-resolved: {auto} step(s) via rerere")
            for c_ in conflicts:
                f = c_.get("file", c_) if isinstance(c_, dict) else c_
                lines.append(f"  {_c(YELLOW, '~')} {f}")
            lines.append(
                "\n  Resolve conflicts, then: git add <file> && "
                "git rebase --continue"
            )
            lines.append("  Or abort: git rebase --abort")
            return "\n".join(lines)
        err = result.get("error", "Operation failed.")
        return f"{_c(RED, 'x')} {err}"
    action = result.get("action", "")
    if action == "none":
        return f"{_c(GREEN, 'OK')} Already up to date."
    patches = result.get("patches_rebased", 0)
    auto = result.get("conflicts_resolved", 0)
    msg = f"{_c(GREEN, 'OK')} Sync complete! {patches} patch(es) rebased."
    if auto:
        msg += f" ({auto} conflict(s) auto-resolved via rerere)"
    return msg


def _format_patch_meta(result: dict) -> str:
    """Format patch meta result for human reading."""
    if result.get("ok") is False:
        return f"{_c(RED, 'x')} {result.get('error', 'Failed.')}"
    # Set operation — echo what was set
    if "set" in result:
        k = result["set"]
        v = result["value"]
        patch = result.get("patch", "")
        return f"{_c(GREEN, 'OK')} {patch}: {k} = {v}"
    # Get all metadata
    meta = result.get("meta")
    if meta is not None:
        patch = result.get("patch", "")
        lines = [f"{_c(BOLD, patch)}"]
        for k, v in meta.items():
            if k == "tags":
                v = ", ".join(v) if v else "(none)"
            elif v is None:
                v = "(not set)"
            elif v == "":
                v = "(not set)"
            lines.append(f"  {k}: {v}")
        return "\n".join(lines)
    # Get single key
    k = result.get("key", "")
    v = result.get("value", "")
    if isinstance(v, list):
        v = ", ".join(v) if v else "(none)"
    elif v is None or v == "":
        v = "(not set)"
    return f"  {k}: {v}" if k else f"{_c(GREEN, 'OK')} Done."


def _format_undo(result: dict) -> str:
    """Format undo result."""
    if result.get("ok") is False:
        err = result.get("error", "Undo failed.")
        return f"{_c(RED, 'x')} {err}"
    msg = result.get("message", "")
    if msg:
        return f"{_c(GREEN, 'OK')} {msg}"
    restored = result.get("restored_to", "")[:7]
    return f"{_c(GREEN, 'OK')} Restored to {restored}"


def _format_setup(result: dict) -> str:
    """Setup writes its own interactive output to stderr; nothing extra needed."""
    return ""


def _format_generic(result: dict) -> str:
    """Fallback formatter: show message or OK."""
    if result.get("ok") is False:
        err = result.get("error", "Operation failed.")
        return f"{_c(RED, 'x')} {err}"
    msg = result.get("message", "")
    if msg:
        return f"{_c(GREEN, 'OK')} {msg}"
    return f"{_c(GREEN, 'OK')} Done."


# ─── Help text ───────────────────────────────────────────────────────────────


def show_help() -> str:
    """Return the help text, mimicking the original Bash version."""
    c = _colors_enabled()
    b = BOLD if c else ""
    cy = CYAN if c else ""
    dm = DIM if c else ""
    r = RESET if c else ""

    return f"""
  bingo-light — AI-native fork maintenance tool.

  Manages your customizations as a clean patch stack on top of upstream,
  so syncing with upstream is always a one-command operation.

  {b}Usage:{r} bingo-light <command> [options]

  {b}Setup:{r}
    {cy}init{r} <upstream-url> [branch]   Initialize fork tracking
    {cy}setup{r}                          Configure MCP for AI tools (interactive)

  {b}Patch Management:{r}
    {cy}patch new{r} <name>               Create a new patch
    {cy}patch list{r} [-v]                 List all patches
    {cy}patch show{r} <name|index>         Show patch diff
    {cy}patch edit{r} <name|index>         Amend an existing patch
    {cy}patch drop{r} <name|index>         Remove a patch
    {cy}patch export{r} [dir]              Export patches as .patch files
    {cy}patch import{r} <file|dir>         Import .patch files
    {cy}patch reorder{r}                   Reorder patch stack
    {cy}patch squash{r} <idx1> <idx2>       Merge two patches
    {cy}patch meta{r} <name> [key] [val]   Get/set patch metadata

  {b}Sync with Upstream:{r}
    {cy}sync{r} [--dry-run] [--force] [--test]  Rebase patches onto latest upstream
    {cy}smart-sync{r}                      One-shot sync: auto-resolves conflicts via rerere
    {cy}undo{r}                            Undo last sync

  {b}Monitoring:{r}
    {cy}status{r}                          Health check & conflict prediction
    {cy}doctor{r}                          Diagnose setup issues
    {cy}diff{r}                            Show all changes vs upstream
    {cy}log{r}                             Show sync history
    {cy}conflict-analyze{r}                Analyze rebase conflicts (structured output)
    {cy}conflict-resolve{r} <file>         Resolve a conflict file and continue
    {cy}history{r}                         Detailed sync history with hash mappings
    {cy}session{r} [update]                 AI session notes (.bingo/session.md)

  {b}Configuration:{r}
    {cy}config{r} get|set|list             Manage configuration
    {cy}test{r}                            Run configured test suite

  {b}Automation:{r}
    {cy}auto-sync{r}                       Generate GitHub Actions workflow

  {b}Multi-repo:{r}
    {cy}workspace{r} init|add|remove|status|sync|list  Multi-repo management

  {b}Quick Start:{r}

    {dm}# 1. Fork a project on GitHub, clone your fork{r}
    git clone https://github.com/YOU/project.git
    cd project

    {dm}# 2. Initialize bingo-light{r}
    bingo-light init https://github.com/ORIGINAL/project.git

    {dm}# 3. Make changes and create patches{r}
    vim src/feature.py
    bingo-light patch new my-custom-feature

    {dm}# 4. Later, sync with upstream{r}
    bingo-light sync

  {dm}Version {VERSION}{r}
"""


# ─── Argument parsing ────────────────────────────────────────────────────────


class _BingoParser(argparse.ArgumentParser):
    """Custom parser that emits bingo-light style errors instead of argparse defaults."""

    def error(self, message: str) -> None:
        # Detect if --json was in sys.argv
        json_mode = "--json" in sys.argv
        # Rewrite argparse "invalid choice" to include "unknown" keyword for test compat
        if "invalid choice:" in message:
            # Extract the bad command name from argparse message
            import re as _re
            m = _re.search(r"invalid choice: '([^']+)'", message)
            if m:
                message = f"Unknown command: {m.group(1)}"
        _error_exit(
            f"{message}. Run 'bingo-light --help' for usage.",
            json_mode,
        )


def build_parser() -> argparse.ArgumentParser:
    """Build the CLI argument parser with all subcommands."""
    parser = _BingoParser(
        prog="bingo-light",
        description="AI-native fork maintenance tool.",
        add_help=False,
    )
    # Note: --json, --yes, --version, --help are pre-extracted in main()
    # before argparse runs. They are NOT defined here.

    sub = parser.add_subparsers(dest="command")

    # init
    p_init = sub.add_parser("init", add_help=False)
    p_init.add_argument("upstream_url")
    p_init.add_argument("branch", nargs="?", default="")

    # status
    sub.add_parser("status", aliases=["st"], add_help=False)

    # sync
    p_sync = sub.add_parser("sync", aliases=["s"], add_help=False)
    p_sync.add_argument("--dry-run", dest="dry_run", action="store_true")
    p_sync.add_argument("--force", action="store_true")
    p_sync.add_argument("--test", action="store_true")

    # smart-sync
    sub.add_parser("smart-sync", add_help=False)

    # undo
    sub.add_parser("undo", add_help=False)

    # diff
    sub.add_parser("diff", aliases=["d"], add_help=False)

    # doctor
    sub.add_parser("doctor", add_help=False)

    # log
    sub.add_parser("log", add_help=False)

    # history
    sub.add_parser("history", add_help=False)

    # conflict-analyze
    sub.add_parser("conflict-analyze", add_help=False)

    # conflict-resolve
    cr = sub.add_parser("conflict-resolve", add_help=False)
    cr.add_argument("resolve_file", nargs="?", default="")
    cr.add_argument("--content-stdin", action="store_true")

    # session
    p_session = sub.add_parser("session", add_help=False)
    p_session.add_argument("session_action", nargs="?", default="")

    # config
    p_config = sub.add_parser("config", add_help=False)
    p_config.add_argument("config_action", choices=["get", "set", "list"])
    p_config.add_argument("config_key", nargs="?", default="")
    p_config.add_argument("config_value", nargs="?", default="")

    # test
    sub.add_parser("test", add_help=False)

    # auto-sync
    p_auto = sub.add_parser("auto-sync", add_help=False)
    p_auto.add_argument("--schedule", default="daily")

    # patch
    p_patch = sub.add_parser("patch", aliases=["p"], add_help=False)
    patch_sub = p_patch.add_subparsers(dest="patch_command")

    pp_new = patch_sub.add_parser("new", add_help=False)
    pp_new.add_argument("patch_name")

    pp_list = patch_sub.add_parser("list", add_help=False)
    pp_list.add_argument("-v", "--verbose", action="store_true")

    pp_show = patch_sub.add_parser("show", add_help=False)
    pp_show.add_argument("target")

    pp_drop = patch_sub.add_parser("drop", add_help=False)
    pp_drop.add_argument("target")

    pp_edit = patch_sub.add_parser("edit", add_help=False)
    pp_edit.add_argument("target")

    pp_export = patch_sub.add_parser("export", add_help=False)
    pp_export.add_argument("output_dir", nargs="?", default=".bl-patches")

    pp_import = patch_sub.add_parser("import", add_help=False)
    pp_import.add_argument("import_path")

    pp_reorder = patch_sub.add_parser("reorder", add_help=False)
    pp_reorder.add_argument("--order", default="")
    pp_reorder.add_argument("order_positional", nargs="?", default="")

    pp_squash = patch_sub.add_parser("squash", add_help=False)
    pp_squash.add_argument("idx1", type=int)
    pp_squash.add_argument("idx2", type=int)

    pp_meta = patch_sub.add_parser("meta", add_help=False)
    pp_meta.add_argument("meta_target")
    pp_meta.add_argument("meta_key", nargs="?", default="")
    pp_meta.add_argument("meta_value", nargs="?", default="")

    # workspace
    p_ws = sub.add_parser("workspace", aliases=["ws"], add_help=False)
    ws_sub = p_ws.add_subparsers(dest="ws_command")

    ws_sub.add_parser("init", add_help=False)

    ws_add = ws_sub.add_parser("add", add_help=False)
    ws_add.add_argument("ws_path", nargs="?", default="")
    ws_add.add_argument("--alias", default="")

    ws_sub.add_parser("list", add_help=False)
    ws_sub.add_parser("sync", add_help=False)
    ws_sub.add_parser("status", add_help=False)

    ws_remove = ws_sub.add_parser("remove", add_help=False)
    ws_remove.add_argument("ws_target")

    # setup
    p_setup = sub.add_parser("setup", add_help=False)
    p_setup.add_argument("--no-completions", dest="no_completions", action="store_true")

    # help
    sub.add_parser("help", add_help=False)

    return parser


# ─── Dispatch ────────────────────────────────────────────────────────────────


def dispatch(args: argparse.Namespace, json_mode: bool) -> Optional[dict]:
    """Dispatch a parsed command to the Repo method and return the result dict."""
    cmd = args.command

    # setup doesn't need a Repo (works outside git repos)
    if cmd == "setup":
        return run_setup(
            yes=getattr(args, "yes_mode", False),
            json_mode=json_mode,
            no_completions=getattr(args, "no_completions", False),
        )

    repo = Repo()

    if cmd == "init":
        return repo.init(args.upstream_url, args.branch)

    if cmd in ("status", "st"):
        return repo.status()

    if cmd in ("sync", "s"):
        return repo.sync(
            dry_run=args.dry_run,
            force=args.force or getattr(args, "yes_mode", False),
            test=args.test,
        )

    if cmd == "smart-sync":
        return repo.smart_sync()

    if cmd == "undo":
        return repo.undo()

    if cmd in ("diff", "d"):
        return repo.diff()

    if cmd == "doctor":
        return repo.doctor()

    if cmd == "log":
        return repo.history()

    if cmd == "history":
        return repo.history()

    if cmd == "conflict-analyze":
        return repo.conflict_analyze()

    if cmd == "conflict-resolve":
        content = ""
        if args.content_stdin:
            import sys as _sys
            content = _sys.stdin.read()
        return repo.conflict_resolve(args.resolve_file, content)

    if cmd == "session":
        update = (args.session_action == "update")
        return repo.session(update=update)

    if cmd == "config":
        if args.config_action == "get":
            if not args.config_key:
                raise BingoError("config get requires a key.")
            return repo.config_get(args.config_key)
        if args.config_action == "set":
            if not args.config_key or not args.config_value:
                raise BingoError("config set requires <key> <value>.")
            return repo.config_set(args.config_key, args.config_value)
        if args.config_action == "list":
            return repo.config_list()

    if cmd == "test":
        return repo.test()

    if cmd == "auto-sync":
        return repo.auto_sync(schedule=getattr(args, "schedule", "daily"))

    if cmd in ("patch", "p"):
        return _dispatch_patch(args, repo)

    if cmd in ("workspace", "ws"):
        return _dispatch_workspace(args, repo)

    return None


def _dispatch_patch(args: argparse.Namespace, repo: Repo) -> dict:
    """Dispatch patch subcommands."""
    pcmd = args.patch_command
    if not pcmd:
        raise BingoError(
            "patch requires a subcommand: new, list, show, edit, drop, "
            "export, import, reorder, squash, meta"
        )

    if pcmd == "new":
        desc = os.environ.get("BINGO_DESCRIPTION", "")
        return repo.patch_new(args.patch_name, description=desc)

    if pcmd == "list":
        return repo.patch_list(verbose=args.verbose)

    if pcmd == "show":
        return repo.patch_show(args.target)

    if pcmd == "drop":
        return repo.patch_drop(args.target)

    if pcmd == "edit":
        return repo.patch_edit(args.target)

    if pcmd == "export":
        return repo.patch_export(args.output_dir)

    if pcmd == "import":
        return repo.patch_import(args.import_path)

    if pcmd == "reorder":
        order = args.order or getattr(args, "order_positional", "")
        return repo.patch_reorder(order=order)

    if pcmd == "squash":
        return repo.patch_squash(args.idx1, args.idx2)

    if pcmd == "meta":
        meta_key = args.meta_key or ""
        meta_value = args.meta_value or ""
        # Support "set-reason VALUE" as shorthand for "reason VALUE"
        if meta_key.startswith("set-") and meta_value:
            meta_key = meta_key[4:]  # "set-reason" -> "reason"
        return repo.patch_meta(
            args.meta_target,
            key=meta_key,
            value=meta_value,
        )

    raise BingoError(f"Unknown patch subcommand: {pcmd}")


def _dispatch_workspace(args: argparse.Namespace, repo: Repo) -> dict:
    """Dispatch workspace subcommands."""
    wcmd = args.ws_command
    if not wcmd:
        raise BingoError(
            "workspace requires a subcommand: init, add, remove, list, sync, status"
        )

    if wcmd == "init":
        return repo.workspace_init()

    if wcmd == "add":
        return repo.workspace_add(
            repo_path=args.ws_path,
            alias=args.alias,
        )

    if wcmd == "list":
        return repo.workspace_list()

    if wcmd == "sync":
        return repo.workspace_sync()

    if wcmd == "status":
        return repo.workspace_status()

    if wcmd == "remove":
        return repo.workspace_remove(args.ws_target)

    raise BingoError(f"Unknown workspace subcommand: {wcmd}")


# ─── Formatter dispatch ─────────────────────────────────────────────────────

_FORMATTERS: Dict[str, Any] = {
    "init": _format_init,
    "status": _format_status,
    "st": _format_status,
    "sync": _format_sync,
    "s": _format_sync,
    "doctor": _format_doctor,
    "diff": _format_diff,
    "d": _format_diff,
    "log": _format_log,
    "history": _format_history,
    "conflict-analyze": _format_conflict_analyze,
    "conflict-resolve": _format_conflict_resolve,
    "session": _format_session,
    "test": _format_test,
    "auto-sync": _format_auto_sync,
    "undo": _format_undo,
    "smart-sync": _format_smart_sync,
    "setup": _format_setup,
}


def _get_formatter(args: argparse.Namespace):
    """Return the appropriate human-output formatter for a command."""
    cmd = args.command

    # patch subcommands
    if cmd in ("patch", "p"):
        pcmd = getattr(args, "patch_command", "")
        if pcmd == "list":
            return _format_patch_list
        if pcmd == "show":
            return _format_patch_show
        if pcmd == "new":
            return _format_patch_new
        if pcmd == "drop":
            return _format_patch_drop
        if pcmd == "export":
            return _format_patch_export
        if pcmd == "import":
            return _format_patch_import
        if pcmd == "meta":
            return _format_patch_meta
        return _format_generic

    # workspace subcommands
    if cmd in ("workspace", "ws"):
        return _format_workspace

    # config
    if cmd == "config":
        return _format_config

    return _FORMATTERS.get(cmd, _format_generic)


# ─── Main ────────────────────────────────────────────────────────────────────


def main() -> None:
    """Entry point."""
    # ── Pre-extract global flags before argparse (subparsers don't inherit them) ──
    raw_args = sys.argv[1:]
    json_mode = False
    yes_mode = False
    show_version = False
    show_help_flag = False
    cleaned = []
    for arg in raw_args:
        if arg == "--json":
            json_mode = True
        elif arg in ("--yes", "-y"):
            yes_mode = True
        elif arg in ("--version",):
            show_version = True
        elif arg in ("--help", "-h"):
            show_help_flag = True
        else:
            cleaned.append(arg)

    # Auto-enable yes when stdin is not a TTY (AI agent / pipe)
    if not sys.stdin.isatty():
        yes_mode = True

    # --version (before argparse)
    if show_version:
        if json_mode:
            _json_print({"ok": True, "version": VERSION})
        else:
            print(f"bingo-light {VERSION}")
        return

    # --help or no args or 'help' command
    if show_help_flag or not cleaned or (cleaned and cleaned[0] == "help"):
        if json_mode:
            _json_print({"ok": True, "help": True, "version": VERSION})
        else:
            print(show_help())
        return

    # ── Parse remaining args with argparse ──
    parser = build_parser()
    args, unknown = parser.parse_known_args(cleaned)
    args.json_mode = json_mode
    args.yes_mode = yes_mode

    # No command after parsing
    if args.command is None:
        print(show_help())
        return

    # Unknown args check
    if unknown:
        _error_exit(
            f"Unknown argument(s): {' '.join(unknown)}. "
            "Run 'bingo-light --help' for usage.",
            json_mode,
        )

    # Dispatch
    try:
        result = dispatch(args, json_mode)
    except BingoError as e:
        _error_exit(str(e), json_mode)
        return  # unreachable, _error_exit calls sys.exit
    except KeyboardInterrupt:
        _error_exit("Interrupted.", json_mode)
        return

    if result is None:
        _error_exit(
            f"Unknown command: {args.command}. Run 'bingo-light --help' for usage.",
            json_mode,
        )
        return

    # Output
    if json_mode:
        _json_print(result)
    else:
        formatter = _get_formatter(args)
        output = formatter(result)
        if output:
            print(output)

    # Exit non-zero if result indicates failure
    if isinstance(result, dict) and result.get("ok") is False:
        sys.exit(1)


if __name__ == "__main__":
    main()
