"""Worker task executors."""

from __future__ import annotations

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

from a2a_agent.config import AgentConfig
from a2a_agent.config import load_config


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")
CURSOR_API_KEY_ENV = "CURSOR_API_KEY"
DIAG_EXECUTOR_PROGRAMS = ("opencode", *CURSOR_EXECUTOR_PROGRAMS, "grok")
OLLAMA_API_KEY_ENV = "OLLAMA_API_KEY"
OLLAMA_HOST_ENV = "A2A_OLLAMA_HOST"
OLLAMA_MODEL_ENV = "A2A_OLLAMA_MODEL"
DEFAULT_OLLAMA_HOST = "https://ollama.com"
OLLAMA_CHAT_ENDPOINT = f"{DEFAULT_OLLAMA_HOST}/api/chat"
DEFAULT_OLLAMA_MODEL = "gpt-oss:120b"
# 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, config)
    if executor == "grok":
        return _run_grok(text)
    if executor == "ollama":
        return _run_ollama(text, config)
    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)}",
        f"cursor_api_key: {'set' if _cursor_api_key_is_set(config) else 'unset'}",
        f"ollama_api_key: {'set' if _ollama_api_key_is_set(config) else 'unset'}",
        f"ollama_model: {_configured_ollama_model(config)}",
    ]
    for program in DIAG_EXECUTOR_PROGRAMS:
        lines.append(f"binary.{program}: {_find_executable(program)}")
    # Ollama Cloud executor는 직접 HTTPS API를 쓰므로 로컬 바이너리가 없다.
    lines.append("binary.ollama: n/a")

    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 _cursor_api_key_is_set(config: AgentConfig) -> bool:
    return CURSOR_API_KEY_ENV in os.environ or bool(config.cursor_api_key)


def _ollama_api_key_is_set(config: AgentConfig) -> bool:
    return bool(os.environ.get(OLLAMA_API_KEY_ENV) or config.ollama_api_key)


def _configured_ollama_model(config: AgentConfig) -> str:
    """Return the configured model for diagnostics without exposing secrets."""

    environment_model = os.environ.get(OLLAMA_MODEL_ENV, "").strip()
    config_model = (config.ollama_model or "").strip()
    return environment_model or config_model or DEFAULT_OLLAMA_MODEL


def _cursor_subprocess_env(config: AgentConfig | None = None) -> dict[str, str]:
    """Build a Cursor-only child environment without changing this worker."""

    environment = os.environ.copy()
    if CURSOR_API_KEY_ENV in environment:
        return environment

    cursor_api_key = config.cursor_api_key if config is not None else None
    if cursor_api_key is None:
        # The worker keeps its startup config in memory. Reloading only on the
        # Cursor path lets a newly stored remote key apply to the next task.
        cursor_api_key = load_config().cursor_api_key
    if cursor_api_key is not None:
        environment[CURSOR_API_KEY_ENV] = cursor_api_key
    return environment


def _redact_values(text: str, values: tuple[str, ...]) -> str:
    redacted = text
    for value in values:
        if value:
            redacted = redacted.replace(value, "<redacted>")
    return redacted


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,
    env: Mapping[str, str] | None = None,
    redact_values: tuple[str, ...] = (),
) -> 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 env is not None:
        popen_kwargs["env"] = dict(env)
    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 = _redact_values(stdout.strip(), redact_values)
    stderr = _redact_values(stderr.strip(), redact_values)
    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,
    config: AgentConfig | None = None,
) -> ExecutorResult:
    # agent는 Cursor CLI의 현재 이름이다. 과거 이름은 실행 파일을 찾지
    # 못했을 때만 사용해, 실제 실행 실패를 다른 버전으로 가리지 않는다.
    env = _cursor_subprocess_env(config)
    cursor_api_key = env.get(CURSOR_API_KEY_ENV)
    redact_values = (cursor_api_key,) if cursor_api_key else ()
    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,
                env=env,
                redact_values=redact_values,
            )

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


