"""
eval runner 스크립트 — TDD GREEN 단계 구현
각 스킬의 evals.json을 로드하고 LLM 응답을 평가합니다.
"""

import argparse
import json
import os
import re
import subprocess
import time
from pathlib import Path

SKILLS_BASE_PATH = "/home/jay/.claude/skills"

# ---------------------------------------------------------------------------
# 키워드 추출 상수
# ---------------------------------------------------------------------------

# 한글 조사 패턴 (단어 끝에서 제거)
_JOSA_PATTERN = (
    r"(을|를|이|가|은|는|에서|에게|에게서|으로부터|으로|로부터|로|과|와|의"
    r"|도|만|까지|부터|한테|서|마저|조차|밖에|이라도|라도|이나|나|이든|든)$"
)

# 동사/형용사 어미로 끝나는 단어 필터 패턴
_VERB_ENDING_PATTERN = (
    r"(야|다|요|습니다|겠습니다|합니다|있습니다|없습니다|해야|해서|하고"
    r"|하며|하여|하는|되는|된다|된|이며|통해야|포함되어야|준수하며)$"
)

# 핵심 키워드에서 제외할 부사/수량어
_EXCLUDED_WORDS = frozenset(
    {
        "최소",
        "최대",
        "모두",
        "항상",
        "반드시",
        "꼭",
        "적어도",
        "최소한",
        "이상",
        "이하",
        "이내",
        "미만",
        "초과",
        "고루",
        "독립적으로",
    }
)

# 단정적 표현 (보험업법 위반 가능)
_ABSOLUTE_EXPRESSIONS = ["무조건", "반드시", "100%", "절대", "확실히"]

# 키워드 매칭 통과 임계값 (30% 이상이면 pass)
_KEYWORD_PASS_THRESHOLD = 0.30


# ---------------------------------------------------------------------------
# 1. evals.json 로딩
# ---------------------------------------------------------------------------


def load_evals(skill_name: str) -> dict:  # type: ignore[type-arg]
    """
    {SKILLS_BASE_PATH}/{skill_name}/evals/evals.json 파일을 로드합니다.

    탐색 순서:
    1. {SKILLS_BASE_PATH}/{skill_name}/evals/evals.json
    2. {SKILLS_BASE_PATH}/skills/{skill_name}/evals/evals.json (fallback)

    Returns:
        {"skill_name": ..., "evals": [...]}

    Raises:
        ValueError: 파일이 존재하지 않거나 JSON 파싱 실패 시
        json.JSONDecodeError: JSON 파싱 실패 시
    """
    base = Path(SKILLS_BASE_PATH)
    candidate_paths = [
        base / skill_name / "evals" / "evals.json",
        base / "skills" / skill_name / "evals" / "evals.json",
    ]

    evals_path: Path | None = None
    for path in candidate_paths:
        if path.exists():
            evals_path = path
            break

    if evals_path is None:
        raise ValueError(
            f"evals.json not found for skill '{skill_name}'. " f"Tried: {[str(p) for p in candidate_paths]}"
        )

    with open(evals_path, encoding="utf-8") as f:
        data = json.load(f)
    return data  # type: ignore[no-any-return]


# ---------------------------------------------------------------------------
# 2. 키워드 매칭
# ---------------------------------------------------------------------------


def _extract_keywords(text: str) -> list[str]:
    """텍스트에서 핵심 키워드를 추출합니다.

    - 한글: 조사/어미 제거 후 2자 이상 명사 위주
    - 영문: 3자 이상 단어
    - 부사/수량어 제외
    """
    # 한글 단어 추출 후 정제
    raw_korean = re.findall(r"[가-힣]{2,}", text)
    cleaned: list[str] = []
    seen: set[str] = set()

    for word in raw_korean:
        # 조사 제거
        cleaned_word = re.sub(_JOSA_PATTERN, "", word)
        # 동사/형용사 어미 포함 단어 제외
        if re.search(_VERB_ENDING_PATTERN, cleaned_word):
            continue
        # 부사/수량어 제외
        if cleaned_word in _EXCLUDED_WORDS:
            continue
        if len(cleaned_word) >= 2 and cleaned_word not in seen:
            seen.add(cleaned_word)
            cleaned.append(cleaned_word)

    # 영문 단어 추출 (3자 이상)
    english_words = re.findall(r"[A-Za-z]{3,}", text)
    seen_lower: set[str] = {w.lower() for w in cleaned}
    for word in english_words:
        if word.lower() not in seen_lower:
            seen_lower.add(word.lower())
            cleaned.append(word)

    return cleaned


