from __future__ import annotations

import asyncio
import contextlib
import os
import re
import shutil
import sys
import time
from collections import deque
from pathlib import Path
from typing import Any, Callable, Awaitable

from aicd.cli_encoding import subprocess_env_utf8
from aicd.tools.path_policy import PathPolicy


def resolve_windows_comspec() -> str:
    """``%ComSpec%``，不可用时回退到 PATH 中的 ``cmd.exe``。"""
    for key in ("ComSpec", "COMSPEC"):
        c = os.environ.get(key)
        if c:
            try:
                if Path(c).expanduser().is_file():
                    return str(Path(c).resolve())
            except OSError:
                pass
            return c
    w = shutil.which("cmd.exe")
    return w if w else "cmd.exe"


def windows_cmd_argv(command_line: str) -> list[str]:
    """
    通过 CMD 执行一行命令：``/d`` 禁用 AutoRun；先 ``chcp 65001`` 再执行，与 UTF-8 环境一致。
    ``command_line`` 将传给 ``cmd /c`` 的剩余部分（勿再外包一层引号除非语法需要）。
    """
    comspec = resolve_windows_comspec()
    inner = f"chcp 65001>nul & {command_line}"
    return [comspec, "/d", "/c", inner]


def windows_powershell_argv(command_line: str, *, flavor: str = "powershell") -> list[str]:
    """``flavor``: ``powershell``（Windows PowerShell 5）或 ``pwsh``（PowerShell 7+）。"""
    name = "pwsh.exe" if flavor.lower().strip() == "pwsh" else "powershell.exe"
    exe = shutil.which(name) or shutil.which(name.replace(".exe", "")) or name
    return [
        exe,
        "-NoProfile",
        "-NonInteractive",
        "-ExecutionPolicy",
        "Bypass",
        "-Command",
        command_line,
    ]


def shell_argv_from_user_input(
    *,
    command: str | None = None,
    argv: list[str] | None = None,
    shell: str | None = None,
) -> list[str]:
    """
    - ``argv``：直接 ``execve`` 风格参数列表（不经 CMD/sh 解释），适用于含空格路径、``git``/``python`` 等。
    - ``command``：在 Windows 上经 ``shell``（默认 CMD）执行的一行命令；在 Unix 上为 ``sh -c``。
    """
    if argv is not None:
        if len(argv) == 0:
            raise ValueError("argv must be a non-empty array of strings")
        return [str(x) for x in argv]
    if command is None or not str(command).strip():
        raise ValueError("non-empty command or argv is required")
    line = str(command).strip()
    if sys.platform != "win32":
        return ["/bin/sh", "-c", line]
    sh = (shell or "cmd").strip().lower()
    if sh in ("powershell", "ps"):
        return windows_powershell_argv(line, flavor="powershell")
    if sh == "pwsh":
        return windows_powershell_argv(line, flavor="pwsh")
    return windows_cmd_argv(line)


def default_shell_argv(command: str) -> list[str]:
    """兼容旧调用：等价于 ``shell_argv_from_user_input(command=command)``。"""
    return shell_argv_from_user_input(command=command)


def warn_shell_python_c_unbalanced_quotes(command: str) -> str | None:
    """
    检测一行 shell 里 ``python -c "..."`` 的双引号是否成对（粗略启发式）。
    """
    s = str(command).strip()
    if not re.search(r"(?i)python(?:\.exe)?\b", s):
        return None
    if not re.search(r"(?i)(^|[\s&(])-c(\s+|$)", s):
        return None
    m = re.search(r"(?i)-c\s+(.*)$", s, re.DOTALL)
    if not m:
        return None
    tail = m.group(1).strip()
    if not tail:
        return "python -c 缺少代码参数"
    if tail.startswith("'"):
        return None
    if tail.count('"') % 2 == 1:
        return (
            "检测到 python -c 后的双引号未成对（CMD 下常与 && 混用导致截断，出现 SyntaxError）。"
            "请改用 **script_run**（kind=py）、或 **fs_write_file** 写入 .py 后用 **run_command** 的 "
            "**argv**：[\"python\", \"脚本路径\"]。避免在一条 CMD 链里塞长段 python -c。"
        )
    return None


