from __future__ import annotations

from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field


class TaskState(str, Enum):
    INIT = "INIT"
    PLAN = "PLAN"
    PLAN_REVIEW = "PLAN_REVIEW"
    IMPLEMENT = "IMPLEMENT"
    TEST = "TEST"
    REVIEW = "REVIEW"
    FIX = "FIX"
    FINAL_VERIFY = "FINAL_VERIFY"
    DONE = "DONE"
    NEEDS_HUMAN = "NEEDS_HUMAN"
    WAITING_CONTINUE = "WAITING_CONTINUE"


def is_terminal(state: TaskState) -> bool:
    return state in {TaskState.DONE, TaskState.NEEDS_HUMAN}


class ErrorCategory(str, Enum):
    """Failure taxonomy from the v0.1 design, section 12."""

    AUTH = "AUTH"
    RATE_LIMIT = "RATE_LIMIT"
    NETWORK = "NETWORK"
    TIMEOUT = "TIMEOUT"
    PROTOCOL = "PROTOCOL"
    AGENT_FAILURE = "AGENT_FAILURE"
    TEST_FAILURE = "TEST_FAILURE"
    STALLED = "STALLED"
    MAX_CYCLES = "MAX_CYCLES"
    OPERATOR = "OPERATOR"
    ISOLATION = "ISOLATION"


BLOCKING_SEVERITIES = frozenset({"blocker", "critical"})
BLOCKING_REVIEW_KINDS = frozenset({"CODE_BLOCKER", "TEST_BLOCKER"})


class StrictModel(BaseModel):
    model_config = ConfigDict(extra="forbid")


class VersionedArtifact(StrictModel):
    schema_version: int = Field(default=1, ge=1)


class Limits(StrictModel):
    command_timeout_s: int = Field(default=900, gt=0)
    agent_timeout_s: int = Field(default=900, gt=0)
    max_fix_cycles: int = Field(default=8, ge=0)


class Policy(StrictModel):
    allow_network: bool = False
    allow_push: bool = False
    #: When true the UI shows an approval gate before IMPLEMENT and FIX.
    require_approval: bool = False
    #: Directories a task repository may live under. Empty means no restriction, which
    #: is safe only for a single-operator local run; any shared UI should set it.
    allowed_repo_roots: list[str] = Field(default_factory=list)
    #: POSIX owner used only for an unwritable repository inside an allowed root.
    repository_owner: str = ""


class AgentTuning(StrictModel):
    """Per-agent model and reasoning effort. Empty means the CLI's own default."""

    model: str = ""
    effort: str = ""


class ModelCatalog(StrictModel):
    """Admin-maintained models and reasoning efforts allowed for one provider.

    The empty string is a valid entry in either list: it stands for "leave the CLI's
    own default alone" and is what the UI offers as the unset choice.
    """

    models: list[str] = Field(default_factory=list)
    efforts: list[str] = Field(default_factory=list)
    default_model: str = ""
    default_effort: str = ""


class Agents(StrictModel):
    planner: str = "codex"
    implementer: str = "claude"
    #: Codex sandbox mode for read-only stages. Some hosts (containers without the
    #: required kernel features) cannot create the sandbox namespace at all, and every
    #: command then fails; such a host must choose a mode it can actually run.
    review_sandbox: str = "read-only"
    #: Parallel implementers. 1 keeps the single-worktree flow the design defaults to.
    implement_workers: int = Field(default=1, ge=1, le=8)
    #: Rounds the planner and reviewer may exchange over a rejected plan before it goes
    #: back for a rewrite. The default allows up to six structured objection/response rounds.
    discussion_rounds: int = Field(default=6, ge=0, le=6)
    #: Absolute paths keyed by adapter name, for installs that are not on PATH.
    binaries: dict[str, str] = Field(default_factory=dict)
    #: Model and effort per adapter name, overridable per task from the UI.
    tuning: dict[str, AgentTuning] = Field(default_factory=dict)
    #: Allowed models and efforts per adapter name. An adapter absent here accepts no
    #: chosen model or effort at all, only the CLI's own default.
    catalog: dict[str, ModelCatalog] = Field(default_factory=dict)
    #: Path to a Claude settings file, for enterprise apiKeyHelper setups.
    settings_file: str = ""
    #: Extra environment variable names an enterprise backend needs. Names only;
    #: values stay in the service's own environment and are never stored here.
    passthrough_env: list[str] = Field(default_factory=list)

    def validate_tuning(self, role: str, tuning: AgentTuning) -> None:
        """Reject a model or effort the provider's catalog does not list.

        An empty model or effort always passes: it means "leave the CLI default alone"
        and never reaches the CLI as a flag.
        """
        provider = self.planner if role == "planner" else self.implementer
        catalog = self.catalog.get(provider, ModelCatalog())
        if tuning.model and tuning.model not in catalog.models:
            raise ValueError(f"{provider} 不支持模型 {tuning.model!r}")
        if tuning.effort and tuning.effort not in catalog.efforts:
            raise ValueError(f"{provider} 不支持思考强度 {tuning.effort!r}")


class CheckpointRecord(VersionedArtifact):
    stage: str
    cycle: int
    head: str
    status_porcelain: str
    diff_path: str


class TaskSpec(StrictModel):
    repo_path: Path
    goal: str = Field(min_length=1)
    constraints: list[str] = Field(default_factory=list)
    tuning: dict[str, AgentTuning] = Field(default_factory=dict)


