#!/usr/bin/env python3
"""Раннер aitasks: ваш Codex отвечает на ваши сообщения из Telegram и веб-чата.

Забирает с сервера новые сообщения, запускает локальный `codex exec` (задачи он меняет через
MCP-сервер aitasks) и отправляет ответ обратно. Работает под вашей учётной записью Codex.

Запуск:
    aitasks_runner.exe                         (Windows, Python не нужен; при первом запуске спросит адрес и токен)
    python aitasks_runner.py --server https://tasks.example.com

Адрес сервера и токен берутся из параметров --server/--token, затем из переменных AITASKS_SERVER/AITASKS_TOKEN,
затем из сохранённых настроек (%APPDATA%\\aitasks\\runner.json). Нужен Codex CLI с выполненным входом (codex login).

Если вы разрешили свой Codex другим (веб-интерфейс → «Codex» → «Доступ к вашему Codex»), раннер отвечает и на их
сообщения: сервер выдаёт на каждое такое сообщение отдельный токен, и Codex работает с задачами от имени автора.
"""

import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

VERSION = "0.4.0"
DEFAULT_SERVER = "http://127.0.0.1:8765"
CONFIG_PATH = Path(os.getenv("AITASKS_CONFIG") or Path(os.getenv("APPDATA") or Path.home() / ".config") / "aitasks" / "runner.json")

# Раннеру нужны только инструменты задач: отключаем shell, браузер и прочее
DISABLED_FEATURES = [
    "shell_tool", "unified_exec", "browser_use", "browser_use_external", "computer_use",
    "image_generation", "apps", "plugins", "multi_agent", "hooks", "memories",
]
# Ограниченный режим для чужих сообщений (по выбору владельца Codex): ещё и без картинок с диска и веб-поиска
RESTRICTED_CONFIG = ["features.view_image=false", 'web_search="disabled"']


def interactive() -> bool:
    return bool(sys.stdin and sys.stdin.isatty())


def ask(prompt: str) -> str:
    """input(), который не падает без консоли (на Windows NUL тоже выглядит как терминал)."""
    try:
        return input(prompt)
    except (EOFError, OSError):
        raise SystemExit(1)


def fail(message: str) -> None:
    """Сообщить об ошибке и выйти; у exe, запущенного двойным кликом, окно не закрывается сразу."""
    print(f"\n⚠️ {message}")
    if getattr(sys, "frozen", False) and interactive():
        ask("\nНажмите Enter, чтобы закрыть окно…")
    sys.exit(1)


def load_config() -> dict:
    try:
        return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}


def save_config(cfg: dict) -> None:
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")


def api(server: str, token: str, path: str, data: dict | None = None, timeout: int = 70):
    req = urllib.request.Request(
        server + path,
        data=json.dumps(data).encode() if data is not None else None,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        method="POST" if data is not None else "GET",
    )
    with urllib.request.urlopen(req, timeout=timeout) as r:
        if r.status == 204:
            return None
        return json.loads(r.read().decode("utf-8"))


class Moved(Exception):
    """Сервер в ожидании: бот работает на другом компьютере (адрес в args[0], может быть пустым)."""


def moved_to(error: urllib.error.HTTPError) -> str:
    try:
        return (json.loads(error.read().decode("utf-8")).get("moved_to") or "").rstrip("/")
    except (OSError, ValueError, AttributeError):
        return ""


def whoami(server: str, token: str) -> dict:
    try:
        return api(server, token, "/api/whoami", timeout=15)
    except urllib.error.HTTPError as e:
        if e.code == 409:
            raise Moved(moved_to(e))
        if e.code == 401:
            raise PermissionError("Токен не подходит. Получите новый: /token в боте или вкладка «Codex» в веб-интерфейсе")
        raise ConnectionError(f"Сервер ответил ошибкой {e.code}")
    except (urllib.error.URLError, OSError) as e:
        raise ConnectionError(f"Сервер {server} недоступен: {e}")


def resolve_codex(path: str) -> list[str]:
    """Команда запуска Codex. npm-установка на Windows даёт codex.cmd — запускаем его JS напрямую через node,
    иначе cmd.exe искажает аргументы с кавычками."""
    p = Path(path)
    if os.name == "nt" and p.suffix.lower() in (".cmd", ".bat", ".ps1"):
        js = p.parent / "node_modules" / "@openai" / "codex" / "bin" / "codex.js"
        node = shutil.which("node")
        if js.exists() and node:
            return [node, str(js)]
    return [path]


def codex_missing(command: list[str]) -> bool:
    """Файлов команды больше нет: Codex переустановили или обновили, пока раннер работал."""
    return any(Path(part).is_absolute() and not Path(part).exists() for part in command)


