"""Spec Wizard service for guided artifact generation with agent + human review."""

from __future__ import annotations

import asyncio
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import yaml

from hangar.validation import validate_change_name


# ── Data classes ──────────────────────────────────────────────────


@dataclass
class ChangeInfo:
    """Result of creating a new change directory."""

    name: str
    path: str
    created: bool


@dataclass
class GenerationResult:
    """Result of running an agent to generate content."""

    content: str
    agent_type: str
    prompt_used: str  # for fallback display
    duration_ms: int


@dataclass
class StepState:
    """State of a single wizard step."""

    artifact_type: str  # "proposal" | "specs" | "design" | "tasks"
    status: str  # "pending" | "approved" | "skipped"
    content: str | None = None


@dataclass
class WizardState:
    """Full state of the wizard for a given change."""

    change_name: str
    description: str | None
    steps: list[StepState] = field(default_factory=list)
    current_step: int = 0  # 0-based


# ── Constants ─────────────────────────────────────────────────────

ARTIFACT_ORDER = ["proposal", "specs", "design", "tasks"]

ARTIFACT_FILE_MAP = {
    "proposal": "proposal.md",
    "specs": "specs/spec.md",
    "design": "design.md",
    "tasks": "tasks.md",
}

# ── Prompt Templates ──────────────────────────────────────────────

PROMPT_TEMPLATES: dict[str, str] = {
    "proposal": """\
Project context:
{context}

The user wants to build: {description}

Generate a proposal.md following OpenSpec format:
- Problem statement (what's broken or missing)
- Solution overview (high-level approach)
- Approach (how we'll build it)
- In scope / Out of scope (clear boundaries)

{rules}

{feedback}

Output ONLY the markdown content, no explanations or preamble.\
""",
    "specs": """\
Project context:
{context}

Approved proposal:
---
{approved_proposal}
---

Based on the approved proposal above, generate specs following OpenSpec format:
- ADDED requirements (new capabilities)
- MODIFIED requirements (changes to existing behavior)
- REMOVED requirements (deprecated features)
- Each requirement should have Given/When/Then scenarios

{rules}

{feedback}

Output ONLY the markdown content, no explanations or preamble.\
""",
    "design": """\
Project context:
{context}

Approved proposal:
---
{approved_proposal}
---

Approved specs:
---
{approved_specs}
---

Based on the approved proposal and specs above, generate a design.md following OpenSpec format:
- Architecture overview (components, data flow)
- New/modified components (classes, services, modules)
- API endpoints or interfaces
- Data models and schemas
- Technology decisions with rationale
- File touchpoints (new files, modified files)

{rules}

{feedback}

Output ONLY the markdown content, no explanations or preamble.\
""",
    "tasks": """\
Project context:
{context}

Approved proposal:
---
{approved_proposal}
---

Approved specs:
---
{approved_specs}
---

Approved design:
---
{approved_design}
---

Based on all the approved artifacts above, generate a tasks.md following OpenSpec format:
- Organize tasks into numbered phases (## Phase N: Title)
- Each task as a checkbox item: - [ ] **X.Y** Task title
- Include test expectations as sub-bullets: - Tests: description
- Each task should be completable in one agent session
- Tasks should be ordered by dependency (earlier tasks don't depend on later ones)

{rules}

{feedback}

Output ONLY the markdown content, no explanations or preamble.\
""",
}


# ── Pure functions ────────────────────────────────────────────────


def format_prompt(
    artifact_type: str,
    description: str,
    approved_artifacts: dict[str, str],
    context: str = "",
    rules: str = "",
    feedback: str | None = None,
) -> str:
    """Build a generation prompt from template + context chain.

    Pure function — no I/O, fully testable.

    Args:
        artifact_type: One of "proposal", "specs", "design", "tasks"
        description: User's description of what they want to build
        approved_artifacts: Map of artifact_type → content for previously approved artifacts
        context: Project context string from config.yaml
        rules: Artifact-specific rules string from config.yaml
        feedback: Optional user feedback for regeneration
    """
    if artifact_type not in PROMPT_TEMPLATES:
        raise ValueError(
            f"Unknown artifact type: {artifact_type}. "
            f"Must be one of: {', '.join(ARTIFACT_ORDER)}"
        )

    template = PROMPT_TEMPLATES[artifact_type]

    # Build substitution dict
    subs: dict[str, str] = {
        "context": context or "(no project context provided)",
        "description": description,
        "rules": f"Additional rules:\n{rules}" if rules else "",
        "feedback": f"User feedback on previous generation:\n{feedback}" if feedback else "",
    }

    # Add approved artifact placeholders
    for art_type in ARTIFACT_ORDER:
        key = f"approved_{art_type}"
        subs[key] = approved_artifacts.get(art_type, "(not yet generated)")

    return template.format(**subs)


def load_config(project_path: Path) -> dict[str, Any]:
    """Load OpenSpec config.yaml from the project.

    Returns empty dict if config doesn't exist.
    Pure function — reads file but has no side effects.
    """
    config_path = project_path / "openspec" / "config.yaml"
    if not config_path.exists():
        return {}

    try:
        with open(config_path) as f:
            result = yaml.safe_load(f)
            if not isinstance(result, dict):
                return {}
            return result or {}
    except (yaml.YAMLError, OSError):
        return {}


def get_context_from_config(config: dict[str, Any]) -> str:
    """Extract the context string from a loaded config."""
    return config.get("context", "")


