"""Assertion evaluators. Five types, all deterministic — docs/04-task-suite.md.

No LLM judge anywhere in this module, by design (ADR-0004): every check here
is something a human could run by hand and get the same answer.
"""

from __future__ import annotations

import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

from ablate.gitutil import diff_name_only, diff_text
from ablate.task import Assertion


@dataclass
class AssertionResult:
    id: str
    type: str
    passed: bool
    required: bool = True
    detail: str = ""
    value: object | None = None  # continuous metric from a `script` assertion


def _glob_to_regex(pattern: str) -> re.Pattern[str]:
    """Translate a glob with '**' support to a regex.

    fnmatch's '*' matches '/', which makes "tests/**" behave the same as
    "tests/*" — wrong for the nested case these assertions exist to catch.
    """
    out = []
    i = 0
    while i < len(pattern):
        c = pattern[i]
        if pattern[i : i + 2] == "**":
            out.append(".*")
            i += 2
        elif c == "*":
            out.append("[^/]*")
            i += 1
        elif c == "?":
            out.append("[^/]")
            i += 1
        else:
            out.append(re.escape(c))
            i += 1
    return re.compile(f"^{''.join(out)}$")


def _matches_any(path: str, patterns: list[str]) -> bool:
    return any(_glob_to_regex(p).match(path) for p in patterns)


def _run_cmd(cmd: str, cwd: Path, timeout_s: int) -> subprocess.CompletedProcess[str]:
    try:
        return subprocess.run(
            cmd,
            shell=True,
            cwd=cwd,
            capture_output=True,
            text=True,
            timeout=timeout_s,
        )
    except subprocess.TimeoutExpired as e:
        return subprocess.CompletedProcess(
            args=cmd, returncode=124, stdout=e.stdout or "", stderr=f"timed out after {timeout_s}s"
        )


def _eval_exit_code(a: Assertion, worktree: Path) -> AssertionResult:
    assert a.run is not None
    proc = _run_cmd(a.run, worktree, a.timeout_s)
    passed = proc.returncode == a.expect
    detail = "" if passed else f"exit {proc.returncode}, expected {a.expect}\n{proc.stderr[-2000:]}"
    return AssertionResult(a.id, a.type, passed, a.required, detail)


def _eval_files_touched(a: Assertion, worktree: Path, base_commit: str) -> AssertionResult:
    touched = diff_name_only(worktree, base_commit)
    missing = [p for p in a.must_include if not any(_glob_to_regex(p).match(f) for f in touched)]
    forbidden = [f for f in touched if a.must_not_include and _matches_any(f, a.must_not_include)]
    passed = not missing and not forbidden
    parts = []
    if missing:
        parts.append(f"missing required paths: {missing}")
    if forbidden:
        parts.append(f"touched forbidden paths: {forbidden}")
    return AssertionResult(a.id, a.type, passed, a.required, "; ".join(parts))


def _eval_present_absent(a: Assertion, worktree: Path, base_commit: str) -> AssertionResult:
    assert a.pattern is not None
    rx = re.compile(a.pattern, re.MULTILINE)

    if a.in_ == "diff":
        haystack = diff_text(worktree, base_commit)
    else:
        chunks = []
        for pattern in a.paths or ["**/*"]:
            for f in worktree.glob(pattern):
                if f.is_file():
                    try:
                        chunks.append(f.read_text(errors="ignore"))
                    except OSError:
                        pass
        haystack = "\n".join(chunks)

    found = rx.search(haystack) is not None
    want_present = a.type == "present"
    passed = found if want_present else not found
    detail = "" if passed else f"pattern {a.pattern!r} {'not found' if want_present else 'found'}"
    return AssertionResult(a.id, a.type, passed, a.required, detail)


def _eval_script(a: Assertion, worktree: Path, base_commit: str) -> AssertionResult:
    import os
    import tempfile

    assert a.run is not None
    with tempfile.NamedTemporaryFile("w", suffix=".diff", delete=False) as f:
        f.write(diff_text(worktree, base_commit))
        diff_path = f.name
    try:
        env = {
            **os.environ,
            "ABLATE_TRIAL_DIR": str(worktree),
            "ABLATE_BASE_COMMIT": base_commit,
            "ABLATE_DIFF_FILE": diff_path,
        }
        proc = subprocess.run(
            a.run, shell=True, cwd=worktree, capture_output=True, text=True,
            timeout=a.timeout_s, env=env,
        )
    finally:
        Path(diff_path).unlink(missing_ok=True)

    passed = proc.returncode == a.expect
    value = None
    if a.capture_json and proc.stdout.strip():
        try:
            value = json.loads(proc.stdout)
        except json.JSONDecodeError:
            passed = False
            return AssertionResult(
                a.id, a.type, passed, a.required, "capture_json set but stdout was not valid JSON"
            )
    detail = "" if passed else f"exit {proc.returncode}, expected {a.expect}"
    return AssertionResult(a.id, a.type, passed, a.required, detail, value)


def evaluate(a: Assertion, worktree: Path, base_commit: str) -> AssertionResult:
    if a.type in ("exit_code", "lint"):
        return _eval_exit_code(a, worktree)
    if a.type == "files_touched":
        return _eval_files_touched(a, worktree, base_commit)
    if a.type in ("present", "absent"):
        return _eval_present_absent(a, worktree, base_commit)
    if a.type == "script":
        return _eval_script(a, worktree, base_commit)
    raise ValueError(f"unknown assertion type {a.type!r}")
