from __future__ import annotations

import os

# YAML configuration support requires PyYAML.
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal

import yaml
from cybervisor.adapters.registry import supported_agent_names

AgentToolType = Literal["gemini", "claude", "mock"]

DEFAULT_ENDPOINT = "https://api.openai.com/v1"
DEFAULT_MODEL = "auto"
DEFAULT_STAGE_NAMES = ["Spec", "Review Spec", "Implement", "Review Code", "Verify"]
DEFAULT_TEMPLATE = "Execute stage '{stage_name}' for feature: {feature_description}"
DEFAULT_ALLOWED_STATUSES = ["approved", "changes_requested"]
ALLOWED_INJECT_SECTIONS = {"summary", "details", "actions"}
CONTRACT_ROOT = Path(".cybervisor/contracts")
CONTRACT_ARTIFACTS_DIR = CONTRACT_ROOT / "artifacts"
CONTRACT_PROMPTS_DIR = CONTRACT_ROOT / "prompts"


@dataclass(slots=True)
class StageConfig:
    name: str
    prompt_template: str = DEFAULT_TEMPLATE
    max_retries: int = 3
    next_stage: str | None = None
    contract: "StageContractConfig | None" = None


@dataclass(slots=True)
class StageRouteConfig:
    next_stage: str
    injections: list[str] = field(default_factory=list)


@dataclass(slots=True)
class StageContractConfig:
    allowed_statuses: list[str] = field(default_factory=lambda: list(DEFAULT_ALLOWED_STATUSES))
    routes: dict[str, StageRouteConfig] = field(default_factory=dict)


@dataclass(slots=True)
class HookConfig:
    api_endpoint: str = DEFAULT_ENDPOINT
    api_key: str = ""
    model: str = DEFAULT_MODEL


@dataclass(slots=True)
class PipelineConfig:
    agent_tool: AgentToolType = "gemini"
    hook: HookConfig = field(default_factory=HookConfig)
    stages: list[StageConfig] = field(default_factory=lambda: [StageConfig(name=n) for n in DEFAULT_STAGE_NAMES])


def contract_artifact_path(stage_name: str) -> Path:
    return CONTRACT_ARTIFACTS_DIR / f"{stage_name}.yaml"


def contract_prompt_path(stage_name: str) -> Path:
    return CONTRACT_PROMPTS_DIR / f"{stage_name}.md"


def _validate_agent_tool(value: str) -> AgentToolType:
    normalized = value.strip().lower()
    supported = supported_agent_names()
    if normalized not in supported:
        raise ValueError(f"Unsupported agent_tool: {value}. Supported values: {', '.join(sorted(supported))}")
    return normalized  # type: ignore[return-value]


def _load_hook_config_from_env() -> HookConfig:
    api_endpoint = str(os.getenv("CYBERVISOR_LLM_BASE_URL") or DEFAULT_ENDPOINT)
    api_key = str(os.getenv("CYBERVISOR_LLM_API_KEY") or "")
    model = str(os.getenv("CYBERVISOR_LLM_MODEL") or DEFAULT_MODEL)
    return HookConfig(api_endpoint=api_endpoint, api_key=api_key, model=model)


def _validate_hook_config_is_env_only(raw_hook: dict[str, Any]) -> None:
    forbidden = {"api_endpoint", "api_key", "model"}
    present = forbidden.intersection(raw_hook.keys())
    if present:
        keys = ", ".join(sorted(present))
        raise ValueError(f"'hook' must not set {keys}; use CYBERVISOR_LLM_* env vars instead")


def _load_stage(stage_raw: dict[str, Any]) -> StageConfig:
    name = str(stage_raw.get("name", "")).strip()
    if not name:
        raise ValueError("Each stage must have a non-empty name")
    prompt_template = str(stage_raw.get("prompt_template") or DEFAULT_TEMPLATE)
    max_retries = int(stage_raw.get("max_retries", 3))
    if max_retries < 1:
        raise ValueError("max_retries must be >= 1")
    next_stage_raw = stage_raw.get("next_stage")
    next_stage = str(next_stage_raw).strip() if next_stage_raw is not None else None
    if next_stage == "":
        next_stage = None
    contract_raw = stage_raw.get("contract")
    contract: StageContractConfig | None = None
    if contract_raw is not None:
        if not isinstance(contract_raw, dict):
            raise ValueError(f"Stage '{name}' contract must be a mapping")
        contract = _load_stage_contract(name, contract_raw)
    return StageConfig(
        name=name,
        prompt_template=prompt_template,
        max_retries=max_retries,
        next_stage=next_stage,
        contract=contract,
    )