def get_rules_for_artifact(config: dict[str, Any], artifact_type: str) -> str:
    """Extract artifact-specific rules from a loaded config.

    Returns formatted rules string or empty string.
    """
    rules = config.get("rules", {})
    if not rules or not isinstance(rules, dict):
        return ""

    artifact_rules = rules.get(artifact_type)
    if not artifact_rules:
        return ""

    if isinstance(artifact_rules, list):
        return "\n".join(f"- {rule}" for rule in artifact_rules)
    return str(artifact_rules)


# ── Service (has I/O) ─────────────────────────────────────────────


class SpecWizardService:
    """Orchestrates spec artifact generation and writing."""

    def __init__(self, project_path: Path) -> None:
        self.project_path = project_path
        self.changes_dir = project_path / "openspec" / "changes"
        self.config = load_config(project_path)

    async def create_change(self, name: str, description: str) -> ChangeInfo:
        """Create a new change directory. Fails if it already exists."""
        # Validate name: lowercase, hyphens, no spaces
        if not re.match(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$", name):
            raise ValueError(
                f"Invalid change name '{name}'. "
                "Use lowercase letters, numbers, and hyphens only."
            )

        change_dir = self.changes_dir / name
        if change_dir.exists():
            raise FileExistsError(f"Change '{name}' already exists")

        change_dir.mkdir(parents=True, exist_ok=False)

        # Write description file for state recovery
        desc_file = change_dir / ".description"
        desc_file.write_text(description)

        return ChangeInfo(name=name, path=str(change_dir), created=True)

    async def generate_artifact(
        self,
        change_name: str,
        artifact_type: str,
        description: str,
        approved_artifacts: dict[str, str],
        feedback: str | None = None,
        agent_type: str = "claude-code",
    ) -> GenerationResult:
        """Generate artifact content using an agent. Does NOT write to disk."""
        prompt = self.build_prompt(
            artifact_type=artifact_type,
            description=description,
            approved_artifacts=approved_artifacts,
            feedback=feedback,
        )

        import time

        start = time.monotonic()

        try:
            content = await self._run_agent(prompt, agent_type)
        except (TimeoutError, RuntimeError, FileNotFoundError) as exc:
            # Return the prompt itself so user can run manually
            return GenerationResult(
                content=f"[Agent failed: {exc}]\n\n"
                f"You can run this prompt manually:\n\n---\n{prompt}\n---",
                agent_type=agent_type,
                prompt_used=prompt,
                duration_ms=int((time.monotonic() - start) * 1000),
            )

        duration_ms = int((time.monotonic() - start) * 1000)

        return GenerationResult(
            content=content,
            agent_type=agent_type,
            prompt_used=prompt,
            duration_ms=duration_ms,
        )

    async def approve_artifact(
        self,
        change_name: str,
        artifact_type: str,
        content: str,
    ) -> None:
        """Write approved content to the correct file path."""
        validate_change_name(change_name)
        if artifact_type not in ARTIFACT_FILE_MAP:
            raise ValueError(f"Unknown artifact type: {artifact_type}")

        change_dir = self.changes_dir / change_name
        if not change_dir.exists():
            raise FileNotFoundError(f"Change '{change_name}' does not exist")

        file_path = change_dir / ARTIFACT_FILE_MAP[artifact_type]

        # Ensure parent dirs exist (for specs/spec.md)
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(content)

    async def get_wizard_state(self, change_name: str) -> WizardState:
        """Detect which artifacts exist on disk, determine current step."""
        validate_change_name(change_name)
        change_dir = self.changes_dir / change_name
        if not change_dir.exists():
            raise FileNotFoundError(f"Change '{change_name}' does not exist")

        # Read description if present
        desc_file = change_dir / ".description"
        description = desc_file.read_text().strip() if desc_file.exists() else None

        steps: list[StepState] = []
        current_step = 0

        for i, artifact_type in enumerate(ARTIFACT_ORDER):
            file_path = change_dir / ARTIFACT_FILE_MAP[artifact_type]
            if file_path.exists():
                content = file_path.read_text()
                steps.append(
                    StepState(
                        artifact_type=artifact_type,
                        status="approved",
                        content=content,
                    )
                )
                current_step = i + 1  # move past this step
            else:
                steps.append(
                    StepState(
                        artifact_type=artifact_type,
                        status="pending",
                        content=None,
                    )
                )

        return WizardState(
            change_name=change_name,
            description=description,
            steps=steps,
            current_step=current_step,
        )

    def build_prompt(
        self,
        artifact_type: str,
        description: str,
        approved_artifacts: dict[str, str],
        feedback: str | None = None,
    ) -> str:
        """Build the generation prompt with context chain and config rules."""
        context = get_context_from_config(self.config)
        rules = get_rules_for_artifact(self.config, artifact_type)

        return format_prompt(
            artifact_type=artifact_type,
            description=description,
            approved_artifacts=approved_artifacts,
            context=context,
            rules=rules,
            feedback=feedback,
        )

    async def _run_agent(
        self, prompt: str, agent_type: str, timeout: int = 120
    ) -> str:
        """Run agent in non-interactive mode, capture stdout."""
        if agent_type == "claude-code":
            cmd = ["claude", "-p", prompt]
        elif agent_type == "codex":
            cmd = ["codex", "--quiet", "--prompt", prompt]
        else:
            raise ValueError(
                f"Agent '{agent_type}' doesn't support non-interactive mode"
            )

        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=self.project_path,
        )

        try:
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(), timeout=timeout
            )
        except asyncio.TimeoutError:
            proc.kill()
            await proc.wait()
            raise TimeoutError(
                f"Agent timed out after {timeout}s. "
                "Try again or use the manual prompt fallback."
            )

        if proc.returncode != 0:
            raise RuntimeError(f"Agent failed: {stderr.decode().strip()}")

        return stdout.decode()
