"""Chạy lệnh con của `zalo-agent-cli` (npm, upstream: github.com/PhucMPham/zalo-agent-cli) trong một phiên Zalo.

Port độc lập từ thansa-os `server/zalo_cli.py`. Bỏ phụ thuộc `mcp_store`, `config`, `winproc`: người gọi tự đưa
`home` (thư mục phiên Zalo của một tài khoản) và `install_dir` (nơi cài bản ghim).

Bẫy đã gặp (giữ nguyên cách xử lý của bản gốc):
  - CLI KHÔNG thoát mã khác 0 khi Zalo từ chối: thành công = rc 0 VÀ stdout có JSON (`--json`). Xem `interpret`.
  - Windows: `npx.cmd` qua cmd.exe sẽ diễn giải `& | < > ^ % !` trong nội dung tin -> chạy `node npx-cli.js` thẳng.
  - Tham số vị trí bắt đầu bằng "-" bị Commander coi là cờ -> chèn `--`.
  - Cờ nhiều giá trị (`--mention <specs...>`) nuốt tham số phía sau -> đặt tham số vị trí TRƯỚC cờ.
  - Mỗi lần `npx -y` mất ~3 giây khởi động -> cài ngầm một bản ghim rồi chạy `node index.js`.
"""
from __future__ import annotations

import asyncio
import json
import os
import re
import shutil
import signal
import sys
import time
from pathlib import Path
from typing import Any, List, Optional, Tuple

CLI_PACKAGE = "zalo-agent-cli@1.6.2"     # bản đã kiểm; đổi bản là phải kiểm lại khuôn dữ liệu MCP
DEFAULT_TIMEOUT = 120
TIMEOUT_MARK = "giây chưa xong"
INSTALL_TIMEOUT = 300
INSTALL_RETRY = 1800
_INSTALL_MARK = ".zalo-kit-installed"
_CMD_UNSAFE = re.compile(r'[&|<>^%!"\r\n]')
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")


def no_window_kwargs() -> dict:
    """Windows: không bật cửa sổ console. POSIX: nhóm tiến trình riêng để giết được cả cây (npx -> node)."""
    if os.name == "nt":
        import subprocess
        return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0)}
    return {"start_new_session": True}


def kill_tree(proc) -> None:
    """Giết cả cây tiến trình. Chỉ kill() launcher thì node cháu còn sống và giữ khoá thư mục phiên."""
    try:
        if os.name == "nt":
            import subprocess
            subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
        else:
            os.killpg(proc.pid, signal.SIGKILL)
        return
    except Exception:
        pass
    try:
        proc.kill()
    except Exception:
        pass


