# -*- coding: utf-8 -*-
"""Bring-your-own-AI adapter.

The user configures a shell command template in app/config.json, e.g.

  "ai_cmd": "gemini -m gemini-2.5-flash -p \"{prompt}\" \"{image}\""

Placeholders:
  {prompt}  the question text (quotes inside are stripped to keep the
            command line parseable)
  {image}   absolute path of an image file; omit it in the template for
            text-only calls. When {image} stands alone as its own argument
            it expands to ONE ARGUMENT PER IMAGE, which is what a CLI with a
            multi-value image flag needs (codex: `-i a.jpg b.jpg`). On a
            text-only call the empty expansion also drops the flag directly
            in front of it, so `-i {image}` doesn't leave a dangling `-i`.
  {out}     optional. Substituted with a temp file path; if present, the
            answer is read from THAT FILE instead of stdout.

Whatever the tool prints to stdout is the answer, unless the template uses
{out}. API keys, billing and model choice all live with the user's own CLI
setup — this app never sees credentials.
"""
import os
import re
import shlex
import shutil
import subprocess
import tempfile
from pathlib import Path

from .ffbin import load_config

TIMEOUT = 120

# Verified against the actual installed binaries (not guessed from memory —
# each template below was run for real with a sample frame before being
# added here). Add more as they get verified; an unverified guess is worse
# than not offering the tool at all.
KNOWN_CLIS = [
    {
        "bin": "claude",
        "label": "Claude Code",
        "template": 'claude -p "{prompt} 圖片路徑: {image}" '
                    '--allowedTools Read --output-format text',
        "note": "已安裝就能直接用，不用額外設定或金鑰（用你目前登入的 Claude 額度）。"
                "--allowedTools Read 限制它只能讀取圖片，不能改動任何檔案。",
    },
    {
        "bin": "codex",
        "label": "Codex CLI",
        "template": 'codex exec --skip-git-repo-check --color never '
                    '-s read-only --ephemeral --ignore-user-config '
                    '-o {out} -i {image} -- "{prompt}"',
        "note": "圖片是用 -i 直接附加給模型，不是叫它自己去開檔，所以最快："
                "實測 6 張連拍 10.2 秒（Claude Code 同樣 6 張要 22.6 秒）。"
                "-s read-only 讓它不能改動任何檔案；--ephemeral 不留工作階段檔案；"
                "-o 把最終答案寫到暫存檔，避免它印在畫面上的工作階段訊息混進結果。",
    },
    {
        "bin": "agy",
        "label": "Antigravity CLI",
        "template": 'agy -p "{prompt} 圖片路徑: {image}" --dangerously-skip-permissions',
        "note": "Google 的終端 AI 代理工具。無終端機模式下讀檔案動作預設會被擋下"
                "（沒有畫面可以按同意），所以需要這個旗標自動放行——"
                "本程式固定只會叫它讀一張截圖說內容，不會給它其他指令。",
    },
    {
        "bin": "gemini",
        "label": "Gemini CLI",
        "template": 'gemini -p "{prompt}" -i "{image}"',
        "note": "需自行安裝并登入 Google 帳號或設定 API key。旗標可能隨版本變動，"
                "填入後請按「測試」確認能正常回應。",
    },
]


def _resolve_binary(bin_name: str):
    """shutil.which(), then unwrap npm-style .cmd/.bat shims to the real
    executable underneath when possible.

    Returns a path string, or a LIST of argv parts when the shim's real
    target is a script needing an interpreter (codex ships no .exe — its
    shim runs `node ...\\codex.js`), or None.

    npm's global-install shims (e.g. claude.CMD) are a 3-line batch file
    that just forwards `%*` to a real .exe sitting under node_modules.
    Running the .cmd directly means Windows routes the call through
    cmd.exe's OWN batch-script argument re-tokenization — which mangles
    non-ASCII text (verified: a Chinese prompt came back corrupted even
    when this Python side passed a correctly-escaped argv). Calling the
    underlying .exe directly skips that layer entirely. If no such .exe
    can be found, fall back to the shim path — a shell tool without this
    pattern just won't hit the bug in the first place."""
    path = shutil.which(bin_name)
    if not path or not path.lower().endswith((".cmd", ".bat")):
        return path
    try:
        content = Path(path).read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return path
    m = re.search(r'"%dp0%\\?(?P<rel>[^"]+\.exe)"', content, re.IGNORECASE)
    if m:
        candidate = Path(path).parent / m.group("rel")
        if candidate.exists():
            return str(candidate)
    # node-script shim: `"%_prog%" "%dp0%\node_modules\...\cli.js" %*`
    m = re.search(r'"%dp0%\\?(?P<rel>[^"]+\.(?:js|mjs|cjs))"', content,
                  re.IGNORECASE)
    if m:
        script = Path(path).parent / m.group("rel")
        node = (Path(path).parent / "node.exe")
        node = str(node) if node.exists() else shutil.which("node")
        if script.exists() and node:
            return [node, str(script)]
    return path


