"""Chores criterion parsing, evaluation, and log writing.

Extracted from chores.py to stay under the 600-line module cap.
"""

from __future__ import annotations

# ---------------------------------------------------------------------------
# Criterion parsing helpers
# ---------------------------------------------------------------------------
import json
import shlex
import subprocess
from datetime import datetime
from pathlib import Path
from time import perf_counter
from typing import cast

from gzkit.commands.chores import (
    _CRITERION_TYPES,
    SHELL_OPERATORS_RE,
    AcceptanceCriterion,
    ChoreDefinition,
    CriterionResult,
)
from gzkit.commands.common import GzCliError


def _parse_command_to_argv(
    command: str,
    context: str,
    blockers: list[str],
) -> tuple[str, ...]:
    """Parse a shell command string into an argv tuple, rejecting shell operators."""
    if SHELL_OPERATORS_RE.search(command):
        blockers.append(
            f"{context}: command contains shell operators. Split into separate criteria instead."
        )
        return ()
    try:
        parts = shlex.split(command)
    except ValueError as exc:
        blockers.append(f"{context}: invalid command syntax: {exc}")
        return ()
    if not parts:
        blockers.append(f"{context}: command is empty after parsing.")
        return ()
    return tuple(parts)


def _parse_criterion(
    raw: object,
    index: int,
    chore_slug: str,
    blockers: list[str],
) -> AcceptanceCriterion | None:
    """Parse one acceptance criterion from acceptance.json."""
    context = f"chores[{chore_slug}].criteria[{index}]"
    if not isinstance(raw, dict):
        blockers.append(f"{context} must be an object.")
        return None
    data = cast("dict[str, object]", raw)

    criterion_type = data.get("type")
    if not isinstance(criterion_type, str) or criterion_type not in _CRITERION_TYPES:
        blockers.append(f"{context}.type must be one of: {', '.join(sorted(_CRITERION_TYPES))}.")
        return None

    desc_raw = data.get("description")
    description = desc_raw.strip() if isinstance(desc_raw, str) else None

    if criterion_type == "fileExists":
        path_raw = data.get("path")
        if not isinstance(path_raw, str) or not path_raw.strip():
            blockers.append(f"{context}.path must be a non-empty string for fileExists.")
            return None
        path = path_raw.strip()
        return AcceptanceCriterion(
            criterion_type=criterion_type,
            command=f"file-exists: {path}",
            argv=(),
            path=path,
            description=description,
        )

    command = data.get("command")
    if not isinstance(command, str) or not command.strip():
        blockers.append(f"{context}.command must be a non-empty string.")
        return None
    command = command.strip()

    argv = _parse_command_to_argv(command, context, blockers)
    if not argv:
        return None

    expected: int | None = None
    not_contains: str | None = None
    contains: str | None = None

    if criterion_type == "exitCodeEquals":
        expected_raw = data.get("expected")
        if not isinstance(expected_raw, int):
            blockers.append(f"{context}.expected must be an integer for exitCodeEquals.")
            return None
        expected = expected_raw

    elif criterion_type == "outputNotContains":
        nc_raw = data.get("notContains")
        if not isinstance(nc_raw, str) or not nc_raw:
            blockers.append(f"{context}.notContains must be a non-empty string.")
            return None
        not_contains = nc_raw

    elif criterion_type == "outputContains":
        c_raw = data.get("contains")
        if not isinstance(c_raw, str) or not c_raw:
            blockers.append(f"{context}.contains must be a non-empty string.")
            return None
        contains = c_raw

    return AcceptanceCriterion(
        criterion_type=criterion_type,
        command=command,
        argv=argv,
        expected=expected,
        not_contains=not_contains,
        contains=contains,
        description=description,
    )


