"""Control-plane subcommands: status / cancel / attach.

Thin readers/writers over ``state.json`` + ``events.jsonl`` + docker -- no
coupling to a live spens process.  Output is machine-readable (JSON) always.

Exit codes (specs/spens_non_interactive.md):

- status / cancel: 0 on success, non-zero on unknown session or docker
  failure; cancel is idempotent and never overwrites an existing terminal
  state.
- attach: exits with the session's final exit code.
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Any

from spens.events import SCHEMA_VERSION, TERMINAL_STATES, utc_now_iso
from spens.sessions import SPENS_DIR_NAME
from spens.sinks.file import read_state, session_lock

# How often attach polls events.jsonl / state.json.
ATTACH_POLL_SECS = 0.5

#: docker's default stop timeout before escalating from SIGTERM to SIGKILL;
#: after a SIGKILL the in-container nono rollback cannot run.
_DOCKER_STOP_TIMEOUT_SECS = 15


def _sessions_root(spens_dir: str | Path | None) -> Path:
    if spens_dir is not None:
        return Path(spens_dir).resolve() / "sessions"
    return Path.cwd() / SPENS_DIR_NAME / "sessions"


def _find_session_dir(session_id: str, spens_dir: str | Path | None) -> Path | None:
    candidate = _sessions_root(spens_dir) / session_id
    return candidate if candidate.is_dir() else None


def _print_json(payload: dict[str, Any]) -> None:
    print(json.dumps(payload))


def _status_payload(state: dict[str, Any] | None) -> dict[str, Any]:
    if state is None:
        return {"state": "unknown"}
    return {
        "state": state.get("state", "unknown"),
        "exit_code": state.get("exit_code"),
        "agent_container": state.get("agent_container", ""),
        "updated_at": state.get("updated_at", ""),
    }


# ---------------------------------------------------------------------------
# docker helpers (stubbed in unit tests)
# ---------------------------------------------------------------------------


def _docker_stop(container: str) -> bool:
    """Stop ``container``; True on success or if it never existed.

    A missing container is not a docker failure -- a session canceled during
    ``building`` has no containers yet.
    """
    result = subprocess.run(
        ["docker", "stop", container],
        capture_output=True, text=True, timeout=_DOCKER_STOP_TIMEOUT_SECS + 15,
    )
    if result.returncode == 0:
        return True
    stderr = result.stderr or ""
    if "No such" in stderr or "not found" in stderr.lower():
        return True
    print(f"[spens] Error: docker stop {container} failed: {stderr.strip()}", file=sys.stderr)
    return False


def _agent_exit_code(container: str) -> int | None:
    """Best-effort read of a stopped agent container's exit code."""
    try:
        result = subprocess.run(
            ["docker", "inspect", "-f", "{{.State.ExitCode}}", container],
            capture_output=True, text=True, timeout=15,
        )
        if result.returncode == 0:
            return int((result.stdout or "").strip())
    except (OSError, ValueError):
        pass
    return None


def _append_event(session_dir: Path, payload: dict[str, Any]) -> None:
    """Append one raw event line under the session lock (used by cancel)."""
    with session_lock(session_dir), open(session_dir / "events.jsonl", "a", encoding="utf-8") as fh:
        fh.write(json.dumps(payload) + "\n")


# ---------------------------------------------------------------------------
# status
# ---------------------------------------------------------------------------


def cmd_status(session_id: str, spens_dir: str | Path | None = None) -> int:
    session_dir = _find_session_dir(session_id, spens_dir)
    state = read_state(session_dir) if session_dir is not None else None
    if session_dir is None or state is None:
        # unknown session -- or one with no readable state -- is an error
        _print_json({"state": "unknown"})
        print(f"[spens] Error: no session '{session_id}' found.", file=sys.stderr)
        return 1
    _print_json(_status_payload(state))
    return 0


# ---------------------------------------------------------------------------
# cancel
# ---------------------------------------------------------------------------


