"""Stale team-member reconciliation for run-end teardown.

A Claude Code team member clears its own ``isActive`` flag in
``~/.claude/teams/<team>/config.json`` when its ``Agent()`` dispatch returns, so
by Phase 7 every worker is normally already inactive. The one failure mode is a
member whose tmux pane died WITHOUT clearing the flag (killed mid-turn): it stays
``isActive: true`` forever and keeps showing as a live member that re-sending
``shutdown_request`` cannot clear — the addressee is already gone.

This module reconciles exactly that case: a member with ``isActive`` truthy
whose recorded tmux pane is no longer live is flipped to inactive. It NEVER
touches a member whose pane is still live, the lead, or a member with no
recorded pane — those are left for graceful shutdown.

CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
keeps the roster honest so a dismissed-but-dead member does not linger as a
live pill — see okstra-lead-contract.md "Run-scoped worker-resource lifecycle"
for neutral cleanup ownership and the selected Claude adapter's
"Run-scoped resource lifecycle" for this provider-specific reconciliation.

Roster resolution (``--project-root``) exists because ``team-state.lead.sessionId``
is a phase-3 snapshot: Claude Code re-issues the session id across resume /
compaction mid-run, so the live on-disk roster often sits under a DIFFERENT
``session-*`` dir than the snapshot names. Resolving by the CURRENT live session
(the newest jsonl under the encoded-cwd projects dir) is what makes split-pane
teardown find the teammates it must dismiss.
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path

from okstra_ctl.session import resolve_inproc_lead_session_id
from okstra_ctl.json_boundary import (
    JsonBoundaryError,
    external_claude_team_json_source,
    load_external_json,
)


def live_pane_ids() -> tuple[bool, set[str]]:
    """Return ``(tmux_available, live_pane_ids)``.

    ``tmux_available`` is False when no tmux server is reachable; the caller must
    then skip reconciliation rather than treat every pane as dead.
    """
    try:
        out = subprocess.run(
            ["tmux", "list-panes", "-a", "-F", "#{pane_id}"],
            capture_output=True, text=True,
        )
    except FileNotFoundError:
        return False, set()
    if out.returncode != 0:
        return False, set()
    return True, {ln.strip() for ln in out.stdout.splitlines() if ln.strip()}


def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
    """Flip dead-pane stale-active members to inactive in-place.

    Returns the names of members that were flipped. A member qualifies only when
    it is not the lead, ``isActive`` is truthy, it has a recorded ``tmuxPaneId``,
    and that pane is not among ``live_panes``.
    """
    lead = config.get("leadAgentId")
    flipped: list[str] = []
    for member in config.get("members", []):
        if member.get("agentId") == lead:
            continue
        pane = (member.get("tmuxPaneId") or "").strip()
        if not member.get("isActive") or not pane:
            continue
        if pane not in live_panes:
            member["isActive"] = False
            flipped.append(member.get("name", member.get("agentId", "?")))
    return flipped


def dismissible_members(config: dict) -> list[str]:
    """Names of every non-lead member — the set the lead may ``shutdown_request``.

    The lead further filters by confirmed-complete (result path present) before
    dismissing; this just tells it which teammates the resolved roster holds.
    """
    lead = config.get("leadAgentId")
    names: list[str] = []
    for member in config.get("members", []):
        if member.get("agentId") == lead:
            continue
        names.append(member.get("name", member.get("agentId", "?")))
    return names


def _team_config_for_session(session_id: str) -> Path:
    """``~/.claude/teams/session-<prefix>/config.json`` for a session id.

    The harness names the team dir with the session id's first segment (the
    8-hex stem before the first dash), never the ``teamName`` label.
    """
    prefix = session_id.split("-", 1)[0]
    return Path.home() / ".claude" / "teams" / f"session-{prefix}" / "config.json"


def _live_team_config(project_root: Path) -> Path | None:
    """Resolve the CURRENT live session's team config, or None when absent.

    Robust to the mid-run session-id drift that strands the phase-3
    ``lead.sessionId`` snapshot: keys off the live session, not the snapshot.
    """
    sid = resolve_inproc_lead_session_id(project_root)
    if not sid:
        return None
    cand = _team_config_for_session(sid)
    return cand if cand.is_file() else None


def _config_path(team_arg: str) -> Path:
    """Resolve a team name, a team dir, or a config.json path to the config file."""
    p = Path(team_arg)
    if p.name == "config.json":
        return p
    if p.is_dir():
        return p / "config.json"
    return Path.home() / ".claude" / "teams" / team_arg / "config.json"


def _atomic_write(path: Path, text: str) -> None:
    """Replace ``path`` atomically — config.json is the harness's roster source
    of truth, so a partial write on crash must never be observable."""
    fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".reconcile-")
    try:
        with os.fdopen(fd, "w") as handle:
            handle.write(text)
        os.replace(tmp, path)
    except BaseException:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def _reconcile_config(cfg_path: Path, dry_run: bool, print_members: bool) -> int:
    """Read, reconcile, and (unless dry-run) write back one config.json.

    When ``print_members`` is set, also emit one ``dismissible-member: <name>``
    line per non-lead member so the lead knows whom to ``shutdown_request``.
    """
    try:
        # 외부 입력: Claude Code가 소유하는 team roster 설정이다.
        config = load_external_json(
            external_claude_team_json_source(cfg_path),
            artifact="Claude team configuration",
        )
    except JsonBoundaryError as exc:
        print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
              file=sys.stderr)
        return 1

    available, live = live_pane_ids()
    if not available:
        print("team-reconcile: tmux unavailable — skipped (no flip)")
    else:
        flipped = reconcile_members(config, live)
        if flipped and dry_run:
            print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
        elif flipped:
            _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
            print(f"team-reconcile: deactivated {len(flipped)} stale member(s): "
                  + ", ".join(flipped))
        else:
            print("team-reconcile: no stale-active members")

    if print_members:
        for name in dismissible_members(config):
            print(f"dismissible-member: {name}")
    return 0


def _session_id_from_stdin() -> str:
    """Extract ``session_id`` from a SessionEnd hook's JSON payload on stdin.

    Returns "" when stdin is a terminal (manual invocation), empty, or not the
    expected JSON — the caller then no-ops. The hook must never block or fail a
    session teardown.
    """
    if sys.stdin.isatty():
        return ""
    try:
        raw = sys.stdin.read()
    except OSError:
        return ""
    if not raw.strip():
        return ""
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return ""
    sid = data.get("session_id") or data.get("sessionId") or ""
    return sid if isinstance(sid, str) else ""


def _run_resolve_and_reconcile(project_root: str, fallback_team: str, dry_run: bool) -> int:
    """``--project-root`` mode: resolve the live roster, reconcile, list members."""
    cfg = _live_team_config(Path(project_root))
    if cfg is None and fallback_team:
        fb = _config_path(fallback_team)
        cfg = fb if fb.is_file() else None
    if cfg is None:
        print("team-reconcile: no live roster for this session — nothing to dismiss")
        return 0
    print(f"team-reconcile: roster resolved at {cfg}")
    return _reconcile_config(cfg, dry_run, print_members=True)


def _run_session_end(dry_run: bool) -> int:
    """``--session-end`` mode: reconcile the ending session's roster from the
    SessionEnd hook payload. Safety net so a lead that skipped run-end teardown
    still leaves no dead-pane member lingering as a live pill."""
    sid = _session_id_from_stdin()
    if not sid:
        return 0
    cfg = _team_config_for_session(sid)
    if not cfg.is_file():
        return 0
    return _reconcile_config(cfg, dry_run, print_members=False)


def _parse_argv(argv: list[str]) -> tuple[bool, str, str, bool, list[str]]:
    dry_run = False
    project_root = ""
    fallback_team = ""
    session_end = False
    rest: list[str] = []
    it = iter(argv)
    for arg in it:
        if arg in ("--list", "--dry-run"):
            dry_run = True
        elif arg == "--project-root":
            project_root = next(it, "")
        elif arg == "--fallback-team":
            fallback_team = next(it, "")
        elif arg == "--session-end":
            session_end = True
        else:
            rest.append(arg)
    return dry_run, project_root, fallback_team, session_end, rest


def main(argv: list[str]) -> int:
    dry_run, project_root, fallback_team, session_end, rest = _parse_argv(argv)

    if session_end:
        return _run_session_end(dry_run)
    if project_root:
        return _run_resolve_and_reconcile(project_root, fallback_team, dry_run)

    if len(rest) != 1:
        print("usage: okstra-team-reconcile.sh [--list] "
              "(<team-name|team-dir|config.json> | --project-root <dir> "
              "[--fallback-team <label>] | --session-end)",
              file=sys.stderr)
        return 2

    cfg_path = _config_path(rest[0])
    if not cfg_path.is_file():
        print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
        return 1
    return _reconcile_config(cfg_path, dry_run, print_members=False)
