"""`project.json.qaCommands` 검증 헬퍼.

implementation phase 의 verifier QA gate 는 plan 의 `validation` 셋(Tier 1) 과
project-wide baseline 인 `qaCommands`(Tier 2) 를 함께 실행한다. Tier 2 는
사용자가 `project.json` 에 직접 선언하며, mutation 을 유발하는 토큰이 포함된
명령을 미리 차단해야 verifier 가 read-only 계약을 깨지 않는다.

본 모듈은 두 가지 책임을 갖는다.

1. `cmd` 문자열에서 mutation 유발 토큰을 검출 (`validate_qa_cmd`).
2. `qaCommands` 블록 전체를 순회하며 모든 위반을 모은다 (`validate_qa_commands`).

본 모듈은 런타임 검증에만 사용된다. 런타임 외 (verifier 가 실제 실행 단계에서
self-enforce 하는 측면) 의 계약은 `prompts/profiles/implementation.md` 의
"Two-tier command lookup" 단락에 명문화돼 있다.
"""
from __future__ import annotations

import re
from typing import Iterable

# 카테고리 화이트리스트. 알 수 없는 카테고리는 오타 가능성이 높으므로 거부.
# `db-test` 는 DB/IO/SQL 변경의 실제 DB(또는 충실한 복제) 실행 테스트 전용 카테고리 —
# mocked 단위테스트로는 query builder 가 실제로 emit 하는 SQL 을 관측할 수 없으므로
# `test` 와 분리한다. implementation verifier / final-verification 의 DB 실제실행 게이트가
# diff 가 DB 를 건드릴 때 이 카테고리(또는 plan validation 의 db 스텝)를 요구한다.
ALLOWED_CATEGORIES: tuple[str, ...] = ("lint", "format", "typecheck", "test", "db-test")

# Mutation 을 유발하거나 lockfile 을 갱신하는 토큰. 각 토큰은 `cmd` 문자열을
# 공백으로 단순 분해한 결과 또는 부분 일치 패턴(prefix/suffix sensitive) 로 검출한다.
# 새로운 도구를 추가할 때마다 한 줄씩 늘려가는 것이 정상 — 정규식 흑마법 금지.
_DENIED_LITERAL_TOKENS: tuple[str, ...] = (
    "--fix",
    "--write",
    "-w",  # gofmt -w, prettier -w
    "-u",  # jest -u
    "--updateSnapshot",
    "--update-snapshot",
    "--snapshot-update",
    "--update-goldens",
    "--update-golden",
)

# 공백 분해로는 잡기 어려운 패턴 (substring 검사로 잡는다).
_DENIED_SUBSTRINGS: tuple[str, ...] = (
    "cargo insta accept",
    "cargo update",
    "pip install -U",
    "pip install --upgrade",
    "pnpm add",
    "bun add",
    "cargo add",
)


def _has_npm_install_without_ci(cmd: str) -> bool:
    """`npm install` 은 lockfile mutation 위험이라 거부, `npm ci` 는 허용.

    부분 문자열 매칭에서 `npm install` 이 잡히면, 그 뒤에 오는 토큰 시퀀스가
    `npm ci` 의 변종이 아닌 한 항상 거부.
    """
    # 단순화: 정확히 `npm install` (또는 `npm i`) 가 등장하는지 검사. `ci` 는 별개
    # 서브커맨드라 `npm ci` 는 이 정규식에 걸리지 않는다.
    return re.search(r"\bnpm\s+(install|i)\b", cmd) is not None


def _has_insta_update_set(cmd: str) -> bool:
    """`INSTA_UPDATE=<value>` 에서 value 가 `no` 가 아닌 경우 거부."""
    match = re.search(r"\bINSTA_UPDATE=([A-Za-z0-9_-]+)", cmd)
    if match is None:
        return False
    return match.group(1).lower() != "no"


def find_denied_tokens(cmd: str) -> list[str]:
    """`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
    if not isinstance(cmd, str):
        return ["<not-a-string>"]
    found: list[str] = []
    tokens = cmd.split()
    for tok in _DENIED_LITERAL_TOKENS:
        if tok in tokens:
            found.append(tok)
    for sub in _DENIED_SUBSTRINGS:
        if sub in cmd:
            found.append(sub)
    if _has_npm_install_without_ci(cmd):
        found.append("npm install (use 'npm ci' instead)")
    if _has_insta_update_set(cmd):
        found.append("INSTA_UPDATE=<not-no>")
    return found


class QaCommandsError(ValueError):
    """`qaCommands` 블록이 계약을 어긴 경우 발생."""


def validate_qa_cmd(cmd: str, *, label: str = "<unnamed>", category: str = "<uncategorised>") -> None:
    """단일 `cmd` 문자열을 검사. 위반이 있으면 `QaCommandsError`.

    `label` / `category` 는 에러 메시지를 사람이 읽을 수 있게 하는 데만 쓰인다.
    """
    denied = find_denied_tokens(cmd)
    if denied:
        joined = ", ".join(denied)
        raise QaCommandsError(
            f"qaCommands.{category}[{label!r}] contains mutation token(s): {joined}. "
            f"Re-declare in check-only form."
        )


def validate_qa_commands(qa: object) -> list[str]:
    """`qaCommands` 블록 전체를 검증. 위반 메시지 리스트를 반환 (비면 안전).

    런타임이 fail-fast 하려면 반환값이 비어있지 않을 때 `PrepareError` 로 승격.
    여기서는 raise 하지 않고 메시지를 모아서 호출자가 일괄 보고할 수 있게 한다.
    """
    errors: list[str] = []
    if qa is None:
        return errors  # 옵션 필드 — 미선언은 합법.
    if not isinstance(qa, dict):
        return [f"qaCommands must be an object, got {type(qa).__name__}"]
    for category, entries in qa.items():
        if category not in ALLOWED_CATEGORIES:
            errors.append(
                f"qaCommands.{category}: unknown category "
                f"(allowed: {', '.join(ALLOWED_CATEGORIES)})"
            )
            continue
        if not isinstance(entries, list):
            errors.append(
                f"qaCommands.{category} must be an array, got {type(entries).__name__}"
            )
            continue
        for idx, entry in enumerate(entries):
            if not isinstance(entry, dict):
                errors.append(
                    f"qaCommands.{category}[{idx}] must be an object, got {type(entry).__name__}"
                )
                continue
            label = entry.get("label")
            cmd = entry.get("cmd")
            if not isinstance(label, str) or not label.strip():
                errors.append(
                    f"qaCommands.{category}[{idx}].label must be a non-empty string"
                )
            if not isinstance(cmd, str) or not cmd.strip():
                errors.append(
                    f"qaCommands.{category}[{idx}].cmd must be a non-empty string"
                )
                continue
            denied = find_denied_tokens(cmd)
            if denied:
                pretty_label = label if isinstance(label, str) else f"index {idx}"
                errors.append(
                    f"qaCommands.{category}[{pretty_label!r}] contains mutation token(s): "
                    f"{', '.join(denied)}. Re-declare in check-only form."
                )
    return errors


def format_errors(errors: Iterable[str]) -> str:
    """`PrepareError` 등에 그대로 박을 수 있는 멀티라인 문자열."""
    lines = list(errors)
    if not lines:
        return ""
    head = "qaCommands validation failed:"
    body = "\n".join(f"  - {line}" for line in lines)
    return f"{head}\n{body}"
