#!/usr/bin/env python3
"""Локальный стенд TextMachine (задача 3 полигона, docs/research/06-local-models.md).

Меряет на установленных ollama-моделях:
  - скорость: prompt_eval tok/s и generation tok/s (из метрик /api/generate);
  - память: пик VRAM (nvidia-smi) и RSS ollama во время генерации;
  - качество: сохраняет переводы ja→ru / zh→ru для субъективного сравнения
    (эталон DeepSeek API добавляется тем же скриптом при наличии ключа).

Запуск: eval/.venv/bin/python eval/local_bench.py [--models m1,m2] [--num-ctx 8192]
Выход: eval/data/local_bench/<model>/<fragment>.txt + results.json + таблица в stdout.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parent
OLLAMA = "http://localhost:11434"

# WSL: в env задан http_proxy, а NO_PROXY=<local> (WinINET-формат) urllib не понимает —
# localhost-запросы уходят на прокси и получают 403. Для ollama прокси отключаем явно.
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))


def _load_env_file() -> None:
    """Ключи из eval/.env (gitignored, закрыт от Claude deny-правилом) или
    legacy ~/.textmachine.env; только в env процесса, не печатаются."""
    for cand in (Path(__file__).resolve().parent / ".env",
                 Path("~/.textmachine.env").expanduser()):
        if not cand.exists():
            continue
        for line in cand.read_text().splitlines():
            line = line.strip()
            if not line or line.startswith("#") or "=" not in line:
                continue
            k, _, v = line.partition("=")
            if v.strip():
                os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
        return


_load_env_file()

PROMPT_TMPL = (
    "Ты профессиональный литературный переводчик с {src_name} на русский. "
    "Переведи фрагмент художественной прозы. Требования: точность смысла, живой "
    "литературный русский без канцелярита и калек, сохранить все предложения и реплики. "
    "Выведи только перевод.\n\n{text}"
)
SRC_NAMES = {"ja": "японского", "zh": "китайского"}

# Фрагменты: (lang, файл, срез символов) — ~1.2-1.5k знаков, самодостаточные сцены
FRAGMENTS = [
    ("ja", "akutagawa-rashomon.txt", 0, 1400),
    ("ja", "dazai-hashire-merosu.txt", 0, 1400),
    ("zh", "luxun-zhufu.txt", 0, 1500),
]


def gpu_mem_mb() -> int:
    try:
        out = subprocess.run(
            ["nvidia-smi", "--query-gpu=memory.used", "--format=csv,noheader,nounits"],
            capture_output=True, text=True, timeout=5).stdout.strip()
        return int(out.splitlines()[0])
    except Exception:
        return -1


def ram_used_gb() -> float:
    with open("/proc/meminfo") as f:
        mi = {l.split(":")[0]: int(l.split()[1]) for l in f}
    return (mi["MemTotal"] - mi["MemAvailable"]) / 1e6


class PeakSampler(threading.Thread):
    def __init__(self):
        super().__init__(daemon=True)
        self.peak_vram = 0
        self.peak_ram = 0.0
        self.stop_flag = False

    def run(self):
        while not self.stop_flag:
            self.peak_vram = max(self.peak_vram, gpu_mem_mb())
            self.peak_ram = max(self.peak_ram, ram_used_gb())
            time.sleep(1)


def ollama_generate(model: str, prompt: str, num_ctx: int, timeout: int = 1800,
                    think: bool = False) -> dict:
    # think=False (дефолт): у моделей 3.5/GLM thinking включён по умолчанию и съедает
    # бюджет вывода на рассуждения (перевод обрезается/пустой) — для роли переводчика
    # отключаем. think=True: явно включаем + большой num_predict, чтобы модель успела
    # и порассуждать, и выдать перевод (замер влияния reasoning на качество).
    # num_predict — потолок вывода, чтобы модель не убегала.
    req = urllib.request.Request(
        f"{OLLAMA}/api/generate",
        data=json.dumps({
            "model": model, "prompt": prompt, "stream": False, "think": think,
            "options": {"num_ctx": num_ctx, "temperature": 0.3,
                        "num_predict": 6000 if think else 2048},
        }).encode(),
        headers={"Content-Type": "application/json"})
    try:
        with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
            return json.loads(resp.read())
    except urllib.error.HTTPError as e:
        # не все модели принимают think=False (нет thinking-способности) — повтор без него
        if e.code == 400:
            body = json.loads(req.data)
            body.pop("think", None)
            req2 = urllib.request.Request(f"{OLLAMA}/api/generate", data=json.dumps(body).encode(),
                                          headers={"Content-Type": "application/json"})
            with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp:
                return json.loads(resp.read())
        raise


def strip_think(text: str) -> str:
    """Qwen3 в дефолте может отдавать <think>…</think> — вырезаем для оценки перевода."""
    return re.sub(r"<think>.*?</think>\s*", "", text, flags=re.S).strip()


def deepseek_generate(prompt: str) -> dict | None:
    key = os.environ.get("DEEPSEEK_API_KEY")
    if not key:
        return None
    req = urllib.request.Request(
        "https://api.deepseek.com/v1/chat/completions",
        data=json.dumps({"model": "deepseek-chat", "temperature": 0.3,
                         "messages": [{"role": "user", "content": prompt}]}).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"})
    t0 = time.time()
    with urllib.request.urlopen(req, timeout=600) as resp:
        data = json.loads(resp.read())
    return {"response": data["choices"][0]["message"]["content"],
            "elapsed_s": time.time() - t0, "usage": data.get("usage", {})}


def installed_models() -> list[str]:
    with _NO_PROXY_OPENER.open(f"{OLLAMA}/api/tags", timeout=10) as resp:
        return [m["name"] for m in json.loads(resp.read())["models"]]


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--models", help="через запятую; по умолчанию все установленные")
    ap.add_argument("--num-ctx", type=int, default=8192)
    ap.add_argument("--out", default=str(ROOT / "data" / "local_bench"))
    ap.add_argument("--think", action="store_true",
                    help="включить thinking-режим (reasoning) + большой num_predict — замер влияния на качество")
    args = ap.parse_args()

    models = args.models.split(",") if args.models else installed_models()
    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)

    results = []
    for model in models:
        print(f"\n=== {model} (num_ctx={args.num_ctx}) ===")
        subprocess.run(["ollama", "stop", model.split(":")[0]], capture_output=True)
        base_vram, base_ram = gpu_mem_mb(), ram_used_gb()
        for lang, fname, a, b in FRAGMENTS:
            src_path = ROOT / "data" / "samples" / lang / fname
            if not src_path.exists():
                print(f"  [skip] нет {src_path}")
                continue
            text = src_path.read_text()[a:b]
            prompt = PROMPT_TMPL.format(src_name=SRC_NAMES[lang], text=text)
            sampler = PeakSampler()
            sampler.start()
            t0 = time.time()
            try:
                r = ollama_generate(model, prompt, args.num_ctx, think=args.think)
            except Exception as e:
                sampler.stop_flag = True
                print(f"  {fname}: ОШИБКА {type(e).__name__}: {str(e)[:200]}")
                results.append({"model": model, "fragment": fname, "error": str(e)[:500]})
                continue
            elapsed = time.time() - t0
            sampler.stop_flag = True
            sampler.join(2)
            gen_tps = r.get("eval_count", 0) / max(r.get("eval_duration", 1), 1) * 1e9
            pp_tps = r.get("prompt_eval_count", 0) / max(r.get("prompt_eval_duration", 1), 1) * 1e9
            translation = strip_think(r.get("response", ""))
            mdir = out_dir / model.replace("/", "_").replace(":", "_")
            mdir.mkdir(exist_ok=True)
            (mdir / f"{fname.removesuffix('.txt')}.ru.txt").write_text(translation)
            rec = {"model": model, "fragment": fname, "lang": lang,
                   "src_chars": len(text),
                   "prompt_eval_tps": round(pp_tps, 1), "gen_tps": round(gen_tps, 1),
                   "gen_tokens": r.get("eval_count"), "elapsed_s": round(elapsed, 1),
                   "peak_vram_mb": sampler.peak_vram, "base_vram_mb": base_vram,
                   "peak_ram_gb": round(sampler.peak_ram, 1),
                   "base_ram_gb": round(base_ram, 1),
                   "out_chars": len(translation)}
            results.append(rec)
            print(f"  {fname}: gen {gen_tps:.1f} tok/s, prompt {pp_tps:.0f} tok/s, "
                  f"{elapsed:.0f}s, VRAM пик {sampler.peak_vram} MB, RAM пик {sampler.peak_ram:.1f} GB")

    # эталон DeepSeek (если есть ключ)
    if os.environ.get("DEEPSEEK_API_KEY"):
        print("\n=== deepseek-chat (эталон API) ===")
        mdir = out_dir / "deepseek-api"
        mdir.mkdir(exist_ok=True)
        for lang, fname, a, b in FRAGMENTS:
            src_path = ROOT / "data" / "samples" / lang / fname
            text = src_path.read_text()[a:b]
            r = deepseek_generate(PROMPT_TMPL.format(src_name=SRC_NAMES[lang], text=text))
            (mdir / f"{fname.removesuffix('.txt')}.ru.txt").write_text(r["response"])
            results.append({"model": "deepseek-api", "fragment": fname,
                            "elapsed_s": round(r["elapsed_s"], 1), "usage": r["usage"]})
            print(f"  {fname}: {r['elapsed_s']:.0f}s")
    else:
        print("\n[note] DEEPSEEK_API_KEY нет — эталонные переводы не сняты")

    (out_dir / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
    print(f"\nJSON: {out_dir}/results.json; переводы: {out_dir}/<model>/*.ru.txt")


if __name__ == "__main__":
    main()
