"""Task schema and loader.

A task is a prompt plus a set of deterministic checks, pinned to a commit.
Schema: docs/04-task-suite.md.
"""

from __future__ import annotations

from fnmatch import fnmatch
from pathlib import Path
from typing import Literal

import yaml
from pydantic import BaseModel, Field, field_validator

from ablate.errors import AblateError

AssertionType = Literal["exit_code", "files_touched", "present", "absent", "lint", "script"]


class Assertion(BaseModel):
    """One deterministic check. Field applicability depends on `type`:

    - exit_code / lint: run, expect (default 0), timeout_s
    - files_touched:    must_include, must_not_include (globs)
    - present / absent: pattern (regex), paths (globs), in_ ("diff" | "files")
    - script:           run, expect, capture_json
    """

    id: str
    type: AssertionType
    required: bool = True

    # exit_code / lint / script
    run: str | None = None
    expect: int = 0
    timeout_s: int = 600
    capture_json: bool = False

    # files_touched
    must_include: list[str] = Field(default_factory=list)
    must_not_include: list[str] = Field(default_factory=list)

    # present / absent
    pattern: str | None = None
    paths: list[str] = Field(default_factory=list)
    in_: Literal["diff", "files"] = Field(default="diff", alias="in")

    model_config = {"populate_by_name": True}

    @field_validator("id")
    @classmethod
    def _id_not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("assertion id must not be empty")
        return v


class Task(BaseModel):
    id: str
    description: str = ""
    base_commit: str | None = None
    setup: str | None = None
    timeout_s: int = 600
    prompt: str
    config_path: str = "CLAUDE.md"
    hide: list[str] = Field(default_factory=list)
    assertions: list[Assertion] = Field(default_factory=list, alias="assert")
    weight: float = 1.0
    #: Free-form labels for subsetting a suite: `--tasks-tag smoke`. Suite size
    #: is the first cost control in docs/06-cost.md, and the cheapest — running
    #: five tasks instead of thirty costs a sixth as much.
    tags: list[str] = Field(default_factory=list)

    model_config = {"populate_by_name": True}

    @field_validator("assertions")
    @classmethod
    def _at_least_one_assertion(cls, v: list[Assertion]) -> list[Assertion]:
        if not v:
            raise ValueError("task must declare at least one assertion")
        return v


def load_task(path: Path) -> Task:
    try:
        raw = yaml.safe_load(path.read_text())
    except yaml.YAMLError as e:
        raise AblateError(f"{path}: invalid YAML — {e}") from e
    if not isinstance(raw, dict):
        raise AblateError(f"{path}: task file must be a YAML mapping")
    try:
        return Task.model_validate(raw)
    except Exception as e:  # pydantic.ValidationError, re-raised with the path
        raise AblateError(f"{path}: {e}") from e


def load_tasks(tasks_dir: Path, pattern: str = "*.yaml") -> list[Task]:
    """Load every task file in a directory, sorted by id for reproducible ordering."""
    paths = sorted(tasks_dir.glob(pattern)) + sorted(tasks_dir.glob("*.yml"))
    if not paths:
        raise AblateError(f"no task files found in {tasks_dir}")
    tasks = [load_task(p) for p in paths]
    seen: set[str] = set()
    for t in tasks:
        if t.id in seen:
            raise AblateError(f"duplicate task id {t.id!r} in {tasks_dir}")
        seen.add(t.id)
    return sorted(tasks, key=lambda t: t.id)


def select_tasks(
    tasks: list[Task],
    *,
    only: str | None = None,
    tag: str | None = None,
) -> list[Task]:
    """Subset a suite by id pattern and/or tag.

    `only` is a comma-separated list of task ids or glob patterns
    (`fix-*,add-cli-flag`); `tag` matches any of a comma-separated list against
    a task's `tags`. Both are cost controls, not analysis options — subsetting
    changes *which* estimand is being estimated, since Δ is defined as an
    average over the tasks in the suite (docs/05-statistics.md).

    Selecting nothing raises rather than silently running an empty suite, which
    would otherwise produce a confident-looking report over zero trials.
    """
    selected = tasks

    if only:
        patterns = [p.strip() for p in only.split(",") if p.strip()]
        selected = [t for t in selected if any(fnmatch(t.id, p) for p in patterns)]
        if not selected:
            known = ", ".join(t.id for t in tasks)
            raise AblateError(
                f"no task matched {only!r}. Available: {known}"
            )

    if tag:
        wanted = {w.strip() for w in tag.split(",") if w.strip()}
        selected = [t for t in selected if wanted & set(t.tags)]
        if not selected:
            known = ", ".join(sorted({g for t in tasks for g in t.tags})) or "(none declared)"
            raise AblateError(f"no task carries tag {tag!r}. Tags in this suite: {known}")

    return selected