def _load_acceptance(
    chore_dir: Path,
    chore_slug: str,
    blockers: list[str],
) -> tuple[AcceptanceCriterion, ...]:
    """Load and validate acceptance.json from a resolved chore directory."""
    acceptance_file = chore_dir / "acceptance.json"
    if not acceptance_file.is_file():
        blockers.append(f"Missing acceptance.json for chore '{chore_slug}': {acceptance_file}")
        return ()

    try:
        payload = json.loads(acceptance_file.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        blockers.append(f"Invalid JSON in {acceptance_file}: {exc.msg}")
        return ()

    if not isinstance(payload, dict):
        blockers.append(f"{acceptance_file} root must be a JSON object.")
        return ()

    criteria_raw = payload.get("criteria")
    if not isinstance(criteria_raw, list) or not criteria_raw:
        blockers.append(f"{acceptance_file} must have a non-empty 'criteria' array.")
        return ()

    parsed: list[AcceptanceCriterion] = []
    for idx, raw_criterion in enumerate(criteria_raw):
        criterion = _parse_criterion(raw_criterion, idx, chore_slug, blockers)
        if criterion is not None:
            parsed.append(criterion)

    return tuple(parsed)


def _parse_chore_pointer(
    raw_chore: object,
    index: int,
    project_root: Path,
    blockers: list[str],
) -> ChoreDefinition | None:
    """Parse one v2.0 chore pointer from the registry."""
    from gzkit.commands.chores import (  # noqa: PLC0415
        ALLOWED_LANES,
        CHORE_SLUG_RE,
        _resolve_chore_dir,
    )

    if not isinstance(raw_chore, dict):
        blockers.append(f"chores[{index}] must be an object.")
        return None
    data = cast("dict[str, object]", raw_chore)

    slug_raw = data.get("slug")
    if not isinstance(slug_raw, str) or not CHORE_SLUG_RE.match(slug_raw):
        blockers.append(f"chores[{index}].slug must be kebab-case (for example: quality-check).")
        return None
    slug = slug_raw.strip()

    title_raw = data.get("title")
    if not isinstance(title_raw, str) or not title_raw.strip():
        blockers.append(f"chores[{slug}].title must be a non-empty string.")
        return None
    title = title_raw.strip()

    lane_raw = data.get("lane")
    if not isinstance(lane_raw, str):
        blockers.append(f"chores[{slug}].lane must be one of: {', '.join(sorted(ALLOWED_LANES))}.")
        return None
    lane = lane_raw.strip().lower()
    if lane not in ALLOWED_LANES:
        blockers.append(f"chores[{slug}].lane must be one of: {', '.join(sorted(ALLOWED_LANES))}.")
        return None

    version_raw = data.get("version", "1.0.0")
    version = version_raw.strip() if isinstance(version_raw, str) else "1.0.0"

    path_raw = data.get("path")
    if not isinstance(path_raw, str) or not path_raw.strip():
        blockers.append(f"chores[{slug}].path must be a non-empty string.")
        return None
    declared_path = path_raw.strip()

    try:
        resolved = _resolve_chore_dir(slug)
    except GzCliError as exc:
        blockers.append(str(exc))
        return None

    criteria = _load_acceptance(
        resolved.path,
        slug,
        blockers,
    )
    if not criteria:
        return None

    timeout_raw = data.get("timeoutSeconds")
    if not isinstance(timeout_raw, int) or isinstance(timeout_raw, bool) or timeout_raw <= 0:
        blockers.append(
            f"chores[{slug}].timeoutSeconds must be a positive integer "
            f"(GHI #447 — explicit per-chore timeouts)."
        )
        return None
    timeout = timeout_raw

    vendor_raw = data.get("vendor")
    vendor = vendor_raw.strip() if isinstance(vendor_raw, str) else None

    return ChoreDefinition(
        slug=slug,
        title=title,
        lane=lane,
        version=version,
        path=declared_path,
        criteria=criteria,
        timeout_seconds=timeout,
        vendor=vendor,
        resolution_source=resolved.source,
    )


# ---------------------------------------------------------------------------
# Criterion execution
# ---------------------------------------------------------------------------


def _evaluate_criterion(
    criterion: AcceptanceCriterion,
    project_root: Path,
    timeout: int,
) -> CriterionResult:
    """Execute one criterion and evaluate the result."""
    if criterion.criterion_type == "fileExists":
        path = criterion.path
        exists = path is not None and (project_root / path).exists()
        return CriterionResult(
            criterion=criterion,
            passed=exists,
            returncode=0 if exists else 1,
            duration_seconds=0.0,
            stdout="",
            stderr="",
            detail=f"file exists: {path}" if exists else f"file not found: {path}",
        )

    started = perf_counter()
    try:
        completed = subprocess.run(
            list(criterion.argv),
            cwd=project_root,
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired as exc:
        duration = perf_counter() - started
        stdout = (
            exc.stdout.decode("utf-8", errors="replace")
            if isinstance(exc.stdout, bytes)
            else (exc.stdout or "")
        )
        stderr = (
            exc.stderr.decode("utf-8", errors="replace")
            if isinstance(exc.stderr, bytes)
            else (exc.stderr or "")
        )
        return CriterionResult(
            criterion=criterion,
            passed=False,
            returncode=124,
            duration_seconds=duration,
            stdout=stdout,
            stderr=stderr,
            detail=f"Timed out after {timeout}s",
        )
    except FileNotFoundError:
        duration = perf_counter() - started
        return CriterionResult(
            criterion=criterion,
            passed=False,
            returncode=127,
            duration_seconds=duration,
            stdout="",
            stderr=f"Command not found: {criterion.argv[0]}",
            detail=f"Missing executable: {criterion.argv[0]}",
        )

    duration = perf_counter() - started

    if criterion.criterion_type == "exitCodeEquals":
        passed = completed.returncode == criterion.expected
        detail = (
            f"exit {completed.returncode} == {criterion.expected}"
            if passed
            else f"exit {completed.returncode} != {criterion.expected}"
        )
    elif criterion.criterion_type == "outputNotContains":
        found = criterion.not_contains is not None and criterion.not_contains in completed.stdout
        passed = not found
        detail = (
            f"output clean of '{criterion.not_contains}'"
            if passed
            else f"output contains '{criterion.not_contains}'"
        )
    elif criterion.criterion_type == "outputContains":
        passed = criterion.contains is not None and criterion.contains in completed.stdout
        detail = (
            f"output contains '{criterion.contains}'"
            if passed
            else f"output missing '{criterion.contains}'"
        )
    else:
        passed = False
        detail = f"Unknown criterion type: {criterion.criterion_type}"

    return CriterionResult(
        criterion=criterion,
        passed=passed,
        returncode=completed.returncode,
        duration_seconds=duration,
        stdout=completed.stdout,
        stderr=completed.stderr,
        detail=detail,
    )


# ---------------------------------------------------------------------------
# Chore log writing
# ---------------------------------------------------------------------------


def _write_chore_log(
    project_root: Path,
    chore: ChoreDefinition,
    status: str,
    results: list[CriterionResult],
) -> Path:
    """Append one dated run entry to chore log."""
    log_path = _log_path(project_root, chore)
    log_path.parent.mkdir(parents=True, exist_ok=True)
    if not log_path.exists():
        log_path.write_text(
            f"# CHORE-LOG: {chore.slug}\n\n",
            encoding="utf-8",
        )

    timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
    lines: list[str] = [
        f"## {timestamp}",
        f"- Status: {status}",
        f"- Chore: {chore.slug}",
        f"- Title: {chore.title}",
        f"- Lane: {chore.lane}",
        f"- Version: {chore.version}",
        "- Criteria Results:",
    ]
    for result in results:
        mark = "PASS" if result.passed else "FAIL"
        lines.append(
            f"  - [{mark}] `{result.criterion.command}` "
            f"=> rc={result.returncode} "
            f"({result.duration_seconds:.2f}s) -- {result.detail}"
        )

    lines.extend(["", "```text"])
    for result in results:
        if result.stdout.strip():
            lines.append(f"[{result.criterion.command}] stdout:")
            lines.append(result.stdout.strip())
        if result.stderr.strip():
            lines.append(f"[{result.criterion.command}] stderr:")
            lines.append(result.stderr.strip())
    lines.extend(["```", ""])

    with log_path.open("a", encoding="utf-8") as handle:
        handle.write("\n".join(lines))

    return log_path


def _log_path(project_root: Path, chore: ChoreDefinition) -> Path:
    """Compute log path inside chore's proofs directory."""
    return project_root / chore.path / "proofs" / "CHORE-LOG.md"