class ZaloCli:
    def __init__(self, install_dir: Optional[str] = None, package: str = CLI_PACKAGE, auto_install: bool = True):
        self.install_dir = Path(install_dir) if install_dir else None
        self.package = package
        self.auto_install = bool(auto_install and install_dir)
        self._task: Optional[asyncio.Task] = None
        self._failed_at = 0.0

    # ---- tìm lệnh ---------------------------------------------------------------------------
    def check(self) -> Optional[str]:
        if not shutil.which("npx"):
            return "Máy chưa có Node.js 20+ (lệnh npx)."
        return None

    def cli_entry(self) -> Optional[Path]:
        if not self.install_dir:
            return None
        try:
            if (self.install_dir / _INSTALL_MARK).read_text(encoding="utf-8").strip() != self.package:
                return None
        except OSError:
            return None
        entry = self.install_dir / "node_modules" / "zalo-agent-cli" / "src" / "index.js"
        return entry if entry.is_file() else None

    def direct_command(self) -> Optional[List[str]]:
        entry, node = self.cli_entry(), shutil.which("node")
        return [node, str(entry)] if entry and node else None

    @staticmethod
    def _node_script(tool: str, name: str) -> Optional[List[str]]:
        exe = shutil.which(tool)
        if not exe:
            return None
        if os.name != "nt":
            return [exe]
        node = shutil.which("node")
        if node:
            cli = Path(node).parent / "node_modules" / "npm" / "bin" / name
            if cli.is_file():
                return [node, str(cli)]
        return None

    def npx_command(self) -> Optional[List[str]]:
        head = self._node_script("npx", "npx-cli.js")
        if head is None and shutil.which("npx"):
            return ["cmd.exe", "/c", shutil.which("npx")]      # chỉ Windows thiếu npx-cli.js mới rơi vào đây
        return head

    def base_command(self) -> Optional[List[str]]:
        """Đầu lệnh chạy CLI (chưa có `--json` và lệnh con)."""
        direct = self.direct_command()
        if direct:
            return direct
        head = self.npx_command()
        return head + ["-y", self.package] if head else None

    # ---- cài ngầm bản ghim ------------------------------------------------------------------
    async def _install(self) -> bool:
        head = self._node_script("npm", "npm-cli.js")
        if head is None or not self.install_dir:
            return False
        root = self.install_dir
        root.mkdir(parents=True, exist_ok=True)
        pkg = root / "package.json"
        if not pkg.exists():
            pkg.write_text('{"name": "zalo-kit-cli", "private": true}\n', encoding="utf-8")
        (root / _INSTALL_MARK).unlink(missing_ok=True)
        argv = head + ["install", "--prefix", str(root), "--no-audit", "--no-fund", "--no-package-lock",
                       "--loglevel=error", self.package]
        proc = await asyncio.create_subprocess_exec(*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
                                                    stdin=asyncio.subprocess.DEVNULL, **no_window_kwargs())
        try:
            _o, err = await asyncio.wait_for(proc.communicate(), timeout=INSTALL_TIMEOUT)
        except asyncio.TimeoutError:
            kill_tree(proc)
            return False
        if proc.returncode != 0:
            print("[zalo-cli] cài lỗi: " + _ANSI.sub("", (err or b"").decode("utf-8", "replace"))[-300:], file=sys.stderr)
            return False
        if not (root / "node_modules" / "zalo-agent-cli" / "src" / "index.js").is_file():
            return False
        (root / _INSTALL_MARK).write_text(self.package, encoding="utf-8")
        return True

    def start_install(self) -> None:
        if not self.auto_install or self.cli_entry() is not None:
            return
        if self._task is not None and not self._task.done():
            return
        if time.time() - self._failed_at < INSTALL_RETRY:
            return
        try:
            loop = asyncio.get_running_loop()
        except RuntimeError:
            return

        async def guarded():
            try:
                ok = await self._install()
            except Exception as e:      # noqa: BLE001
                print(f"[zalo-cli] cài lỗi: {e}", file=sys.stderr)
                ok = False
            if not ok:
                self._failed_at = time.time()

        self._task = loop.create_task(guarded())

    # ---- chạy lệnh -------------------------------------------------------------------------
    def build_argv(self, command: List[str], positionals=None, options=None) -> Optional[List[str]]:
        base = self.base_command()
        if base is None:
            return None
        argv = base + ["--json"] + list(command)
        pos = [str(p) for p in (positionals or [])]
        opts = [str(o) for o in (options or [])]
        if any(p.startswith("-") for p in pos):
            return argv + opts + ["--"] + pos
        return argv + pos + opts

    @staticmethod
    def unsafe_for_cmd(argv: List[str]) -> Optional[str]:
        if argv and os.path.basename(argv[0]).lower() == "cmd.exe":
            if any(_CMD_UNSAFE.search(a) for a in argv[3:]):
                return "Windows thiếu npx-cli.js: nội dung có & | < > ^ % ! \" hoặc xuống dòng không gửi an toàn được."
        return None

    async def run(self, home: str, command: List[str], positionals=None, options=None,
                  timeout: int = DEFAULT_TIMEOUT) -> Tuple[bool, Any, str]:
        """Chạy `<cli> --json <command> ...` với HOME=home. Trả `(ok, json, lỗi)`."""
        if not home:
            return False, None, "thiếu thư mục phiên Zalo (home)"
        if self.direct_command() is None:
            self.start_install()
        argv = self.build_argv(command, positionals, options)
        if argv is None:
            return False, None, "máy chưa có Node.js 20+ (lệnh npx)"
        why = self.unsafe_for_cmd(argv)
        if why:
            return False, None, why
        env = dict(os.environ, HOME=home, USERPROFILE=home)
        proc = await asyncio.create_subprocess_exec(*argv, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
                                                    stdin=asyncio.subprocess.DEVNULL, env=env, **no_window_kwargs())
        try:
            out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
        except asyncio.TimeoutError:
            kill_tree(proc)
            try:
                await asyncio.wait_for(proc.wait(), 5)
            except Exception:
                pass
            return interpret(None, None, f"quá {timeout} {TIMEOUT_MARK}")
        return interpret(proc.returncode, out.decode("utf-8", "replace"), err.decode("utf-8", "replace"))


def parse_json(text: str) -> Any:
    s = str(text or "").strip()
    if not s:
        return None
    try:
        return json.loads(s)
    except ValueError:
        pass
    dec = json.JSONDecoder()
    for i, ch in enumerate(s):
        if ch in "{[":
            try:
                return dec.raw_decode(s[i:])[0]
            except ValueError:
                continue
    return None


def interpret(rc: Optional[int], out: Optional[str], err: str) -> Tuple[bool, Any, str]:
    if rc is None:
        return False, None, f"Zalo {err}"
    if rc != 0:
        return False, None, f"zalo-agent-cli thoát mã {rc}. " + _ANSI.sub("", err or out or "").strip()[-400:]
    data = parse_json(out)
    if data is None:
        both = _ANSI.sub("", err or "") + "\n" + _ANSI.sub("", out or "")
        marks = [ln.strip().lstrip("✗").strip() for ln in both.splitlines() if "✗" in ln]
        return False, None, "; ".join(m for m in marks if m) or both.strip()[-300:] or "không có phản hồi"
    return True, data, ""


def is_timeout(err: str) -> bool:
    """Hết giờ = KHÔNG RÕ tin đã đi chưa. Người gọi không được gửi lại bằng đường khác (dễ thành 2 tin)."""
    return TIMEOUT_MARK in str(err or "")