def keyword_match(response: str, expected: str) -> tuple[bool, list[str], list[str]]:
    """
    expected에서 핵심 키워드를 추출하고, response에서 키워드 존재 여부를 확인합니다.

    Args:
        response: LLM 응답 문자열
        expected: 기대 출력 설명 (키워드 추출 대상)

    Returns:
        (pass여부, 매칭된_키워드, 미매칭_키워드)
        키워드 30% 이상 매칭이면 pass
    """
    keywords = _extract_keywords(expected)
    if not keywords:
        return True, [], []

    response_lower = response.lower()
    matched: list[str] = []
    missed: list[str] = []

    for kw in keywords:
        if kw.lower() in response_lower:
            matched.append(kw)
        else:
            missed.append(kw)

    pass_rate = len(matched) / len(keywords)
    passed = pass_rate >= _KEYWORD_PASS_THRESHOLD
    return passed, matched, missed


# ---------------------------------------------------------------------------
# 3. 금지어/라우팅 체크
# ---------------------------------------------------------------------------


def _extract_routing_targets(boundary: str) -> list[str]:
    """boundary에서 '→ 스킬명' 또는 '-> 스킬명' 패턴의 라우팅 대상 목록을 추출합니다."""
    # → 또는 -> 다음에 오는 스킬명 (하이픈 포함 가능)
    return re.findall(r"(?:→|->)\s*([\w-]+)", boundary)


def check_forbidden(response: str, boundary: str) -> tuple[bool, list[str]]:
    """
    boundary에서 금지어/금지 패턴을 추출하고 response에서 감지합니다.

    규칙:
    1. boundary에 '→ 스킬명' 패턴이 있으면, 응답에 해당 스킬명이 언급되면 금지
    2. boundary에 명시된 단정적 표현이 응답에 있으면 금지

    Args:
        response: LLM 응답 문자열
        boundary: 경계 조건 문자열

    Returns:
        (pass여부, 발견된_금지어_목록)
    """
    found_forbidden: list[str] = []

    # 라우팅 대상 스킬명 추출 → 응답에 있으면 금지 (범위 밖 스킬 언급)
    routing_targets = _extract_routing_targets(boundary)
    for target in routing_targets:
        if target.lower() in response.lower():
            found_forbidden.append(target)

    # 단정적 표현 감지 (boundary에 명시된 경우)
    boundary_lower = boundary.lower()
    for expr in _ABSOLUTE_EXPRESSIONS:
        if expr in response and expr in boundary_lower:
            found_forbidden.append(expr)

    passed = len(found_forbidden) == 0
    return passed, found_forbidden


def check_routing(response: str, boundary: str) -> tuple[bool, str]:
    """
    boundary에 '→ 스킬명' 패턴이 있는 경우 응답의 라우팅 적절성을 확인합니다.

    Args:
        response: LLM 응답 문자열
        boundary: 경계 조건 문자열

    Returns:
        (pass여부, 판정사유)
    """
    routing_targets = _extract_routing_targets(boundary)

    if not routing_targets:
        return True, "라우팅 규칙 없음"

    response_lower = response.lower()

    # 위임 표현 패턴
    delegation_patterns = [
        "이용해주세요",
        "이용하세요",
        "담당합니다",
        "해당 스킬",
        "라우팅",
        "전달",
        "문의",
        "스킬을 이용",
        "utilize",
        "refer",
        "contact",
    ]

    for target in routing_targets:
        if target.lower() in response_lower:
            # 응답에 라우팅 대상이 언급 → 위임 표현도 있으면 pass
            has_delegation = any(p in response for p in delegation_patterns)
            if has_delegation:
                return True, f"{target} 스킬로 올바르게 위임함"
            else:
                return False, f"{target} 역할을 직접 수행함 (위임 표현 없음)"

    # 응답에 라우팅 대상 스킬명이 없는 경우
    # 구체적 업무 수행 여부를 휴리스틱으로 판단
    # (숫자+단위 + 전략 지시어 패턴)
    budget_pattern = re.search(r"\d+[원만억%]|\d+\s*(CPC|CPM|ROAS|CTR)", response)
    strategy_keywords = ["예산", "전략", "타겟팅", "최적", "설정하세요", "배분", "타겟은"]
    has_strategy = any(kw in response for kw in strategy_keywords)

    if budget_pattern and has_strategy:
        target_str = ", ".join(routing_targets)
        return False, f"{target_str} 영역의 업무를 직접 수행함"

    return True, "범위 내 응답"


# ---------------------------------------------------------------------------
# 4. 통합 평가
# ---------------------------------------------------------------------------


