"""
CopilotCLIAdapter — drives GitHub's `copilot` CLI via per-turn subprocess.

Each turn spawns (fresh process, resume-by-session-id after turn 1):
  copilot -p <text> --output-format json --allow-all-tools --no-color -C <cwd> [--resume=<sid>]

`--output-format json` emits JSON Lines (one object per line) on stdout. Every
event is wrapped `{type, data, id, timestamp, parentId, ephemeral?}` EXCEPT the
terminal `result`, which is flat. `--allow-all-tools` is required for
non-interactive use (otherwise the CLI blocks on a permission prompt). The
`result` event carries `sessionId`, which we resume with `--resume=<sid>` on the
next turn (fresh process each turn; the CLI holds context in ~/.copilot).

JSONL event stream (stdout) — the ones we consume:
  session.tools_updated       {model}                         ← reported model
  assistant.turn_start        {turnId, interactionId}
  assistant.message_start     {messageId}                     (ephemeral)
  assistant.message_delta     {messageId, deltaContent}       ← streaming text
  assistant.message           {messageId, content, toolRequests[], outputTokens}
  assistant.reasoning         {reasoningId, content}          ← thinking
  tool.execution_start        {toolCallId, toolName, arguments}
  tool.execution_partial_result {toolCallId, partialOutput}   (ignored)
  tool.execution_complete     {toolCallId, success, result:{content}}
  assistant.turn_end          {turnId}
  assistant.idle                                              (ignored)
  result (flat)               {sessionId, exitCode, usage}    ← turn terminator
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import uuid
from pathlib import Path
from typing import AsyncIterator, Callable

from ..attachments import Attachment
from ..blocks import (
    Block, AgentInfoBlock, AudioHintBlock, ErrorBlock, SessionEndBlock,
    TextBlock, ThinkingBlock, ToolCallBlock, tool_result_or_large,
)
from .base import PerTurnCLIAdapter

log = logging.getLogger("beacon.copilot")

_COPILOT_DIR = Path.home() / ".copilot"

# Copilot reasoning-effort levels (`--effort/--reasoning-effort`). Richer than codex's
# 4-level scale — copilot 1.0.69 offers a 7-level ladder incl. an explicit "none".
_COPILOT_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]


class CopilotCLIAdapter(PerTurnCLIAdapter):
    cli_name = "copilot"
    _capture_stderr = True
    _close_stdin = True        # non-interactive; nothing to feed on stdin
    cap_images = True          # native --attachment <path> (image or document)
    cap_documents = True       # copilot's --attachment ingests native documents too
    cap_thinking = True        # assistant.reasoning
    cap_interrupt = True       # per-turn; stop() terminates the process
    cap_input_during_turn = False
    cap_sentinel = True        # terminull sentinel MCP via per-spawn --additional-mcp-config (see _build_argv)
    provides_models = False
    provides_usage = False
    agent_id = "copilot"
    display_name = "GitHub Copilot CLI"
    beta = True
    cli_binary = "copilot"

    def __init__(
        self,
        binary: str | None = None,
        cwd: str | None = None,
        resume_session_id: str | None = None,
        on_session_id: "Callable[[str], None] | None" = None,
        effort: str | None = None,
    ) -> None:
        self._binary = binary or shutil.which("copilot") or "copilot"
        self._cwd = cwd
        # Reasoning effort (`--effort`). None ⇒ copilot's persisted/default level.
        # Re-applied on the NEXT turn's spawn (per-turn adapter).
        self._effort = (effort or "").strip() or None
        # Copilot session UUID — resume by id (turns after the first, or an adopted
        # external session). Captured from the terminal `result` event's `sessionId`.
        self._session_uuid: str | None = resume_session_id
        self._on_session_id = on_session_id
        self._model = ""
        self._send_queue: asyncio.Queue[tuple[str, list[Attachment]]] = None  # type: ignore
        self._pending_tools: dict[str, dict] = {}   # toolCallId → {name, args}
        self._fetch_store: dict[str, str] = {}
        self._proc: asyncio.subprocess.Process | None = None

    @classmethod
    def readiness(cls) -> tuple[bool, str]:
        # Copilot signs in via GitHub OAuth (state under ~/.copilot); a PAT in
        # GITHUB_TOKEN/GH_TOKEN also works. If neither is present, nudge to sign in.
        if _COPILOT_DIR.exists() or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"):
            return True, ""
        return False, "Run `copilot` once to sign in with your GitHub account."

    # ── Lifecycle ─────────────────────────────────────────────────────────────

    async def start(self, session_id: str) -> AgentInfoBlock:
        self._beacon_session_id = session_id
        self._send_queue = asyncio.Queue()
        return self.make_agent_info(
            name="GitHub Copilot CLI",
            working_dirs=[self._cwd] if self._cwd else [],
            effort_levels=_COPILOT_EFFORT_LEVELS,
            effort=self._effort or "",
        )

    def set_effort(self, level: str) -> None:
        """Per-turn adapter: stored, applied on the NEXT turn's spawn (`--effort`)."""
        self._effort = (level or "").strip() or None

    def clear_history(self) -> None:
        self._session_uuid = None
        # Wipe the persisted ext_sid too, else a reconnect respawns with --resume
        # and resurrects the old context. Next turn spawns fresh; copilot mints a
        # new session id, re-persisted from the `result` event.
        if self._on_session_id:
            self._on_session_id("")

    # ── Turn loop (spawn/stream/teardown handled by PerTurnCLIAdapter) ─────────

    async def _build_argv(self, text: str, attachments: list[Attachment]) -> list[str]:
        # `copilot -p` takes the prompt as a flag value (not a positional). Attachments are
        # passed NATIVELY via `--attachment <path>` (copilot 1.0.69 ingests images AND
        # native documents this way), one flag per file — strictly better than the old
        # OCR-text inline (real vision + real document parsing). Each per-turn spawn's `-p`
        # is that invocation's "initial prompt", so --attachment is valid every turn.
        # Attachments already live on disk (Attachment.local_path). See attachments.py.
        text = self.sentinel_client_preamble(is_first_turn=not self._session_uuid) + text
        argv = [self._binary, "-p", text,
                "--output-format", "json", "--allow-all-tools", "--no-color"]
        for a in attachments:
            argv += ["--attachment", str(a.local_path)]
        # Reasoning effort (`--effort <level>`); unset ⇒ copilot's persisted/default level.
        if self._effort:
            argv += ["--effort", self._effort]
        if self._cwd:
            argv += ["-C", self._cwd]
        # Terminull sentinel MCP: run_terminal_command / show_photo / schedule + command
        # tools. Injected PER-SPAWN via --additional-mcp-config (not the global
        # ~/.copilot/mcp-config.json) so the port + THIS session's id ride inline and
        # concurrent sessions can't clobber a shared file — same discipline as
        # claude_code's --mcp-config. copilot strips ambient env from MCP children, so
        # the env block in the config is the only delivery path. Skipped if the
        # SentinelServer port can't be resolved (nothing to route to).
        from ..sentinel_tools import resolve_sentinel_port, write_global_script, _SCRIPT_FILE
        port = resolve_sentinel_port()
        if port and getattr(self, "_beacon_session_id", ""):
            write_global_script()   # ensure ~/.terminull/sentinel_mcp.py exists + current
            mcp_cfg = {"mcpServers": {"terminull": {
                "type": "local",
                "command": "python3",
                "args": [str(_SCRIPT_FILE)],
                "tools": ["*"],
                "env": {
                    "TERMINULL_SENTINEL_PORT": str(port),
                    "TERMINULL_SESSION_ID": self._beacon_session_id,
                },
            }}}
            argv += ["--additional-mcp-config", json.dumps(mcp_cfg)]
        # Resume the specific session by id once known (turn ≥2, or an adopted
        # external session); else a fresh session.
        if self._session_uuid:
            argv += [f"--resume={self._session_uuid}"]
        return argv

    def _reset_turn_state(self) -> None:
        self._pending_tools.clear()

    # ── Event parser ──────────────────────────────────────────────────────────

    async def _parse_event(self, event: dict) -> AsyncIterator[Block]:
        etype = event.get("type", "")
        data = event.get("data") or {}

        if etype == "session.tools_updated":
            m = data.get("model")
            if m:
                self._model = m
            return

        if etype == "assistant.message_delta":
            delta = data.get("deltaContent", "")
            if delta:
                yield TextBlock(text=delta, done=False, id=data.get("messageId") or "")
            return

        if etype == "assistant.message":
            content = data.get("content", "")
            if content:
                yield TextBlock(text=content, done=True, id=data.get("messageId") or "")
            # Tool requests are rendered from the richer tool.execution_* events
            # (which carry live output), so nothing more to emit here.
            return

        if etype == "assistant.reasoning":
            c = data.get("content", "")
            if c:
                yield ThinkingBlock(thinking=c)
            return

        if etype == "tool.execution_start":
            tcid = data.get("toolCallId") or str(uuid.uuid4())
            name = data.get("toolName", "tool")
            args = data.get("arguments") or {}
            self._pending_tools[tcid] = {"name": name, "args": args}
            yield AudioHintBlock(state="agent_silent")
            yield ToolCallBlock(id=tcid, name=name, args=args, status="running")
            return

        if etype == "tool.execution_complete":
            tcid = data.get("toolCallId") or str(uuid.uuid4())
            meta = self._pending_tools.pop(tcid, {})
            name = meta.get("name") or "tool"
            args = meta.get("args") or {}
            success = bool(data.get("success", True))
            res = data.get("result") or {}
            content = res.get("content") or res.get("detailedContent") or ""
            if not isinstance(content, str):
                content = json.dumps(content)
            yield ToolCallBlock(id=tcid, name=name, args=args,
                                status="done" if success else "error")
            yield tool_result_or_large(
                tool_call_id=tcid, content=content,
                fetch_store=self._fetch_store, is_error=not success,
            )
            yield AudioHintBlock(state="agent_speaking")
            return

        if etype == "result":
            # Terminal event (flat, no `data` wrapper). Capture the session id for
            # the next turn's --resume, then end the turn.
            sid = event.get("sessionId")
            if sid and sid != self._session_uuid:
                self._session_uuid = sid
                if self._on_session_id:
                    self._on_session_id(sid)
            yield AudioHintBlock(state="agent_silent")
            exit_code = event.get("exitCode", 0) or 0
            if exit_code != 0:
                yield ErrorBlock(message=f"copilot exited with code {exit_code}",
                                 recoverable=True)
                yield SessionEndBlock(reason="error")
            else:
                yield SessionEndBlock(reason="completed")
            return