def warn_shell_windows_python_c_with_and_chain(command: str) -> str | None:
    """
    Windows CMD 下 ``cd ... && python -c "..."`` 即使源码里引号成对，也常把 ``-c`` 实参截断，
    表现为 ``SyntaxError: unterminated string literal``（例如只收到 ``"import``）。
    """
    if sys.platform != "win32":
        return None
    s = str(command).strip()
    if "&&" not in s:
        return None
    if not re.search(r"(?i)python(?:\.exe)?\b", s):
        return None
    if not re.search(r"(?i)-c\s+", s):
        return None
    return (
        "在 Windows CMD 中，请勿把 ``cd ... && python -c \"...\"`` 写在同一行（``&&`` 与双引号组合易截断参数，触发 SyntaxError）。"
        "请改用 **script_run**（kind=py）、分两次 **run_command**（先 cd /d，再 python），"
        "或 **fs_write_file** 写入 .py 后用 **argv**：[\"python\", \"脚本路径\"]。"
    )


_FINDSTR_COMMON_NOEXT_FILES = frozenset(
    x.lower()
    for x in (
        "LICENSE",
        "README",
        "COPYING",
        "AUTHORS",
        "CHANGELOG",
        "Makefile",
        "Dockerfile",
        "Procfile",
        "CONTRIBUTING",
        "GITATTRIBUTES",
        "GITIGNORE",
    )
)


def warn_shell_windows_findstr_maybe_dir_operand(command: str) -> str | None:
    """
    Windows ``findstr`` 把「搜索串」后面的实参当作**要打开的文件**；若写成子目录名而无通配符，
    会得到 ``FINDSTR: Cannot open <name>``（并非文件被锁或未释放）。
    """
    if sys.platform != "win32":
        return None
    s = str(command).strip()
    if not re.search(r'(?i)\bfindstr\b', s):
        return None
    if "*" in s:
        return None
    tail = re.split(r"[&|]+", s)[-1].strip()
    toks = tail.split()
    if len(toks) < 3:
        return None
    last = toks[-1].strip('"')
    if not last or last.startswith("/") or last.startswith("-"):
        return None
    if "." in last or last.endswith("\\") or last.endswith("/"):
        return None
    if last.lower() in _FINDSTR_COMMON_NOEXT_FILES:
        return None
    return (
        "Windows **findstr** 将把最后的参数当作**单个文件**；若为**文件夹**会报 *Cannot open*（不是资源锁）。"
        "请改为 `cd /d <dir> && findstr /s /i <pattern> *.*`，或 `findstr /s /i <pattern> <dir>\\\\*.*`，"
        "或改用 **rg** / **fs_search_regex**。"
    )


def warn_shell_risky_python_c(command: str) -> str | None:
    """合并：未成对引号，或 ``&&`` + ``python -c``（仅 Windows）。"""
    return warn_shell_python_c_unbalanced_quotes(
        command
    ) or warn_shell_windows_python_c_with_and_chain(command)


def warn_shell_preflight(command: str) -> str | None:
    """``task_shell`` / ``run_command`` 提交前：Python 引号风险 + Windows findstr 误用提示。"""
    return warn_shell_risky_python_c(command) or warn_shell_windows_findstr_maybe_dir_operand(
        command
    )


# pip/apt/npm 等常用非交互提示；键若已在调用方 env 中则保留调用方值
_NONINTERACTIVE_ENV_DEFAULTS: dict[str, str] = {
    "CI": "1",
    "DEBIAN_FRONTEND": "noninteractive",
    "PIP_NO_INPUT": "1",
    "PIP_DISABLE_PIP_VERSION_CHECK": "1",
    "NPM_CONFIG_YES": "true",
    "DOTNET_NOLOGO": "1",
}

_PASSWORD_PROMPT_RE = re.compile(
    r"(?i)(\[sudo\]\s+password|password\s*for\s+|enter\s+(?:your\s+)?password|passphrase|"
    r"口令\s*[:：]|密码\s*[:：]|authentication\s+token)"
)


def coerce_run_command_options(raw: dict[str, Any] | None) -> dict[str, Any]:
    """从工具 JSON / 任务 payload 提取 ``ProcessTool.run_command`` 扩展参数。"""
    if not raw:
        return {}
    out: dict[str, Any] = {}
    if raw.get("noninteractive_env") is True:
        out["noninteractive_env"] = True
    sm = raw.get("stdin_mode")
    if isinstance(sm, str):
        s = sm.strip()
        if s in ("inherit", "devnull", "auto_yes"):
            out["stdin_mode"] = s
    oi = raw.get("output_idle_timeout_sec")
    if oi is not None:
        try:
            f = float(oi)
            if f > 0:
                out["output_idle_timeout_sec"] = f
        except (TypeError, ValueError):
            pass
    return out