def evaluate_response(response: str, eval_case: dict) -> dict:  # type: ignore[type-arg]
    """
    keyword_match + check_forbidden 통합 판정

    Args:
        response: LLM 응답 문자열
        eval_case: eval 케이스 dict (id, prompt, expected_output, assertions, files)

    Returns:
        {"passed": bool, "eval_id": int, "details": {...}}
    """
    eval_id = eval_case.get("id", 0)
    expected_output = eval_case.get("expected_output", "")
    assertions = eval_case.get("assertions", [])

    # 키워드 매칭
    kw_passed, matched, missed = keyword_match(response, expected_output)

    # assertions에서 boundary 역할 추출
    boundary = " ".join(assertions)
    forbidden_passed, found_forbidden = check_forbidden(response, boundary)

    overall_passed = kw_passed and forbidden_passed

    details: dict = {  # type: ignore[type-arg]
        "keyword_match": {
            "passed": kw_passed,
            "matched": matched,
            "missed": missed,
        },
        "forbidden_check": {
            "passed": forbidden_passed,
            "found_forbidden": found_forbidden,
        },
    }

    return {
        "passed": overall_passed,
        "eval_id": eval_id,
        "details": details,
    }


# ---------------------------------------------------------------------------
# 5. 스킬 목록 조회
# ---------------------------------------------------------------------------


def get_skill_list() -> list[str]:
    """
    SKILLS_BASE_PATH 하위에서 evals/evals.json이 있는 스킬 목록을 반환합니다.
    """
    base = Path(SKILLS_BASE_PATH)
    if not base.exists():
        return []

    skills: list[str] = []
    for skill_dir in sorted(base.iterdir()):
        if skill_dir.is_dir():
            evals_file = skill_dir / "evals" / "evals.json"
            if evals_file.exists():
                skills.append(skill_dir.name)
    return skills


# ---------------------------------------------------------------------------
# 6. 보고서 생성
# ---------------------------------------------------------------------------


def generate_report(results: list[dict], output_path: str) -> str:  # type: ignore[type-arg]
    """
    결과를 JSON 문자열로 반환하고 output_path에 파일로 저장합니다.

    Args:
        results: 평가 결과 목록
        output_path: 출력 파일 경로

    Returns:
        JSON 문자열
    """
    skill_stats: dict[str, dict] = {}  # type: ignore[type-arg]
    failed_cases: list[dict] = []  # type: ignore[type-arg]

    for result in results:
        skill_name = result.get("skill_name", "unknown")
        if skill_name not in skill_stats:
            skill_stats[skill_name] = {"total": 0, "passed": 0}
        skill_stats[skill_name]["total"] += 1
        if result.get("passed"):
            skill_stats[skill_name]["passed"] += 1
        else:
            failed_cases.append(result)

    skill_results: dict[str, dict] = {}  # type: ignore[type-arg]
    for skill_name, stats in skill_stats.items():
        total = stats["total"]
        passed = stats["passed"]
        skill_results[skill_name] = {
            "pass_rate": passed / total if total > 0 else 0.0,
            "total": total,
            "passed": passed,
        }

    total_evals = len(results)
    total_passed = sum(1 for r in results if r.get("passed"))
    total_pass_rate = total_passed / total_evals if total_evals > 0 else 0.0

    report_data: dict = {  # type: ignore[type-arg]
        "skill_results": skill_results,
        "total_pass_rate": total_pass_rate,
        "total_evals": total_evals,
        "total_passed": total_passed,
        "failed_cases": failed_cases,
    }

    report_json = json.dumps(report_data, ensure_ascii=False, indent=2)

    with open(output_path, "w", encoding="utf-8") as f:
        f.write(report_json)

    return report_json


# ---------------------------------------------------------------------------
# 7. LLM 호출
# ---------------------------------------------------------------------------


def call_llm(prompt: str) -> str:
    """
    Claude CLI를 통해 LLM을 호출합니다. (OAuth 인증 사용)

    Args:
        prompt: 사용자 프롬프트

    Returns:
        LLM 응답 문자열

    Raises:
        RuntimeError: claude CLI 호출 실패 시
    """
    time.sleep(1)  # rate limit 고려

    result = subprocess.run(
        ["claude", "-p", prompt, "--model", "claude-haiku-4-5-20251001"],
        capture_output=True,
        text=True,
        timeout=120,
    )

    if result.returncode != 0:
        raise RuntimeError(f"claude CLI 호출 실패: {result.stderr[:500]}")

    return result.stdout.strip()


