#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve the party-mode roster, lazily.

Merges the installed ACL agents with the user's custom `party_members`
into one collective, then projects only what the moment needs:

  * default (no flag) — the active roster to load on entry: the
    `default_party` group if one is configured, else the whole collective.
    Other groups come back as names only, so nothing you aren't using is
    loaded into the party.
  * --list-groups — just id + name + size for every configured group. The
    cheap menu for "which room?", with no member detail.
  * --party <id> — full member detail for one chosen group, on demand
    (e.g. when the user switches rooms). Unknown id returns the available
    names instead of an error wall.

The merge is deterministic (a keyed union; a custom member whose code
matches an installed agent overrides it), so the orchestrator consumes a
resolved roster instead of re-deriving it every session.

Stdlib only (Python 3.11+ for tomllib). Shells out to the project's
resolve_config.py and resolve_customization.py; falls back to reading
customize.toml directly if the customization resolver is unavailable.

  resolve_party.py --project-root P --skill S
  resolve_party.py --project-root P --skill S --list-groups
  resolve_party.py --project-root P --skill S --party writers-room
"""

import argparse
import json
import subprocess
import sys
from pathlib import Path

try:
    import tomllib
except ImportError:  # pragma: no cover - guarded for <3.11
    sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
    sys.exit(3)


def _run_json(cmd):
    """Run a resolver script and parse its JSON stdout. None on any failure."""
    try:
        out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
    except (OSError, subprocess.SubprocessError):
        return None
    if out.returncode != 0 or not out.stdout.strip():
        return None
    try:
        return json.loads(out.stdout)
    except json.JSONDecodeError:
        return None


def load_agents(project_root: Path):
    """Installed agents as {code: entry}. Empty dict (with a flag) on failure."""
    script = project_root / "_acl" / "scripts" / "resolve_config.py"
    data = _run_json([sys.executable, str(script), "--project-root", str(project_root), "--key", "agents"])
    if data is None:
        return {}, False
    return data.get("agents", {}) or {}, True


def load_workflow(project_root: Path, skill_root: Path):
    """Merged [workflow] table. Falls back to the skill's base customize.toml."""
    script = project_root / "_acl" / "scripts" / "resolve_customization.py"
    data = _run_json([sys.executable, str(script), "--skill", str(skill_root), "--key", "workflow"])
    if data is not None and "workflow" in data:
        return data["workflow"]
    # Fallback: read the skill's base customize.toml directly (no override merge).
    toml_path = skill_root / "customize.toml"
    if toml_path.exists():
        try:
            with toml_path.open("rb") as f:
                return tomllib.load(f).get("workflow", {})
        except (OSError, tomllib.TOMLDecodeError):
            pass
    return {}


def _alias(code: str) -> str:
    """Short alias for an installed agent code: acl-agent-analyst -> analyst."""
    for prefix in ("acl-agent-", "acl-"):
        if code.startswith(prefix):
            return code[len(prefix):]
    return code


def build_collective(agents: dict, party_members: list):
    """One pool keyed by code. Custom members override matching installed agents.

    Returns (collective, index, installed_codes):
      * collective — every member (installed + custom), the pool groups draw
        from and the orchestrator can summon by name.
      * index — maps every resolvable token (code, prefix-stripped alias,
        lower-cased name) to a canonical code.
      * installed_codes — the codes occupying an installed-agent slot, in
        order. This is the DEFAULT room: installed agents (with any custom
        override applied in place), and NOT the pure-custom additions. So
        shipping or defining custom members grows the pool without crowding
        the default party.
    """
    collective = {}
    index = {}
    installed_codes = []

    def register(code, entry):
        collective[code] = entry
        index[code] = code
        index[code.lower()] = code
        index[_alias(code).lower()] = code
        name = entry.get("name")
        if name:
            index[name.lower()] = code

    for code, info in agents.items():
        register(code, {
            "code": code,
            "name": info.get("name", code),
            "icon": info.get("icon", ""),
            "title": info.get("title", ""),
            "description": info.get("description", ""),
            "module": info.get("module", ""),
            "team": info.get("team", ""),
            "source": "installed",
        })
        installed_codes.append(code)

    for m in (party_members if isinstance(party_members, list) else []):
        if not isinstance(m, dict):
            continue
        code = m.get("code")
        if not code:
            continue
        # A custom member overrides an installed agent it matches by code/alias/name.
        canonical = index.get(code) or index.get(code.lower()) or code
        # Start from the installed entry so fields the override omits
        # (icon, title, description, module, team) survive.
        entry = dict(collective.get(canonical, {}))
        entry.update({"code": canonical, "source": "custom"})
        for field in ("name", "icon", "title", "persona", "capabilities", "model"):
            if m.get(field) is not None:
                entry[field] = m[field]
        entry.setdefault("name", canonical)
        register(canonical, entry)
        # An override keeps the installed slot; a brand-new custom does not join it.

    return collective, index, installed_codes