async def kill_windows_process_tree(
    root_pid: int, *, timeout_sec: float = 15.0
) -> dict[str, Any]:
    """
    结束 ``root_pid`` 及其**所有后代**（``taskkill /T /F``）。

    用于 Windows 上通过 CMD/PowerShell 拉起的 GUI 等子进程：仅 ``terminate`` 外层 ``cmd.exe``
    时，子进程常会残留；整树结束后可一并清理。
    """
    pid = int(root_pid)
    try:
        tk = await asyncio.create_subprocess_exec(
            "taskkill",
            "/PID",
            str(pid),
            "/T",
            "/F",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
    except FileNotFoundError:
        return {"ok": False, "error": "taskkill not found on PATH"}
    try:
        out_b, err_b = await asyncio.wait_for(
            tk.communicate(), timeout=timeout_sec
        )
    except TimeoutError:
        tk.kill()
        return {"ok": False, "error": "taskkill communicate timeout"}
    so = (out_b or b"").decode("utf-8", errors="replace").strip()
    se = (err_b or b"").decode("utf-8", errors="replace").strip()
    rc = tk.returncode if tk.returncode is not None else -1
    low = se.lower()
    # 目标已退出时 taskkill 常非 0，仍视为可接受
    benign = rc == 0 or any(
        x in low
        for x in (
            "not found",
            "could not find",
            "no tasks",
            "no matching",
        )
    ) or ("没有运行" in se and "无法终止" not in se)
    return {
        "ok": benign,
        "returncode": rc,
        "stdout_tail": so[:800],
        "stderr_tail": se[:800],
    }


async def _terminate_subprocess(
    proc: asyncio.subprocess.Process, *, kill_subtree: bool = False
) -> None:
    """取消外部命令时尽量结束子进程；Windows 上可选整树结束（``task_shell`` 取消 GUI 用）。"""
    if proc.returncode is not None:
        return
    pid = proc.pid
    if sys.platform == "win32" and kill_subtree:
        await kill_windows_process_tree(pid)
    else:
        proc.terminate()
        try:
            await asyncio.wait_for(proc.wait(), timeout=5.0)
        except TimeoutError:
            proc.kill()
            try:
                await asyncio.wait_for(proc.wait(), timeout=2.0)
            except TimeoutError:
                pass
    if proc.returncode is None:
        try:
            proc.kill()
        except (ProcessLookupError, OSError):
            return
        try:
            await asyncio.wait_for(proc.wait(), timeout=2.0)
        except TimeoutError:
            pass


class ProcessTool:
    def __init__(self, policy: PathPolicy, cwd: Path | None = None) -> None:
        self._policy = policy
        self._cwd = cwd or Path.cwd()

    async def run_command(
        self,
        argv: list[str],
        *,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        timeout_sec: float | None = None,
        on_line: Callable[[str], Awaitable[None]] | None = None,
        on_spawned: Callable[[int], Awaitable[None]] | None = None,
        kill_subtree_on_cancel: bool | None = None,
        noninteractive_env: bool = False,
        stdin_mode: str = "inherit",
        output_idle_timeout_sec: float | None = None,
    ) -> dict[str, Any]:
        wd = Path(cwd).resolve() if cwd else self._cwd
        self._policy.check_allowed(wd)
        sm = (stdin_mode or "inherit").strip()
        if sm not in ("inherit", "devnull", "auto_yes"):
            sm = "inherit"

        env_merged: dict[str, str] | None = dict(env) if env else None
        if noninteractive_env:
            base = dict(env_merged) if env_merged else {}
            for k, v in _NONINTERACTIVE_ENV_DEFAULTS.items():
                base.setdefault(k, v)
            env_merged = base

        if output_idle_timeout_sec is not None:
            try:
                oi = float(output_idle_timeout_sec)
            except (TypeError, ValueError):
                stall_threshold = 0.0
            else:
                stall_threshold = oi if oi > 0 else 0.0
        elif sm == "auto_yes":
            stall_threshold = 600.0
        else:
            stall_threshold = 0.0

        exec_kw: dict[str, Any] = {
            "cwd": str(wd),
            "env": subprocess_env_utf8(env_merged),
            "stdout": asyncio.subprocess.PIPE,
            "stderr": asyncio.subprocess.PIPE,
        }
        if sm == "devnull":
            exec_kw["stdin"] = asyncio.subprocess.DEVNULL
        elif sm == "auto_yes":
            exec_kw["stdin"] = asyncio.subprocess.PIPE

        proc = await asyncio.create_subprocess_exec(*argv, **exec_kw)
        if on_spawned is not None:
            await on_spawned(proc.pid)
        subtree = (
            kill_subtree_on_cancel
            if kill_subtree_on_cancel is not None
            else (sys.platform == "win32")
        )
        out_chunks: list[str] = []
        err_chunks: list[str] = []
        recent = deque(maxlen=48)
        last_act: list[float] = [time.monotonic()]
        state: dict[str, bool] = {}

        def touch() -> None:
            last_act[0] = time.monotonic()

        async def pump(
            stream: asyncio.StreamReader | None, sink: list[str], prefix: str
        ) -> None:
            if stream is None:
                return
            while True:
                line = await stream.readline()
                if not line:
                    break
                s = line.decode("utf-8", errors="replace").rstrip("\r\n")
                sink.append(s)
                touch()
                recent.append(s)
                if on_line:
                    await on_line(f"{prefix}{s}")

        async def _pump_all() -> None:
            await asyncio.gather(
                pump(proc.stdout, out_chunks, "[stdout] "),
                pump(proc.stderr, err_chunks, "[stderr] "),
                proc.wait(),
            )

        async def auto_yes_feeder() -> None:
            sent = 0
            try:
                while sent < 5000 and proc.returncode is None:
                    await asyncio.sleep(0.4)
                    if proc.returncode is not None:
                        break
                    blob = "\n".join(recent)
                    if len(blob) > 4000:
                        blob = blob[-4000:]
                    if _PASSWORD_PROMPT_RE.search(blob):
                        state["password_hint"] = True
                        await _terminate_subprocess(proc, kill_subtree=subtree)
                        return
                    if proc.stdin is None:
                        break
                    try:
                        proc.stdin.write(b"y\n")
                        await proc.stdin.drain()
                    except (BrokenPipeError, ConnectionResetError, OSError):
                        break
                    sent += 1
            except asyncio.CancelledError:
                raise

        async def stall_watch() -> None:
            try:
                while proc.returncode is None:
                    await asyncio.sleep(2.5)
                    if proc.returncode is not None:
                        return
                    if time.monotonic() - last_act[0] > stall_threshold:
                        state["stall"] = True
                        await _terminate_subprocess(proc, kill_subtree=subtree)
                        return
            except asyncio.CancelledError:
                raise

        bg_tasks: list[asyncio.Task[None]] = []
        if sm == "auto_yes":
            bg_tasks.append(asyncio.create_task(auto_yes_feeder()))
        if stall_threshold > 0:
            bg_tasks.append(asyncio.create_task(stall_watch()))

        out_timeout: dict[str, Any] | None = None
        try:
            try:
                if timeout_sec:
                    await asyncio.wait_for(_pump_all(), timeout=timeout_sec)
                else:
                    await _pump_all()
            except asyncio.CancelledError:
                await _terminate_subprocess(proc, kill_subtree=subtree)
                raise
            except TimeoutError:
                await _terminate_subprocess(proc, kill_subtree=subtree)
                out_timeout = {
                    "ok": False,
                    "error": "timeout",
                    "pid": proc.pid,
                    "stdout": "\n".join(out_chunks[-200:]),
                    "stderr": "\n".join(err_chunks[-200:]),
                }
        finally:
            for t in bg_tasks:
                t.cancel()
            for t in bg_tasks:
                with contextlib.suppress(asyncio.CancelledError):
                    await t

        if out_timeout is not None:
            return out_timeout

        rc = proc.returncode if proc.returncode is not None else -1
        so = "\n".join(out_chunks)
        se = "\n".join(err_chunks)
        if state.get("password_hint"):
            return {
                "ok": False,
                "returncode": rc,
                "pid": proc.pid,
                "error": "interactive_password_or_secret_prompt",
                "stdout": so,
                "stderr": se,
                "hint": "检测到密码或敏感输入提示，不能自动代填。请用户在本机终端自行执行该步骤，"
                "或改用无需提权/输密的方式；若仅为安装软件，优先使用带 -y / 接受协议 开关的非交互命令并征得用户同意。",
            }
        if state.get("stall"):
            return {
                "ok": False,
                "returncode": rc,
                "pid": proc.pid,
                "error": "output_idle_timeout",
                "stdout": so,
                "stderr": se,
                "hint": "在设定时间内没有新的行输出；子进程可能在等待交互，或长时间静默编译/下载。"
                "可调大 **output_idle_timeout_sec**、为命令增加非交互参数（如 apt -y、winget 接受协议），"
                "或请用户改在系统终端操作；不确定时不要盲答，可向用户说明最后几行输出并请求指示。",
            }
        return {
            "ok": rc == 0,
            "returncode": rc,
            "pid": proc.pid,
            "stdout": so,
            "stderr": se,
        }