def _load_stage_contract(stage_name: str, contract_raw: dict[str, Any]) -> StageContractConfig:
    removed_fields = {"artifact_path", "artifact_kind", "input_bindings", "prompt_file"}
    present_removed_fields = sorted(removed_fields.intersection(contract_raw))
    if present_removed_fields:
        keys = ", ".join(present_removed_fields)
        raise ValueError(
            f"Stage '{stage_name}' contract no longer supports {keys}; use fixed .cybervisor/contracts paths instead"
        )

    allowed_statuses_raw = contract_raw.get("allowed_statuses", list(DEFAULT_ALLOWED_STATUSES))
    if not isinstance(allowed_statuses_raw, list) or not allowed_statuses_raw:
        raise ValueError(f"Stage '{stage_name}' contract allowed_statuses must be a non-empty list")
    allowed_statuses: list[str] = []
    for value in allowed_statuses_raw:
        status = str(value).strip()
        if not status:
            raise ValueError(f"Stage '{stage_name}' contract allowed_statuses must not contain empty values")
        if status not in allowed_statuses:
            allowed_statuses.append(status)

    routes_raw = contract_raw.get("routes", {})
    if not isinstance(routes_raw, dict):
        raise ValueError(f"Stage '{stage_name}' contract routes must be a mapping")
    routes: dict[str, StageRouteConfig] = {}
    for raw_status, raw_route in routes_raw.items():
        status = str(raw_status).strip()
        if not status:
            raise ValueError(f"Stage '{stage_name}' contract routes must use non-empty status keys")
        if not isinstance(raw_route, dict):
            raise ValueError(
                f"Stage '{stage_name}' contract route '{status}' must be a mapping with next_stage and injections"
            )
        target = str(raw_route.get("next_stage", "")).strip()
        if not target:
            raise ValueError(f"Stage '{stage_name}' contract route '{status}' must define a non-empty next_stage")
        injections_raw = raw_route.get("injections", [])
        if not isinstance(injections_raw, list):
            raise ValueError(f"Stage '{stage_name}' contract route '{status}' injections must be a list")
        injections: list[str] = []
        for raw_section in injections_raw:
            section = str(raw_section).strip()
            if section not in ALLOWED_INJECT_SECTIONS:
                allowed = ", ".join(sorted(ALLOWED_INJECT_SECTIONS))
                raise ValueError(
                    f"Stage '{stage_name}' contract route '{status}' injections must use only: {allowed}"
                )
            if section not in injections:
                injections.append(section)
        routes[status] = StageRouteConfig(next_stage=target, injections=injections)

    return StageContractConfig(allowed_statuses=allowed_statuses, routes=routes)


def _validate_stage_definitions(stages: list[StageConfig]) -> None:
    stage_names: list[str] = [stage.name for stage in stages]
    unique_names = set(stage_names)
    if len(unique_names) != len(stage_names):
        raise ValueError("Stage names must be unique when loading configuration")

    for stage in stages:
        if stage.next_stage is not None and stage.next_stage not in unique_names:
            raise ValueError(
                f"Stage '{stage.name}' next_stage target '{stage.next_stage}' does not match any configured stage"
            )
        contract = stage.contract
        if contract is None:
            continue
        for status in contract.routes:
            if status not in contract.allowed_statuses:
                raise ValueError(
                    f"Stage '{stage.name}' contract route '{status}' must appear in allowed_statuses"
                )
        for route in contract.routes.values():
            if route.next_stage not in unique_names:
                raise ValueError(
                    f"Stage '{stage.name}' contract route target '{route.next_stage}' does not match any configured stage"
                )


def _load_raw_config(config_path: Path) -> dict[str, Any]:
    suffix = config_path.suffix.lower()

    if suffix in {".yaml", ".yml"}:
        loaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
        if loaded is None:
            return {}
        if not isinstance(loaded, dict):
            raise ValueError("Configuration file root must be a YAML mapping")
        if not all(isinstance(k, str) for k in loaded.keys()):
            raise ValueError("Configuration file mapping keys must be strings")
        return dict(loaded)

    raise ValueError(f"Unsupported config file type: {config_path.suffix} (expected .yaml/.yml)")


def load_config(path: str | Path) -> PipelineConfig:
    config_path = Path(path)

    if config_path.suffix == "":
        for ext in (".yaml", ".yml"):
            candidate = config_path.with_suffix(ext)
            if candidate.exists():
                config_path = candidate
                break

    if not config_path.exists():
        raise FileNotFoundError(f"Configuration file not found: {config_path}")

    raw = _load_raw_config(config_path)

    pipeline_raw = raw.get("pipeline", {})
    hook_raw = raw.get("hook", {})
    stages_raw = raw.get("stages", [])
    if not isinstance(pipeline_raw, dict):
        raise ValueError("'pipeline' must be a mapping")
    if not isinstance(hook_raw, dict):
        raise ValueError("'hook' must be a mapping")
    if not isinstance(stages_raw, list):
        raise ValueError("'stages' must be a list")

    agent_tool = _validate_agent_tool(str(pipeline_raw.get("agent_tool", "gemini")))
    _validate_hook_config_is_env_only(hook_raw)
    hook = _load_hook_config_from_env()

    if len(stages_raw) == 0:
        stages = [StageConfig(name=n) for n in DEFAULT_STAGE_NAMES]
    else:
        if not all(isinstance(stage, dict) for stage in stages_raw):
            raise ValueError("Each stage must be a mapping")
        stages = [_load_stage(stage) for stage in stages_raw]
    _validate_stage_definitions(stages)

    return PipelineConfig(agent_tool=agent_tool, hook=hook, stages=stages)
