"""Worker task executors."""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass
import os
from pathlib import Path
import platform
import signal
import shutil
import subprocess
import sys
import tempfile
import time

from a2a_agent.config import AgentConfig


DEFAULT_EXECUTOR_TIMEOUT_SECONDS = 540
EXECUTOR_TIMEOUT_ENV = "A2A_EXECUTOR_TIMEOUT_SECONDS"
PROCESS_TREE_REAP_TIMEOUT_SECONDS = 10
OPENCODE_RUN_ARGS = ("run", "-m", "opencode/big-pickle")
DIAG_EXECUTOR_PROBE_PROMPT = "reply with the single word: PONG"
DIAG_EXECUTOR_PROBE_TIMEOUT_SECONDS = 60
CURSOR_EXECUTOR_PROGRAMS = ("agent", "cursor-agent")
DIAG_EXECUTOR_PROGRAMS = ("opencode", *CURSOR_EXECUTOR_PROGRAMS, "grok")
# PATH에 없는 Cursor CLI 설치본을 찾는 표준 디렉터리다. 첫 요소는 환경
# 변수 이름이고, 나머지는 그 기준 경로 아래의 디렉터리다.
CURSOR_STANDARD_INSTALL_DIRECTORY_PARTS: dict[
    str, tuple[tuple[str, ...], ...]
] = {
    "nt": (
        ("LOCALAPPDATA", "Programs", "cursor-agent"),
        ("USERPROFILE", ".local", "bin"),
        ("USERPROFILE", "AppData", "Local", "Programs", "cursor-agent"),
    ),
    "posix": (
        ("HOME", ".local", "bin"),
        ("HOME", ".cursor", "bin"),
    ),
}
OPENCODE_DATABASE_LOCK_TEXT = "database is locked"
OPENCODE_LOCK_RETRY_ENV = "A2A_OPENCODE_LOCK_RETRY"
DEFAULT_OPENCODE_LOCK_RETRIES = 2
OPENCODE_LOCK_RETRY_DELAYS_SECONDS = (2, 3)


@dataclass(frozen=True)
class ExecutorResult:
    ok: bool
    text: str
    returncode: int | None = None


def selected_executor(config: AgentConfig) -> str:
    return os.environ.get("A2A_EXECUTOR", config.executor).strip().lower() or "echo"


def executor_timeout_seconds() -> int:
    raw_timeout = os.environ.get(EXECUTOR_TIMEOUT_ENV)
    if raw_timeout is None:
        return DEFAULT_EXECUTOR_TIMEOUT_SECONDS

    try:
        timeout = int(raw_timeout)
        if timeout <= 0:
            raise ValueError
    except ValueError:
        print(
            f"warning: invalid {EXECUTOR_TIMEOUT_ENV}={raw_timeout!r}; "
            f"using default {DEFAULT_EXECUTOR_TIMEOUT_SECONDS}",
            file=sys.stderr,
        )
        return DEFAULT_EXECUTOR_TIMEOUT_SECONDS

    return timeout


def opencode_lock_retry_count() -> int:
    """Return the configured retry count without exceeding the safe maximum."""

    raw_retries = os.environ.get(OPENCODE_LOCK_RETRY_ENV)
    if raw_retries is None:
        return DEFAULT_OPENCODE_LOCK_RETRIES

    try:
        retries = int(raw_retries)
    except ValueError:
        return DEFAULT_OPENCODE_LOCK_RETRIES

    return max(0, min(retries, DEFAULT_OPENCODE_LOCK_RETRIES))


def execute_text(text: str, config: AgentConfig) -> ExecutorResult:
    executor = selected_executor(config)
    if executor == "echo":
        return ExecutorResult(ok=True, text=text)
    if executor == "opencode":
        return _run_opencode(text)
    if executor == "cursor":
        return _run_cursor(text)
    if executor == "grok":
        return _run_grok(text)
    return ExecutorResult(ok=False, text=f"unknown executor: {executor}")


def diagnose_executor(
    config: AgentConfig,
    *,
    timeout: int = DIAG_EXECUTOR_PROBE_TIMEOUT_SECONDS,
) -> ExecutorResult:
    """Collect fixed, read-only executor diagnostics without using the AI path."""

    command_args = list(OPENCODE_RUN_ARGS)
    lines = [
        "diagnostic: diag-executor",
        f"platform.system: {platform.system()}",
        f"os.name: {os.name}",
        f"python.version: {platform.python_version()}",
        f"selected_executor: {selected_executor(config)}",
    ]
    for program in DIAG_EXECUTOR_PROGRAMS:
        lines.append(f"binary.{program}: {_find_executable(program)}")

    command = _build_command("opencode", command_args)
    lines.append(f"opencode.command: {command!r}")

    stdin_probe = _run_opencode(DIAG_EXECUTOR_PROBE_PROMPT, timeout=timeout)
    _append_diagnostic_probe(lines, "stdin_probe", stdin_probe)

    argv_probe = _run_external(
        "opencode",
        [*OPENCODE_RUN_ARGS, DIAG_EXECUTOR_PROBE_PROMPT],
        timeout=timeout,
        label="opencode argv probe",
    )
    _append_diagnostic_probe(lines, "argv_probe", argv_probe)

    # A missing binary or a failed probe is useful diagnostic data, not a failed
    # control task. The report itself is complete when both fixed probes return.
    return ExecutorResult(ok=True, text="\n".join(lines))


