#!/usr/bin/env python3
"""Phase 0 평가 러너 — 골든셋으로 모델(사다리) 평가.

byOPUS 플랜 Phase 0. 카테고리별 채점 + fabrication rate + 회귀(baseline) 비교.
LLM이 안 떠 있어도 `--lint` 로 골든셋 구조 검증 가능(오프라인).

사용:
  # 오프라인 구조검증 (LLM 불필요)
  python run_eval.py --lint

  # 단일 모델 평가 (vLLM 떠 있어야 함)
  python run_eval.py --base-url http://localhost:8001/v1 --model Qwen3-8B

  # 모델 사다리 (models.json 의 reachable 모델 전부)
  python run_eval.py --models models.json

  # 회귀 비교
  python run_eval.py --model Qwen3-8B --baseline results/qwen3-8b_xxx.json

채점은 production 프롬프트를 그대로 사용:
  - nl2sql  : worker/sql_prompt.SQL_SYSTEM_PROMPT (단일 소스)
  - 그 외   : 본 파일의 TOOL_ROUTING/GROUNDING/SCAFFOLD 프롬프트
              (= Phase 2 Verifier·Rule 코퍼스의 초안)
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import os
import re
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
MCP = HERE.parent
sys.path.insert(0, str(MCP / "worker"))

# production NL2SQL 프롬프트 (단일 소스). import 실패해도 lint 는 동작.
try:
    from sql_prompt import SQL_SYSTEM_PROMPT  # type: ignore
except Exception:  # pragma: no cover
    SQL_SYSTEM_PROMPT = None

# ── 평가용 프롬프트 (Phase 2 Verifier/Rule 초안) ──────────────────────────────

TOOL_LIST = [
    ("active_alarms", "현재 활성 알람/트립 조회"),
    ("query_events", "기간 이벤트 로그 조회"),
    ("summarize_events", "기간 이벤트 요약(특정 area, 어제/지난 N시간 등)"),
    ("generate_status_report", "교대 보고서/운전 상태 종합 보고"),
    ("find_tags", "태그 검색(어떤 태그가 있는지, 종류별 찾기)"),
    ("query_pv_history", "특정 태그 과거 PV 추이"),
    ("query_with_nl", "자연어로 임의 SQL 결과"),
    ("run_sql", "직접 SQL 실행"),
    ("search_kb", "절차서/매뉴얼/설계서 등 문서 검색"),
    ("trace_connections", "유체/공급/계통 경로 추적(어디서 공급, 흐름)"),
]
KNOWN_TOOLS = [t for t, _ in TOOL_LIST]

TOOL_ROUTING_PROMPT = (
    "너는 산업 플랜트 운전 어시스턴트의 의도 라우터다.\n"
    "사용자 질문에 가장 알맞은 도구 하나를 아래 목록에서 고른다.\n\n"
    + "\n".join(f"- {name}: {desc}" for name, desc in TOOL_LIST)
    + "\n\n규칙: 설명/문장 없이 도구 이름 하나만 정확히 출력하라(예: active_alarms)."
)

GROUNDING_PROMPT = (
    "너는 산업 플랜트 운전 어시스턴트다. 안전이 중요하므로 사실을 지어내지 않는다.\n"
    "원칙:\n"
    "- 주어진 컨텍스트/데이터에 있는 사실만 사용한다.\n"
    "- 근거가 없으면 '확인 불가' 또는 '정보 없음'이라고 답하고, 추정·창작하지 않는다.\n"
    "- description/값이 비어있으면 '없음/미등록'으로 표시하고 용도를 지어내지 않는다.\n"
    "- 존재하지 않는 태그/플랜트/설비에 대해서는 존재하지 않는다고 답한다.\n"
)

SCAFFOLD_PROMPT = (
    GROUNDING_PROMPT
    + "\n제어/상태 판단 질문은 반드시 아래 5단계를 이 순서의 라벨로 답하라:\n"
    "  제어변수: ...\n  현재값: ...\n  설정치: ...\n  제약: ...\n  판단: ...\n"
    "데이터가 없는 항목은 라벨을 유지하고 '미제공'이라고 쓴다.\n"
)

REFUSE_MARKERS = [
    "확인 불가", "판정 불가", "판단 불가", "정보 없음", "정보가 없", "데이터가 없", "데이터 없",
    "없습니다", "미등록", "모름", "알 수 없", "존재하지 않", "제공되지 않", "예측할 수 없",
    "예측은 불가", "확인할 수 없", "조회되지 않",
]

# ── 채점기 ────────────────────────────────────────────────────────────────────

def _norm(s: str) -> str:
    s = s or ""
    # 마크다운 펜스 제거
    s = re.sub(r"```[a-zA-Z]*", "", s).replace("```", "")
    return re.sub(r"\s+", " ", s).strip().lower()


def score_nl2sql(out: str, exp: dict) -> tuple[bool, str]:
    n = _norm(out)
    missing = [s for s in exp.get("sql_contains", []) if s.lower() not in n]
    bad = [s for s in exp.get("must_not", []) if s.lower() in n]
    ok = not missing and not bad
    detail = []
    if missing:
        detail.append(f"누락:{missing}")
    if bad:
        detail.append(f"금지포함:{bad}")
    return ok, "; ".join(detail) or "ok"


def score_tool(out: str, exp: dict) -> tuple[bool, str]:
    n = _norm(out)
    picked = next((t for t in KNOWN_TOOLS if t in n), "")
    targets = exp.get("tool_any") or ([exp["tool"]] if "tool" in exp else [])
    ok = picked in [t.lower() for t in targets]
    return ok, f"선택={picked or '(없음)'} 기대={targets}"


def score_abstain(out: str, exp: dict) -> tuple[bool, str]:
    n = _norm(out)
    refused = any(m.lower() in n for m in REFUSE_MARKERS)
    fabricated = [s for s in exp.get("must_not", []) if s.lower() in n]
    ok = refused and not fabricated
    detail = []
    if not refused:
        detail.append("거부표현 없음(헛소리 위험)")
    if fabricated:
        detail.append(f"창작포함:{fabricated}")
    return ok, "; ".join(detail) or "거부 ok"


def score_scaffold(out: str, exp: dict) -> tuple[bool, str]:
    n = _norm(out)
    steps = [s.lower() for s in exp.get("steps", [])]
    pos, last, missing, order_ok = [], -1, [], True
    for s in steps:
        i = n.find(s)
        if i < 0:
            missing.append(s)
        else:
            if exp.get("order") and i < last:
                order_ok = False
            last = i
            pos.append(s)
    ok = not missing and order_ok
    detail = []
    if missing:
        detail.append(f"누락단계:{missing}")
    if not order_ok:
        detail.append("순서 어긋남")
    return ok, "; ".join(detail) or "절차 ok"


def score_grounding(out: str, exp: dict) -> tuple[bool, str]:
    n = _norm(out)
    missing = [s for s in exp.get("answer_contains", []) if s.lower() not in n]
    ok = not missing
    return ok, (f"누락:{missing}" if missing else "ok")


SCORERS = {
    "nl2sql": score_nl2sql,
    "tool_call": score_tool,
    "abstain": score_abstain,
    "scaffold": score_scaffold,
    "grounding": score_grounding,
}

# ── 프롬프트 빌드 ─────────────────────────────────────────────────────────────

def build_messages(item: dict) -> list[dict]:
    cat, q = item["category"], item["question"]
    ctx = item.get("context", "")
    user = (f"[컨텍스트]\n{ctx}\n\n[질문]\n{q}" if ctx else q)
    if cat == "nl2sql":
        if not SQL_SYSTEM_PROMPT:
            raise RuntimeError("sql_prompt.SQL_SYSTEM_PROMPT import 실패")
        return [{"role": "system", "content": SQL_SYSTEM_PROMPT}, {"role": "user", "content": q}]
    if cat == "tool_call":
        return [{"role": "system", "content": TOOL_ROUTING_PROMPT}, {"role": "user", "content": q}]
    if cat == "scaffold":
        return [{"role": "system", "content": SCAFFOLD_PROMPT}, {"role": "user", "content": user}]
    # abstain, grounding
    return [{"role": "system", "content": GROUNDING_PROMPT}, {"role": "user", "content": user}]

# ── 골든셋 로드 + lint ────────────────────────────────────────────────────────

REQUIRED_EXPECT = {
    "nl2sql": ["sql_contains"],
    "tool_call": [],  # tool 또는 tool_any
    "abstain": ["refuse"],
    "scaffold": ["steps"],
    "grounding": ["answer_contains"],
}


def load_golden(path: Path) -> list[dict]:
    items = []
    for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
        line = line.strip()
        if not line:
            continue
        try:
            items.append(json.loads(line))
        except json.JSONDecodeError as e:
            raise SystemExit(f"[lint] {path.name}:{i} JSON 파싱 실패: {e}")
    return items


def lint(items: list[dict]) -> int:
    errs, counts = [], {}
    ids = set()
    for it in items:
        cat = it.get("category")
        counts[cat] = counts.get(cat, 0) + 1
        for f in ("id", "category", "question", "expect"):
            if f not in it:
                errs.append(f"{it.get('id','?')}: 필드 누락 '{f}'")
        if it.get("id") in ids:
            errs.append(f"중복 id: {it['id']}")
        ids.add(it.get("id"))
        if cat not in SCORERS:
            errs.append(f"{it.get('id')}: 알 수 없는 category '{cat}'")
            continue
        exp = it.get("expect", {})
        for req in REQUIRED_EXPECT[cat]:
            if req not in exp:
                errs.append(f"{it.get('id')}: expect.{req} 누락")
        if cat == "tool_call" and not (exp.get("tool") or exp.get("tool_any")):
            errs.append(f"{it.get('id')}: tool 또는 tool_any 필요")
        if cat == "tool_call":
            for t in (exp.get("tool_any") or [exp.get("tool")]):
                if t and t not in KNOWN_TOOLS:
                    errs.append(f"{it.get('id')}: 미지의 도구 '{t}'")
    print(f"골든셋 {len(items)}문항  " + "  ".join(f"{k}={v}" for k, v in sorted(counts.items())))
    if errs:
        print("\n".join("  ✗ " + e for e in errs))
        return 1
    print("✓ lint 통과")
    return 0

# ── 모델 호출 + 실행 ──────────────────────────────────────────────────────────

def make_client(base_url: str):
    from openai import OpenAI
    return OpenAI(base_url=base_url, api_key=os.environ.get("VLLM_API_KEY", "dummy"))


def run_model(name: str, base_url: str, model: str, items: list[dict], args) -> dict:
    client = make_client(base_url)
    results, cat_pass, cat_tot = [], {}, {}
    print(f"\n=== [{name}] {model} @ {base_url} ===")
    for it in items:
        cat = it["category"]
        cat_tot[cat] = cat_tot.get(cat, 0) + 1
        try:
            kw = dict(model=model, messages=build_messages(it),
                      max_tokens=args.max_tokens, temperature=args.temperature)
            if args.seed is not None:
                kw["seed"] = args.seed
            if args.no_think:
                kw["extra_body"] = {"chat_template_kwargs": {"enable_thinking": False}}
            resp = client.chat.completions.create(**kw)
            out = resp.choices[0].message.content or ""
            ok, detail = SCORERS[cat](out, it["expect"])
        except Exception as e:
            ok, detail, out = False, f"ERROR: {e}", ""
        cat_pass[cat] = cat_pass.get(cat, 0) + (1 if ok else 0)
        results.append({"id": it["id"], "category": cat, "passed": ok, "detail": detail})
        print(f"  [{'PASS' if ok else 'FAIL'}] {it['id']:<12} {detail}")

    total = len(results)
    passed = sum(r["passed"] for r in results)
    ab_tot = cat_tot.get("abstain", 0)
    ab_pass = cat_pass.get("abstain", 0)
    fab_rate = round((ab_tot - ab_pass) / ab_tot, 3) if ab_tot else None
    per_cat = {c: {"pass": cat_pass.get(c, 0), "total": cat_tot[c],
                   "pct": round(cat_pass.get(c, 0) / cat_tot[c], 3)} for c in sorted(cat_tot)}
    print("  ── 카테고리별 ──")
    for c, v in per_cat.items():
        print(f"    {c:<11} {v['pass']:>2}/{v['total']:<2}  {v['pct']*100:5.1f}%")
    print(f"  전체 {passed}/{total} ({passed/total*100:.1f}%)   "
          f"fabrication_rate={fab_rate if fab_rate is not None else 'n/a'}")
    return {"name": name, "model": model, "base_url": base_url,
            "timestamp": dt.datetime.now().isoformat(timespec="seconds"),
            "overall": {"pass": passed, "total": total, "pct": round(passed / total, 3)},
            "fabrication_rate": fab_rate, "per_category": per_cat, "items": results}


def diff_baseline(cur: dict, baseline_path: Path):
    base = json.loads(baseline_path.read_text(encoding="utf-8"))
    base_map = {r["id"]: r["passed"] for r in base.get("items", [])}
    regr = [r["id"] for r in cur["items"] if base_map.get(r["id"]) and not r["passed"]]
    fixed = [r["id"] for r in cur["items"] if base_map.get(r["id"]) is False and r["passed"]]
    print(f"\n── 회귀 비교 vs {baseline_path.name} ──")
    print(f"  회귀(PASS→FAIL): {regr or '없음'}")
    print(f"  개선(FAIL→PASS): {fixed or '없음'}")
    return 1 if regr else 0


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--golden", default=str(HERE / "golden.jsonl"))
    ap.add_argument("--models", help="모델 사다리 JSON (models.json)")
    ap.add_argument("--base-url", default=os.environ.get("VLLM_BASE_URL", "http://localhost:8001/v1"))
    ap.add_argument("--model", default=os.environ.get("VLLM_MODEL", ""))
    ap.add_argument("--out", default=str(HERE / "results"))
    ap.add_argument("--baseline", help="회귀 비교용 이전 결과 JSON")
    ap.add_argument("--categories", help="콤마구분 카테고리 필터")
    ap.add_argument("--limit", type=int, default=0, help="앞에서 N문항만")
    ap.add_argument("--max-tokens", type=int, default=1024)
    ap.add_argument("--temperature", type=float, default=0.0)
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--lint", action="store_true", help="LLM 없이 골든셋 구조검증만")
    ap.add_argument("--no-think", action="store_true", help="Qwen3 등 thinking 모델: enable_thinking=false")
    args = ap.parse_args()

    items = load_golden(Path(args.golden))
    if args.categories:
        cats = set(args.categories.split(","))
        items = [it for it in items if it.get("category") in cats]
    if args.limit:
        items = items[: args.limit]

    if args.lint:
        return lint(items)

    rc = lint(items)
    if rc:
        print("lint 실패 — 평가 중단")
        return rc

    # 모델 목록 결정
    if args.models:
        cfg = json.loads(Path(args.models).read_text(encoding="utf-8"))
        rungs = cfg["models"]
    elif args.model:
        rungs = [{"name": args.model, "base_url": args.base_url, "model": args.model}]
    else:
        print("모델 미지정: --model 또는 --models 필요 (또는 VLLM_MODEL 환경변수)")
        return 2

    outdir = Path(args.out)
    outdir.mkdir(parents=True, exist_ok=True)
    summaries, rc = [], 0
    for r in rungs:
        res = run_model(r["name"], r.get("base_url", args.base_url), r["model"], items, args)
        summaries.append(res)
        stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        fp = outdir / f"{r['name']}_{stamp}.json"
        fp.write_text(json.dumps(res, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"  → 저장 {fp}")
        if args.baseline:
            rc |= diff_baseline(res, Path(args.baseline))

    if len(summaries) > 1:
        print("\n=== 모델 사다리 비교 (합격선 넘는 最小 모델 선택) ===")
        print(f"  {'model':<22} {'overall':>8} {'fab_rate':>9}  per-category")
        for s in summaries:
            pc = " ".join(f"{c}:{v['pct']*100:.0f}%" for c, v in s["per_category"].items())
            print(f"  {s['name']:<22} {s['overall']['pct']*100:6.1f}% "
                  f"{(s['fabrication_rate'] if s['fabrication_rate'] is not None else 0)*100:7.1f}%  {pc}")
    return rc


if __name__ == "__main__":
    raise SystemExit(main())