def cmd_cancel(session_id: str, spens_dir: str | Path | None = None) -> int:
    session_dir = _find_session_dir(session_id, spens_dir)
    if session_dir is None:
        _print_json({"state": "unknown"})
        print(f"[spens] Error: no session '{session_id}' found.", file=sys.stderr)
        return 1

    # Idempotence + first-terminal-wins: an already-terminal session is left
    # exactly as it is (its containers are gone or stopping on their own).
    state = read_state(session_dir)
    if state is None:
        _print_json({"state": "unknown"})
        print(f"[spens] Error: session '{session_id}' has no readable state.", file=sys.stderr)
        return 1
    if state.get("state") in TERMINAL_STATES:
        _print_json(_status_payload(state))
        return 0

    # docker stop delivers SIGTERM to the agent and nono, which runs its
    # exit-time rollback (the same path as a natural exit).  Containers are
    # read from state.json so cancel works even mid-build; a session that
    # has not created them yet simply stops nothing.
    for container in (state.get("agent_container"), state.get("interceptor_container")):
        if container and not _docker_stop(container):
            return 1

    # If the stop escalated to SIGKILL (137), the in-container rollback could
    # not run -- record a warning next to the canceled event.
    agent_container = state.get("agent_container") or ""
    killed = agent_container and _agent_exit_code(agent_container) == 137

    with session_lock(session_dir):
        current = read_state(session_dir)
        # First writer of a terminal state wins: only mark canceled if the
        # running session has not itself reached a terminal state meanwhile.
        if current is not None and current.get("state") not in TERMINAL_STATES:
            current["state"] = "canceled"
            current["updated_at"] = utc_now_iso()
            tmp = session_dir / "state.json.tmp"
            tmp.write_text(json.dumps(current), encoding="utf-8")
            tmp.replace(session_dir / "state.json")
            state = current

    _append_event(
        session_dir,
        {
            "schema_version": SCHEMA_VERSION,
            "event": "canceled",
            "session_id": session_id,
            "timestamp": utc_now_iso(),
            "data": {"reason": "canceled by spens cancel"},
        },
    )
    if killed:
        _append_event(
            session_dir,
            {
                "schema_version": SCHEMA_VERSION,
                "event": "warning",
                "session_id": session_id,
                "timestamp": utc_now_iso(),
                "data": {
                    "message": (
                        "[spens] Warning: the agent was killed before it could "
                        "exit cleanly; the in-container rollback may not have "
                        "run and workspace changes may still be present."
                    )
                },
            },
        )

    _print_json(_status_payload(state))
    return 0


# ---------------------------------------------------------------------------
# attach
# ---------------------------------------------------------------------------


def _follow_docker_logs(container: str) -> None:
    """Stream the agent container's output to stderr (raw text)."""
    try:
        proc = subprocess.Popen(
            ["docker", "logs", "-f", container],
            stdout=sys.stderr, stderr=sys.stderr,
        )
        proc.wait()
    except OSError:
        pass


def _final_exit_code(state: dict[str, Any]) -> int:
    exit_code = state.get("exit_code")
    if isinstance(exit_code, int):
        return exit_code
    if state.get("state") == "canceled":
        return 130
    if state.get("state") == "error":
        return 1
    return 0


def cmd_attach(session_id: str, spens_dir: str | Path | None = None) -> int:
    session_dir = _find_session_dir(session_id, spens_dir)
    if session_dir is None:
        print(f"[spens] Error: no session '{session_id}' found.", file=sys.stderr)
        return 1
    events_path = session_dir / "events.jsonl"

    state = read_state(session_dir)
    agent_container = (state or {}).get("agent_container") or ""

    # Live agent output (raw) goes to stderr so stdout stays clean JSONL --
    # the same events arrive on stdout as agent_output events in jsonl /
    # background mode; docker logs covers tty-mode sessions too.
    logs_thread: threading.Thread | None = None
    if agent_container and (state or {}).get("state") not in TERMINAL_STATES:
        logs_thread = threading.Thread(
            target=_follow_docker_logs, args=(agent_container,), daemon=True
        )
        logs_thread.start()

    if events_path.exists():
        with open(events_path, encoding="utf-8") as fh:
            pending = ""
            while True:
                line = fh.readline()
                if line:
                    if line.endswith("\n"):
                        # Tolerate a partially-written line: only complete
                        # lines are printed (the writer flushes per event).
                        text, pending = (pending + line).rstrip("\n"), ""
                        if text:
                            print(text)
                    else:
                        pending += line
                    continue
                state = read_state(session_dir)
                if state is not None and state.get("state") in TERMINAL_STATES:
                    break
                time.sleep(ATTACH_POLL_SECS)
    else:
        # No events file yet (e.g. canceled mid-build): wait for the
        # terminal state directly.
        while True:
            state = read_state(session_dir)
            if state is not None and state.get("state") in TERMINAL_STATES:
                break
            time.sleep(ATTACH_POLL_SECS)

    return _final_exit_code(read_state(session_dir) or {})


# ---------------------------------------------------------------------------
# dispatch
# ---------------------------------------------------------------------------


def run_control_command(command: str, argv: list[str]) -> int:
    """Parse and run one control subcommand; returns the process exit code."""
    parser = argparse.ArgumentParser(prog=f"spens {command}")
    parser.add_argument("session_id", help="Session id (see spens status)")
    parser.add_argument("--spens-dir", default=None, metavar="PATH")
    args = parser.parse_args(argv)

    if command == "status":
        return cmd_status(args.session_id, args.spens_dir)
    if command == "cancel":
        return cmd_cancel(args.session_id, args.spens_dir)
    if command == "attach":
        return cmd_attach(args.session_id, args.spens_dir)
    parser.error(f"unknown control command '{command}'")  # pragma: no cover
    return 2  # pragma: no cover