def _append_diagnostic_probe(
    lines: list[str], label: str, result: ExecutorResult
) -> None:
    lines.extend(
        [
            f"{label}.ok: {str(result.ok).lower()}",
            f"{label}.returncode: {result.returncode}",
            f"{label}.text: {result.text!r}",
        ]
    )


def _standard_executable_paths(
    program: str,
    *,
    platform_name: str,
    home: Path | str | None = None,
    environ: Mapping[str, str] | None = None,
) -> tuple[Path, ...]:
    """Return known install-location candidates after a PATH lookup fails."""

    if program not in CURSOR_EXECUTOR_PROGRAMS:
        return ()

    environment = os.environ if environ is None else environ
    injected_home = Path(home) if home is not None else None
    candidates: list[Path] = []
    for root_name, *parts in CURSOR_STANDARD_INSTALL_DIRECTORY_PARTS.get(
        platform_name, ()
    ):
        if root_name in {"HOME", "USERPROFILE"} and injected_home is not None:
            root = injected_home
        elif configured_root := environment.get(root_name):
            root = Path(configured_root)
        elif root_name == "LOCALAPPDATA" and injected_home is not None:
            root = injected_home / "AppData" / "Local"
        elif root_name == "HOME":
            root = Path.home()
        else:
            continue

        directory = root.joinpath(*parts)
        filenames = (
            (f"{program}.exe", f"{program}.cmd")
            if platform_name == "nt"
            else (program,)
        )
        candidates.extend(directory / filename for filename in filenames)
    return tuple(candidates)


def _find_executable(
    program: str,
    *,
    platform_name: str | None = None,
    home: Path | str | None = None,
    environ: Mapping[str, str] | None = None,
) -> str | None:
    """Resolve an executor with PATH first, then Cursor's standard locations."""

    executable = shutil.which(program)
    if executable is not None:
        return executable

    current_platform = os.name if platform_name is None else platform_name
    for candidate in _standard_executable_paths(
        program,
        platform_name=current_platform,
        home=home,
        environ=environ,
    ):
        if candidate.is_file() and (
            current_platform == "nt" or os.access(candidate, os.X_OK)
        ):
            return str(candidate.resolve())
    return None


def _build_command(
    program: str,
    args: list[str],
    *,
    platform_name: str | None = None,
    home: Path | str | None = None,
    environ: Mapping[str, str] | None = None,
) -> str | list[str] | None:
    """PATH와 표준 설치 위치에서 program 실제 경로를 찾아 커맨드를 만든다.

    윈도우 npm/설치본은 program.cmd 같은 셸 스크립트라 bare 이름으로는
    파이썬 subprocess가 실행 파일을 못 찾는다. shutil.which는 PATHEXT를 존중해
    .cmd를 찾아주고, .cmd/.bat는 cmd.exe를 통해 실행해야 한다.
    """
    current_platform = os.name if platform_name is None else platform_name
    executable = _find_executable(
        program,
        platform_name=current_platform,
        home=home,
        environ=environ,
    )
    if executable is None:
        return None
    if current_platform == "nt" and executable.lower().endswith((".cmd", ".bat")):
        # cmd /s /c는 전체 배치 호출을 한 쌍의 바깥 따옴표로 감싸야
        # 실행 파일과 인자에 공백이 있어도 첫 따옴표를 잘못 제거하지 않는다.
        inner = subprocess.list2cmdline([executable, *args])
        return f'cmd /d /s /v:off /c "{inner}"'
    return [executable, *args]


def _kill_process_tree(
    proc: subprocess.Popen[str], *, platform: str | None = None
) -> None:
    current_platform = os.name if platform is None else platform
    tree_killed = False

    if current_platform == "nt":
        try:
            completed = subprocess.run(
                ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
                check=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=PROCESS_TREE_REAP_TIMEOUT_SECONDS,
            )
            tree_killed = completed.returncode == 0
        except (OSError, subprocess.SubprocessError):
            pass
    else:
        try:
            os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
            tree_killed = True
        except OSError:
            pass

    if not tree_killed:
        try:
            proc.kill()
        except OSError:
            pass


def _kill_and_reap(proc: subprocess.Popen[str]) -> None:
    try:
        proc.kill()
    except OSError:
        pass

    try:
        proc.wait(timeout=PROCESS_TREE_REAP_TIMEOUT_SECONDS)
    except (OSError, subprocess.SubprocessError):
        pass

    # 손자 프로세스가 파이프를 계속 잡는 비정상 상황에서도 로컬 핸들을 닫는다.
    for stream in (proc.stdout, proc.stderr):
        if stream is not None:
            try:
                stream.close()
            except OSError:
                pass