def run_codex(codex: list[str], server: str, token: str, prompt: str, model: str | None, timeout: int, workdir: Path,
              restricted: bool = False) -> str:
    """token — личный токен или токен на задание (сообщение того, кому вы разрешили свой Codex)."""
    out = workdir / "last_message.txt"
    out.unlink(missing_ok=True)
    cmd = [
        *codex, "exec", "--skip-git-repo-check", "--ephemeral", "--ignore-user-config", "--ignore-rules",
        "-s", "read-only", "-C", str(workdir), "-o", str(out), "--color", "never",
        "-c", f'mcp_servers.aitasks.url="{server}/mcp"',
        "-c", 'mcp_servers.aitasks.bearer_token_env_var="AITASKS_TOKEN"',
        # инструменты задач вызываются без подтверждения — иначе в неинтерактивном режиме они блокируются
        "-c", 'mcp_servers.aitasks.default_tools_approval_mode="approve"',
        "-c", 'approval_policy="never"',
    ]
    for f in DISABLED_FEATURES:
        cmd += ["-c", f"features.{f}=false"]
    if restricted:
        for c in RESTRICTED_CONFIG:
            cmd += ["-c", c]
    if model:
        cmd += ["-m", model]
    cmd.append("-")
    env = {**os.environ, "AITASKS_TOKEN": token}
    proc = subprocess.run(cmd, input=prompt, text=True, encoding="utf-8", errors="replace",
                          capture_output=True, timeout=timeout, env=env,
                          creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))  # без мелькающих окон из AITasks.exe
    text = out.read_text(encoding="utf-8").strip() if out.exists() else ""
    if proc.returncode != 0 and not text:
        tail = (proc.stderr or proc.stdout or "").strip()[-1500:]
        raise RuntimeError(f"codex exec завершился с кодом {proc.returncode}: {tail}")
    return text


def ask_settings(server: str | None) -> tuple[str, str]:
    """Первый запуск: спросить адрес сервера и токен, проверить и сохранить."""
    print("Первый запуск: укажите сервер AI Tasks и ваш личный токен.")
    print("Токен выдаёт бот командой /token или веб-интерфейс, вкладка «Codex».\n")
    while True:
        default = server or DEFAULT_SERVER
        entered = ask(f"Адрес сервера [{default}]: ").strip() or default
        server = entered  # при повторной попытке предлагаем уже введённый адрес
        token = ask("Личный токен (atk_…): ").strip()
        try:
            me = whoami(entered.rstrip("/"), token)
        except (PermissionError, ConnectionError) as e:
            print(f"⚠️ {e}\nПопробуйте ещё раз.\n")
            continue
        save_config({"server": entered.rstrip("/"), "token": token})
        print(f"✅ Здравствуйте, {me['name']}! Настройки сохранены в {CONFIG_PATH}\n")
        return entered.rstrip("/"), token


def main() -> None:
    for stream in (sys.stdout, sys.stderr):
        if hasattr(stream, "reconfigure"):
            stream.reconfigure(encoding="utf-8", errors="replace")
    p = argparse.ArgumentParser(description="Раннер aitasks: Codex отвечает на сообщения из Telegram и веб-чата")
    p.add_argument("--server", help=f"Адрес сервера aitasks (по умолчанию из настроек или {DEFAULT_SERVER})")
    p.add_argument("--token", help="Личный токен (по умолчанию из AITASKS_TOKEN или сохранённых настроек)")
    p.add_argument("--codex", default=os.getenv("CODEX_BIN") or shutil.which("codex") or "codex", help="Путь к Codex CLI")
    p.add_argument("--model", default=os.getenv("AITASKS_MODEL"), help="Модель Codex (по умолчанию — модель Codex по умолчанию)")
    p.add_argument("--timeout", type=int, default=300, help="Лимит на одно сообщение, сек")
    p.add_argument("--once", action="store_true", help="Обработать не больше одного сообщения и выйти (для проверки)")
    p.add_argument("--reset", action="store_true", help="Забыть сохранённые адрес сервера и токен")
    p.add_argument("--version", action="version", version=f"aitasks_runner {VERSION}")
    args = p.parse_args()

    if args.reset:
        CONFIG_PATH.unlink(missing_ok=True)
        print(f"Сохранённые настройки удалены ({CONFIG_PATH}).")
    cfg = load_config()
    server = (args.server or os.getenv("AITASKS_SERVER") or cfg.get("server") or "").rstrip("/")
    token = args.token or os.getenv("AITASKS_TOKEN") or cfg.get("token") or ""

    if not shutil.which(args.codex) and not Path(args.codex).exists():
        fail(f"Не найден Codex CLI ({args.codex}). Установите Codex и выполните codex login.")
    codex = resolve_codex(shutil.which(args.codex) or args.codex)

    def locate() -> list[str] | None:
        found = shutil.which(args.codex) or shutil.which("codex")
        return resolve_codex(found) if found else None

    if not token:
        if not interactive():
            fail("Нет токена: задайте --token, переменную AITASKS_TOKEN или запустите раннер в окне, чтобы ввести его.")
        server, token = ask_settings(server)
    server = server or DEFAULT_SERVER

    def remember(new: str) -> None:
        """AI Tasks переехал на другой компьютер — запомнить его адрес вместо прежнего."""
        if cfg.get("token") == token:
            save_config({**cfg, "server": new})
    try:
        try:
            me = whoami(server, token)
        except Moved as e:
            if not e.args[0]:
                raise ConnectionError(f"AI Tasks на {server} в режиме ожидания: бот работает на другом компьютере, "
                                      "а его адрес в сети неизвестен. Укажите адрес: --server http://…")
            print(f"AI Tasks переехал: {server} → {e.args[0]}")
            server = e.args[0]
            remember(server)
            me = whoami(server, token)
    except PermissionError as e:
        hint = " Сохранённый токен устарел — запустите раннер с параметром --reset и введите новый." if cfg.get("token") == token else ""
        fail(f"{e}.{hint}")
    except (ConnectionError, Moved) as e:
        fail(str(e) or f"AI Tasks на {server} в режиме ожидания")
    print(f"✅ Подключено к {server} как {me['name']}. Жду сообщения из Telegram и веб-чата… (Ctrl+C — выход)")
    try:
        serve(server, token, codex, args.model, args.timeout, once=args.once, on_moved=remember, locate=locate)
    except PermissionError:
        fail("Токен больше не действует. Получите новый (/token) и запустите раннер с параметром --reset.")