def _ollama_chat_url() -> str:
    """Build the direct Ollama Cloud chat endpoint from the optional host."""

    configured_host = os.environ.get(OLLAMA_HOST_ENV)
    if configured_host is None:
        return OLLAMA_CHAT_ENDPOINT

    host = configured_host.strip()
    if not host:
        return OLLAMA_CHAT_ENDPOINT
    return f"{host.rstrip('/')}/api/chat"


def _first_nonempty(*values: str | None) -> str | None:
    for value in values:
        if value:
            return value
    return None


def _ollama_settings(
    config: AgentConfig | None,
) -> tuple[str | None, str, tuple[str, ...]]:
    """Resolve API credentials and model for the next direct Cloud request.

    The worker's startup config can be stale after a remote control setting is
    saved. Reloading here makes the next task use the stored value while still
    allowing an environment variable to override it for a headless deployment.
    """

    latest_config = load_config()
    environment_api_key = os.environ.get(OLLAMA_API_KEY_ENV)
    environment_model = os.environ.get(OLLAMA_MODEL_ENV, "").strip()
    persisted_api_key = latest_config.ollama_api_key
    persisted_model = (latest_config.ollama_model or "").strip()
    injected_api_key = config.ollama_api_key if config is not None else None
    injected_model = (config.ollama_model or "").strip() if config else ""

    api_key = _first_nonempty(
        environment_api_key,
        persisted_api_key,
        injected_api_key,
    )
    model = environment_model or persisted_model or injected_model or DEFAULT_OLLAMA_MODEL
    sensitive_values = tuple(
        value
        for value in (environment_api_key, persisted_api_key, injected_api_key)
        if value
    )
    return api_key, model, sensitive_values


def _ollama_failure(message: str, sensitive_values: tuple[str, ...]) -> ExecutorResult:
    return ExecutorResult(
        ok=False,
        text=_redact_values(message, sensitive_values),
    )


def _run_ollama(
    prompt: str,
    config: AgentConfig | None = None,
) -> ExecutorResult:
    """Run one Ollama Cloud chat completion through its HTTPS API.

    This intentionally uses only the Python standard library. In particular it
    does not invoke the Ollama binary, so workers stay headless and avoid CLI
    startup/PATH dependencies.
    """

    api_key, model, sensitive_values = _ollama_settings(config)
    if not api_key:
        return ExecutorResult(ok=False, text="ollama api key not set")

    request_body = json.dumps(
        {
            "model": model,
            "messages": [{"role": "user", "content": prompt}],
            "stream": False,
        },
        ensure_ascii=False,
    ).encode("utf-8")
    request = urllib.request.Request(
        _ollama_chat_url(),
        data=request_body,
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

    try:
        response = urllib.request.urlopen(
            request,
            timeout=executor_timeout_seconds(),
        )
        try:
            response_body = response.read()
        finally:
            close = getattr(response, "close", None)
            if callable(close):
                close()
    except urllib.error.HTTPError as error:
        return _ollama_failure(
            f"ollama API request failed with HTTP {error.code}: {error.reason}",
            sensitive_values,
        )
    except urllib.error.URLError as error:
        return _ollama_failure(
            f"ollama API network error: {error.reason}",
            sensitive_values,
        )
    except TimeoutError:
        return _ollama_failure(
            "ollama API request timed out",
            sensitive_values,
        )
    except (OSError, ValueError) as error:
        return _ollama_failure(
            f"ollama API network error: {error}",
            sensitive_values,
        )

    try:
        if isinstance(response_body, bytes):
            response_text = response_body.decode("utf-8")
        elif isinstance(response_body, str):
            response_text = response_body
        else:
            raise TypeError("response body must be text or bytes")
        response_json = json.loads(response_text)
    except (TypeError, UnicodeDecodeError, json.JSONDecodeError):
        return _ollama_failure("ollama API returned invalid JSON", sensitive_values)

    if not isinstance(response_json, dict):
        return _ollama_failure(
            "ollama API response JSON must be an object",
            sensitive_values,
        )
    message = response_json.get("message")
    if not isinstance(message, dict) or not isinstance(message.get("content"), str):
        return _ollama_failure(
            "ollama API response missing message.content",
            sensitive_values,
        )

    return ExecutorResult(
        ok=True,
        text=_redact_values(message["content"], sensitive_values),
    )


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)