class TaskRecord(VersionedArtifact):
    task_id: str
    repo_path: Path
    goal: str
    constraints: list[str] = Field(default_factory=list)
    state: TaskState = TaskState.INIT
    baseline_commit: str | None = None
    worktree_path: Path | None = None
    dirty_files: list[str] = Field(default_factory=list)
    cycle: int = 0
    plan_rounds: int = 0
    regressions: int = 0
    #: Exchanges already spent on the current plan.
    discussion_rounds: int = 0
    limits: Limits = Field(default_factory=Limits)
    active_run_id: str | None = None
    #: OS pid of the active run, so recovery can tell interrupted from still-running.
    active_run_pid: int | None = None
    #: Every live provider pid. Parallel implementation starts several at once, and cancel
    #: has to reach all of them or the losers keep editing their worktrees.
    active_run_pids: list[int] = Field(default_factory=list)
    #: Last stage that produced a validated artifact. Recovery continues after it.
    last_successful_stage: str | None = None
    stage_hashes: dict[str, str] = Field(default_factory=dict)
    session_ids: dict[str, str] = Field(default_factory=dict)
    #: Previous issue ids per reviewing stage. REVIEW and FINAL_VERIFY are separate reviewers,
    #: so one passing must not erase the other's history.
    last_issues: dict[str, list[str]] = Field(default_factory=dict)
    #: Issue ids the next FIX must answer by name.
    open_issues: list[str] = Field(default_factory=list)
    #: Non-code findings retained as evidence without forcing another FIX cycle.
    advisory_issues: list[str] = Field(default_factory=list)
    #: Model and effort chosen for this task, by adapter name. Empty uses the service default.
    tuning: dict[str, AgentTuning] = Field(default_factory=dict)
    #: Stages still needing an operator approval before they may run.
    require_approval: bool = False
    approved_stages: list[str] = Field(default_factory=list)
    #: Running total of provider-reported usage. Empty when no provider reported any.
    usage: dict[str, float] = Field(default_factory=dict)
    #: Failing test count from the previous TEST round, used for regression detection.
    last_failed: int | None = None
    #: Retry attempts already spent per stage, cleared once the stage succeeds.
    attempts: dict[str, int] = Field(default_factory=dict)
    last_checkpoint: CheckpointRecord | None = None
    #: State to restore when operator resumes after startup recovery.
    resume_state: TaskState | None = None
    error: str | None = None
    error_category: ErrorCategory | None = None
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

    @classmethod
    def new(cls, task_id: str, repo_path: str | Path, goal: str, **extra) -> "TaskRecord":
        return cls(task_id=task_id, repo_path=Path(repo_path), goal=goal, **extra)


class PlanStep(VersionedArtifact):
    id: str
    action: str
    verification: str = ""
    #: Files this step expects to touch. Declared disjointness is what makes two steps
    #: safe to run in parallel; undeclared means the step is assumed to touch anything.
    files: list[str] = Field(default_factory=list)
    #: Ids of steps that must finish first. A step with any dependency is never parallelised.
    depends_on: list[str] = Field(default_factory=list)


class PlanRisk(VersionedArtifact):
    risk: str
    mitigation: str = ""


class PlanArtifact(VersionedArtifact):
    goal: str
    acceptance_criteria: list[str]
    assumptions: list[str] = Field(default_factory=list)
    constraints: list[str] = Field(default_factory=list)
    files_expected: list[str] = Field(default_factory=list)
    steps: list[PlanStep] = Field(default_factory=list)
    risks: list[PlanRisk] = Field(default_factory=list)


class ImplementationArtifact(VersionedArtifact):
    files_changed: list[str]
    tests_run: list[str]
    notes: str = ""


class ReviewIssue(VersionedArtifact):
    issue_id: str
    severity: Literal["blocker", "critical", "major", "minor"]
    problem: str
    file: str = ""
    #: Where in the file, e.g. "function foo". Free text, the schema does not parse it.
    location: str = ""
    expected_fix: str = ""
    verification: str = ""
    kind: Literal[
        "CODE_BLOCKER",
        "TEST_BLOCKER",
        "MANUAL_ACCEPTANCE",
        "DELIVERY_HYGIENE",
        "FOLLOW_UP",
    ] = "CODE_BLOCKER"


class ReviewArtifact(VersionedArtifact):
    verdict: Literal["PASS", "CHANGES_REQUIRED"]
    issues: list[ReviewIssue]
    summary: str = ""

    def blockers(self) -> list[str]:
        return sorted(issue.issue_id for issue in self.issues if issue.severity in BLOCKING_SEVERITIES)

    def issue_ids(self) -> list[str]:
        """Every raised issue. Stalling is about repetition, not severity."""
        return sorted(issue.issue_id for issue in self.issues)

    def blocking_issue_ids(self) -> list[str]:
        return sorted(issue.issue_id for issue in self.issues if issue.kind in BLOCKING_REVIEW_KINDS)

    def advisory_issue_ids(self) -> list[str]:
        return sorted(issue.issue_id for issue in self.issues if issue.kind not in BLOCKING_REVIEW_KINDS)


class FixResolution(VersionedArtifact):
    issue_id: str
    status: Literal["fixed", "wont_fix", "deferred"]
    notes: str = ""


class FixArtifact(VersionedArtifact):
    resolved: list[FixResolution]
    not_resolved: list[FixResolution] = Field(default_factory=list)
    extra_changes: list[str] = Field(default_factory=list)
    tests_run: list[str] = Field(default_factory=list)


class StageTestReport(VersionedArtifact):
    command: str
    exit_code: int | None
    duration_ms: int
    stdout_path: str
    stderr_path: str
    passed: bool
    #: Parsed pass/fail summary line, kept verbatim for the reviewer to read.
    summary: str = ""
    failed: int | None = None
    regression: bool = False