def serve(server: str, token: str, codex: list[str], model: str | None, timeout: int, once: bool = False,
          stop=None, log=print, on_moved=None, locate=None) -> None:
    """Цикл: забрать сообщение → codex exec → отправить ответ. stop — threading.Event (раннер внутри AITasks.exe).
    on_moved(адрес) — AI Tasks переехал на другой компьютер: переключиться на него и запомнить.
    locate() — найти Codex заново, если прежняя команда пропала (переустановка, обновление).
    Недействительный токен → PermissionError."""
    workdir = Path(tempfile.gettempdir()) / "aitasks_runner"
    workdir.mkdir(exist_ok=True)
    while not (stop and stop.is_set()):
        try:
            item = api(server, token, "/api/runner/next?wait=25&share=1", timeout=45)
        except urllib.error.HTTPError as e:
            if e.code == 401:
                raise PermissionError("токен не действует")
            if e.code == 409:  # сервер в ожидании: бот работает на другом компьютере
                new = moved_to(e)
                if new and on_moved and new != server:
                    log(f"AI Tasks переехал: {server} → {new}")
                    server = new
                    on_moved(new)
                    continue
                log("AI Tasks здесь в режиме ожидания (бот работает на другом компьютере), повтор через 30 с")
                time.sleep(30)
                continue
            log(f"Сервер ответил ошибкой {e.code}, повтор через 10 с")
            time.sleep(10)
            continue
        except (urllib.error.URLError, OSError, TimeoutError) as e:
            log(f"Нет связи с сервером ({e}), повтор через 10 с")
            time.sleep(10)
            continue
        if not item:
            continue
        started = time.time()
        text = item["text"]
        sender = item.get("from")  # сообщение того, кому вы разрешили свой Codex
        hidden = bool(item.get("hide_log"))
        who = f"[{sender}] " if sender else ""
        if hidden:
            log(f"\n→ {who}сообщение ({len(text)} симв.; текст не пишется в журнал)")
        else:
            flat = " ".join(text.split())  # превью одной строкой; агент получает текст целиком
            log(f"\n→ {who}{flat[:150]}" + (f"… [всего {len(text)} симв.]" if len(flat) > 150 else ""))
        if locate and codex_missing(codex):
            found = locate()
            if found:
                log(f"Codex переустановлен, теперь: {' '.join(found)}")
                codex = found
        restricted = bool(item.get("restricted"))
        job_dir = Path(tempfile.mkdtemp(prefix="job_", dir=workdir)) if restricted else workdir  # пустая папка на задание
        try:
            reply, ok = run_codex(codex, server, item.get("token") or token, item["prompt"], model, timeout, job_dir, restricted), True
        except subprocess.TimeoutExpired:
            reply, ok = f"не уложился в {timeout} с", False
        except Exception as e:
            reply, ok = str(e), False
        finally:
            if restricted:
                shutil.rmtree(job_dir, ignore_errors=True)
        log(f"← ({time.time() - started:.0f} с) " + (f"ответ отправлен ({len(reply)} симв.)" if hidden and ok else reply))
        for _ in range(5):
            try:
                api(server, token, "/api/runner/reply", {"id": item["id"], "text": reply, "ok": ok}, timeout=30)
                break
            except (urllib.error.URLError, OSError) as e:
                log(f"Не удалось отправить ответ ({e}), повтор…")
                time.sleep(5)
        if once:
            break


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