def _run_external(
    program: str,
    args: list[str],
    timeout: int | None = None,
    label: str = "external executor",
    stdin_text: str | None = None,
    combine_error_output: bool = False,
) -> ExecutorResult:
    command = _build_command(program, args)
    if command is None:
        return ExecutorResult(
            ok=False,
            text=(
                f"{program}을(를) PATH에서 찾지 못했습니다. {program} 설치 후 "
                "새 터미널에서 워커를 다시 실행하세요."
            ),
        )

    effective_timeout = executor_timeout_seconds() if timeout is None else timeout
    popen_kwargs: dict[str, object] = {
        "stdout": subprocess.PIPE,
        "stderr": subprocess.PIPE,
        "text": True,
        # 윈도우 한국어 로케일은 기본 코덱이 cp949라 UTF-8 출력을 못 읽고 깨진다.
        # 외부 AI CLI 출력은 UTF-8이므로 명시적으로 UTF-8로 디코딩한다.
        "encoding": "utf-8",
        "errors": "replace",
    }
    if stdin_text is not None:
        popen_kwargs["stdin"] = subprocess.PIPE
    if os.name != "nt":
        popen_kwargs["start_new_session"] = True

    try:
        proc = subprocess.Popen(command, **popen_kwargs)
    except FileNotFoundError:
        return ExecutorResult(ok=False, text=f"{program} executable was not found")

    try:
        if stdin_text is None:
            stdout, stderr = proc.communicate(timeout=effective_timeout)
        else:
            stdout, stderr = proc.communicate(
                input=stdin_text,
                timeout=effective_timeout,
            )
    except subprocess.TimeoutExpired:
        _kill_process_tree(proc)
        try:
            proc.communicate(timeout=PROCESS_TREE_REAP_TIMEOUT_SECONDS)
        except subprocess.TimeoutExpired:
            _kill_and_reap(proc)
        return ExecutorResult(
            ok=False,
            text=(
                f"{label} timed out after {effective_timeout} seconds; "
                "process tree killed"
            ),
        )

    stdout = stdout.strip()
    stderr = stderr.strip()
    if proc.returncode == 0:
        return ExecutorResult(
            ok=True,
            text=stdout or f"({label} returned no output)",
            returncode=proc.returncode,
        )

    if combine_error_output:
        detail = "\n".join(part for part in (stdout, stderr) if part) or "no output"
    else:
        detail = stderr or stdout or "no output"
    return ExecutorResult(
        ok=False,
        text=f"{label} failed with exit code {proc.returncode}: {detail}",
        returncode=proc.returncode,
    )


def _run_opencode(
    prompt: str,
    *,
    timeout: int | None = None,
    sleep_fn: Callable[[float], object] = time.sleep,
) -> ExecutorResult:
    # opencode run은 비 TTY stdin을 메시지로 읽는다. 프롬프트를 argv에 넣지
    # 않아야 Windows cmd.exe가 >, %VAR%, 따옴표를 다시 해석하지 않는다.
    retries = opencode_lock_retry_count()
    for retry_index in range(retries + 1):
        result = _run_external(
            "opencode",
            list(OPENCODE_RUN_ARGS),
            timeout=timeout,
            label="opencode",
            stdin_text=prompt,
            combine_error_output=True,
        )
        if (
            result.ok
            or OPENCODE_DATABASE_LOCK_TEXT not in result.text.lower()
        ):
            return result

        if retry_index == retries:
            return ExecutorResult(
                ok=False,
                text=(
                    f"{result.text}\n"
                    "opencode database lock persisted after "
                    f"{retries} retries"
                ),
                returncode=result.returncode,
            )

        sleep_fn(OPENCODE_LOCK_RETRY_DELAYS_SECONDS[retry_index])

    raise AssertionError("opencode retry loop did not return")


def _run_cursor(prompt: str) -> ExecutorResult:
    # agent는 Cursor CLI의 현재 이름이다. 과거 이름은 실행 파일을 찾지
    # 못했을 때만 사용해, 실제 실행 실패를 다른 버전으로 가리지 않는다.
    for program in CURSOR_EXECUTOR_PROGRAMS:
        if _find_executable(program) is not None:
            return _run_external(
                program,
                ["-p", "--trust", "--force"],
                label=program,
                stdin_text=prompt,
            )

    # 둘 다 없을 때도 과거 이름으로 기존의 찾지 못함 오류 형식을 보존한다.
    return _run_external(
        "cursor-agent",
        ["-p", "--trust", "--force"],
        label="cursor-agent",
        stdin_text=prompt,
    )


def _run_grok(prompt: str) -> ExecutorResult:
    # Grok의 일회용 -p는 stdin 프롬프트를 공식 지원하지 않는다. 사용자
    # 문자열 대신 임시 파일 경로만 argv로 넘기고 공식 @file 참조를 쓴다.
    fd, raw_path = tempfile.mkstemp(prefix="a2a-agent-prompt-", suffix=".txt")
    prompt_path = Path(raw_path)
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="") as prompt_file:
            prompt_file.write(prompt)
        return _run_external(
            "grok",
            [
                "--always-approve",
                "-p",
                f"Follow the exact instructions in this file: @{prompt_path}",
            ],
            label="grok",
        )
    finally:
        prompt_path.unlink(missing_ok=True)