def resolve_members(member_tokens, collective, index):
    """(resolved entries in listed order, unresolved tokens)."""
    resolved, unresolved = [], []
    for token in member_tokens or []:
        if not isinstance(token, str):
            unresolved.append(token)  # malformed config value — never a key lookup
            continue
        code = index.get(token) or index.get(token.lower())
        if code and code in collective:
            resolved.append(collective[code])
        else:
            unresolved.append(token)
    return resolved, unresolved


def group_menu(groups):
    """Names only — the cheap menu. Open-cast groups (no roster) are flagged."""
    out = []
    for g in groups or []:
        if not isinstance(g, dict) or not g.get("id"):
            continue
        members = g.get("members", []) or []
        entry = {"id": g["id"], "name": g.get("name", g["id"]),
                 "member_count": len(members)}
        if not members:
            entry["open_cast"] = True
        out.append(entry)
    return out


def find_group(groups, group_id):
    for g in groups or []:
        if isinstance(g, dict) and g.get("id") == group_id:
            return g
    return None


def group_detail(g, collective, index):
    """Full detail for one group: resolved members + the optional scene.

    `scene` is a freeform line the orchestrator plays — setting, what's
    happening, room dynamics, in-the-moment character notes. Surfaced only
    here (when a group is the active/chosen roster), never in the menu.

    `members` is optional. With none, the group is open-cast: `open_cast`
    is flagged and the scene describes the pool the orchestrator casts from
    on the fly (e.g. "figures from the Star Wars Rebels universe"). A few
    listed members anchor the room; the scene can still invite more.
    """
    raw_members = g.get("members", []) or []
    members, unresolved = resolve_members(raw_members, collective, index)
    detail = {"active": g["id"], "name": g.get("name", g["id"]),
              "members": members, "unresolved": unresolved,
              "memory_enabled": bool(g.get("memory", False))}
    if g.get("scene"):
        detail["scene"] = g["scene"]
    if not raw_members:
        detail["open_cast"] = True
    return detail


def main():
    ap = argparse.ArgumentParser(description="Resolve the party-mode roster, lazily.")
    ap.add_argument("--project-root", required=True)
    ap.add_argument("--skill", required=True, help="Path to the acl-party-mode skill dir")
    ap.add_argument("--party", help="Resolve full detail for this group id")
    ap.add_argument("--list-groups", action="store_true", help="Group names only")
    args = ap.parse_args()

    project_root = Path(args.project_root).resolve()
    skill_root = Path(args.skill).resolve()

    workflow = load_workflow(project_root, skill_root)
    groups = workflow.get("party_groups", []) or []
    default_party = workflow.get("default_party", "") or ""
    party_mode = workflow.get("party_mode", "session") or "session"
    # The global party_memory flag governs only the DEFAULT installed-agent room;
    # a named group carries its own `memory` flag (resolved in group_detail).
    party_memory = bool(workflow.get("party_memory", True))

    # Group menu never needs the (more expensive) installed-agent resolve.
    if args.list_groups:
        _emit({
            "party_mode": party_mode,
            "default_party": default_party,
            "groups": group_menu(groups),
        })
        return

    agents, agents_ok = load_agents(project_root)
    collective, index, installed_codes = build_collective(agents, workflow.get("party_members", []))

    if args.party:
        g = find_group(groups, args.party)
        if g is None:
            _emit({"error": "unknown_group", "requested": args.party,
                   "available": group_menu(groups)})
            return
        _emit({**group_detail(g, collective, index), "party_mode": party_mode})
        return

    # Default: the active roster to load on entry.
    result = {"party_mode": party_mode, "groups": group_menu(groups),
              "installed_agents_resolved": agents_ok}
    g = find_group(groups, default_party) if default_party else None
    if g is not None:
        result.update(group_detail(g, collective, index))
    else:
        # No default group: the installed agents (custom additions stay in the
        # pool but don't crowd the default room), exactly like a plain install.
        result.update({"active": "installed",
                       "members": [collective[c] for c in installed_codes],
                       "memory_enabled": party_memory})
    _emit(result)


def _emit(obj):
    reconfigure = getattr(sys.stdout, "reconfigure", None)
    if reconfigure is not None:
        reconfigure(encoding="utf-8")
    sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")


if __name__ == "__main__":
    main()