# ---------------------------------------------------------------------------
# 8. 스킬 eval 실행
# ---------------------------------------------------------------------------


def run_evals_for_skill(
    skill_name: str, dry_run: bool = False, verbose: bool = False
) -> dict:  # type: ignore[type-arg]
    """
    특정 스킬의 eval을 실행합니다.

    Args:
        skill_name: 스킬 이름
        dry_run: True이면 API 호출 없이 구조 검증만 수행
        verbose: 상세 출력 여부

    Returns:
        dry_run=True이면 {"dry_run": True, "eval_count": N, "skill_name": ...}
        dry_run=False이면 평가 결과 dict
    """
    evals_data = load_evals(skill_name)
    evals = evals_data.get("evals", [])

    if dry_run:
        if verbose:
            print(f"[dry-run] {skill_name}: {len(evals)}개 eval 케이스 발견")
        return {
            "dry_run": True,
            "eval_count": len(evals),
            "skill_name": skill_name,
        }

    results: list[dict] = []  # type: ignore[type-arg]
    for eval_case in evals:
        prompt = eval_case.get("prompt", "")
        if verbose:
            print(f"[{skill_name}] eval #{eval_case.get('id', '?')} 실행 중...")

        response = call_llm(prompt)
        result = evaluate_response(response, eval_case)
        result["skill_name"] = skill_name
        results.append(result)

        if verbose:
            status = "PASS" if result["passed"] else "FAIL"
            print(f"  → {status}")

    total = len(results)
    passed = sum(1 for r in results if r.get("passed"))
    pass_rate = passed / total if total > 0 else 0.0

    return {
        "skill_name": skill_name,
        "results": results,
        "total": total,
        "passed": passed,
        "pass_rate": pass_rate,
    }


# ---------------------------------------------------------------------------
# 9. CLI 인터페이스
# ---------------------------------------------------------------------------


def create_argument_parser() -> argparse.ArgumentParser:
    """CLI 인수 파서를 생성합니다."""
    parser = argparse.ArgumentParser(description="eval runner — 스킬 eval을 실행하고 결과를 평가합니다.")
    parser.add_argument(
        "--skill",
        metavar="SKILL_NAME",
        help="단일 스킬 실행",
    )
    parser.add_argument(
        "--all",
        action="store_true",
        default=False,
        help="전체 스킬 실행",
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        default=False,
        help="상세 출력",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        default=False,
        dest="dry_run",
        help="API 호출 없이 구조 검증만 수행",
    )
    return parser


def validate_skill_name(name: str) -> None:
    """
    스킬명의 유효성을 검사합니다.

    Args:
        name: 스킬 이름

    Raises:
        ValueError: 유효하지 않은 스킬명이면
    """
    skill_list = get_skill_list()
    if name not in skill_list:
        raise ValueError(f"'{name}'은(는) 유효하지 않은 스킬명입니다. " f"사용 가능한 스킬: {skill_list}")


def main() -> None:
    """CLI 진입점."""
    parser = create_argument_parser()
    args = parser.parse_args()

    if not args.skill and not args.all:
        parser.print_help()
        return

    skills_to_run: list[str] = []

    if args.all:
        skills_to_run = get_skill_list()
        if not skills_to_run:
            print("실행 가능한 스킬이 없습니다.")
            return
        if args.verbose:
            print(f"전체 스킬 {len(skills_to_run)}개 실행: {skills_to_run}")
    elif args.skill:
        try:
            validate_skill_name(args.skill)
        except ValueError as e:
            print(f"오류: {e}")
            return
        skills_to_run = [args.skill]

    all_results: list[dict] = []  # type: ignore[type-arg]

    for skill_name in skills_to_run:
        print(f"\n{'=' * 60}")
        print(f"스킬: {skill_name}")
        print("=" * 60)

        result = run_evals_for_skill(skill_name, dry_run=args.dry_run, verbose=args.verbose)
        print(json.dumps(result, ensure_ascii=False, indent=2))

        if not args.dry_run:
            for r in result.get("results", []):
                all_results.append(r)

    if not args.dry_run and all_results:
        import tempfile

        with tempfile.NamedTemporaryFile(
            mode="w",
            suffix=".json",
            prefix="eval_report_",
            delete=False,
            encoding="utf-8",
        ) as f:
            report_path = f.name

        report = generate_report(all_results, report_path)
        print(f"\n{'=' * 60}")
        print("전체 결과 보고서")
        print("=" * 60)
        print(report)
        print(f"\n보고서 저장: {report_path}")


if __name__ == "__main__":
    main()