def detect_clis() -> list:
    """Which known AI CLIs are actually on PATH right now."""
    out = []
    for c in KNOWN_CLIS:
        path = _resolve_binary(c["bin"])
        out.append({**c, "installed": bool(path), "path": path})
    return out


def templates() -> list:
    """The ordered fallback chain. `ai_cmds` (a list) is the current form;
    `ai_cmd` (a single string) is still honoured so existing configs keep
    working untouched."""
    cfg = load_config()
    chain = cfg.get("ai_cmds")
    if isinstance(chain, list):
        out = [str(t).strip() for t in chain if str(t).strip()]
        if out:
            return out
    one = str(cfg.get("ai_cmd") or "").strip()
    return [one] if one else []


def label_for(tmpl: str) -> str:
    """Best-effort friendly name for logs, from the template's binary."""
    head = (shlex.split(tmpl, posix=False) or [""])[0].strip('"').lower()
    stem = Path(head).stem
    for c in KNOWN_CLIS:
        if c["bin"] == stem:
            return c["label"]
    return stem or "AI"


def ai_available() -> bool:
    return bool(templates())


# Which link of the chain last worked. Retrying a dead first choice on every
# single call is expensive — a failing agy costs ~10 s, and a burst analysis
# makes dozens of calls, so that alone would add minutes per run. Start from
# the last known-good entry instead, and still fall further down on failure.
_preferred = 0
_chain_seen: list = []


def ask(prompt: str, image: str | list | None = None,
        timeout: int = TIMEOUT, log=None) -> str:
    """Ask the configured AI, walking the fallback chain on failure.

    Any failure of one link — unreachable service, exhausted quota, a
    non-zero exit, a timeout — moves on to the next. Only when every link
    has failed does this raise, and the error then names what each one said,
    because "the AI failed" without per-tool detail is what made the earlier
    agy outage take so long to pin down.
    """
    global _preferred, _chain_seen
    tmpls = templates()
    if not tmpls:
        raise RuntimeError("尚未設定任何 AI 指令（app/config.json 的 ai_cmds）")
    if tmpls != _chain_seen:          # config edited -> re-evaluate from the top
        _chain_seen, _preferred = tmpls, 0

    order = list(range(_preferred, len(tmpls))) + list(range(0, _preferred))
    errors = []
    for pos, idx in enumerate(order):
        tmpl = tmpls[idx]
        name = label_for(tmpl)
        try:
            reply = _ask_one(tmpl, prompt, image, timeout)
            if idx != _preferred:
                _preferred = idx      # stick with whatever answered
                if log:
                    log(f"    ✔ 改用 {name} 成功，後續呼叫直接從它開始")
            return reply
        except Exception as e:        # noqa: BLE001 — any failure -> next link
            errors.append(f"{name}: {str(e)[-200:]}")
            if pos + 1 < len(order) and log:
                log(f"    ↷ {name} 失敗，改試下一個 AI…")
    raise RuntimeError("所有 AI 都失敗了 —— " + " ｜ ".join(errors))


def _kill_tree(pid: int):
    """Kill a process AND its descendants.

    These CLIs are launchers: agy starts a language server and a store
    manager, codex an app-server, each in its own process. subprocess's own
    timeout only kills the direct child, so the helpers survive as orphans —
    an observed leftover agy had burned 941 seconds of CPU an hour after its
    parent was gone, starving every later call and making the whole run look
    mysteriously slow. taskkill /T walks the tree.
    """
    try:
        subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)],
                       capture_output=True, timeout=20)
    except Exception:                                     # noqa: BLE001
        try:
            os.kill(pid, 9)
        except Exception:                                 # noqa: BLE001
            pass


def _run_killable(argv: list, timeout: int):
    """subprocess.run(timeout=) but the whole process tree dies on timeout."""
    p = subprocess.Popen(argv, shell=False, stdin=subprocess.DEVNULL,
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         text=True, encoding="utf-8", errors="replace")
    try:
        out, err = p.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        _kill_tree(p.pid)
        try:
            out, err = p.communicate(timeout=15)
        except Exception:                                 # noqa: BLE001
            out, err = "", ""
        raise RuntimeError(
            f"超過 {timeout} 秒沒有回應，已強制結束（含子程序）"
            f"{(': ' + err.strip()[-200:]) if err else ''}")
    return subprocess.CompletedProcess(argv, p.returncode, out, err)


def _ask_one(tmpl: str, prompt: str, image, timeout: int) -> str:
    """`image` may be a single path or a list of paths (burst analysis).

    Multiple paths are newline-joined into the template's {image} slot: the
    whole thing lives inside one argv element, and both verified CLIs
    (claude / agy) locate file paths anywhere in the prompt text, so no
    per-tool multi-image flag is needed.
    """
    images = ([str(p) for p in image] if isinstance(image, (list, tuple))
              else ([str(image)] if image else []))
    joined = "\n".join(images)
    # One template serves both call kinds: scene description (with an image)
    # and script generation (text only). A template written for the former
    # naturally contains {image}; the latter must NOT be rejected because of
    # that — {image} simply resolves to nothing. (Erroring here is what
    # silently pushed script generation onto the rules-based fallback,
    # discarding every scene description the user had just paid for.)

    # Tokenize the TEMPLATE first (while it still only contains placeholders,
    # not real paths), then substitute {prompt}/{image} into individual argv
    # elements and exec with shell=False. Doing substitution first and
    # handing the resulting string to shell=True (the original approach)
    # breaks on Windows: cmd.exe wraps the whole thing in its own "..." for
    # `/c`, and if the template itself already contains "..." (as every
    # practical `-p "..."` template does), cmd.exe's nested-quote parsing
    # corrupts the command — it was literally splitting the line into a
    # garbage fragment plus a lucky-accident-correct fragment. Going through
    # argv + CreateProcess directly sidesteps cmd.exe's quoting entirely.
    argv = shlex.split(tmpl, posix=False)
    argv = [a[1:-1] if len(a) >= 2 and a[0] == a[-1] == '"' else a for a in argv]

    out_path = None
    built = []
    for a in argv:
        if a == "{image}":
            # A bare {image} argument means "expand to one argument per
            # image" — required by multi-value flags like codex's `-i`.
            # Newline-joining them into a single argument (what the {image}
            # substitution below does) would hand the CLI one absurd
            # filename instead of N files.
            if not images and built and built[-1].startswith("-"):
                built.pop()      # text-only call: drop the now-argument-less flag
            built.extend(images)
            continue
        if "{out}" in a:
            if out_path is None:
                fd, out_path = tempfile.mkstemp(prefix="aicli_", suffix=".txt")
                os.close(fd)
            a = a.replace("{out}", out_path)
        built.append(a.replace("{prompt}", prompt).replace("{image}", joined))
    argv = built
    if argv:
        # shell=False requires argv[0] to be something CreateProcess can run
        # directly; resolve PATH + unwrap .cmd/.bat shims ourselves rather
        # than letting a shell do it (that shell is exactly what corrupts
        # non-ASCII arguments — see the resolve_binary docstring above).
        resolved = _resolve_binary(argv[0]) or argv[0]
        # a list means "interpreter + script" (node-based shim), so it
        # replaces argv[0] with BOTH parts
        argv = (list(resolved) + argv[1:]) if isinstance(resolved, list) \
            else [resolved] + argv[1:]

    try:
        # stdin MUST be closed. codex treats an inherited stdin as "a prompt
        # may still be coming" and blocks until the timeout kills it (exit
        # 124), even though the prompt was already passed as an argument.
        r = _run_killable(argv, timeout)
        if r.returncode != 0:
            raise RuntimeError(
                f"ai_cmd failed ({r.returncode}): {(r.stderr or '')[-800:]}")
        if out_path:
            txt = Path(out_path).read_text(encoding="utf-8",
                                           errors="replace").strip()
            if txt:
                return txt
            # fall through to stdout rather than returning nothing, so a
            # template that misuses {out} still degrades to the old behaviour
        return (r.stdout or "").strip()
    finally:
        if out_path:
            Path(out_path).unlink(missing_ok=True)
