"""Tests for SpecWizardService, prompt templates, and config loading."""

import asyncio

import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from pathlib import Path

from hangar.services.wizard import (
    ChangeInfo,
    GenerationResult,
    StepState,
    WizardState,
    ARTIFACT_ORDER,
    ARTIFACT_FILE_MAP,
    PROMPT_TEMPLATES,
    format_prompt,
    load_config,
    get_context_from_config,
    get_rules_for_artifact,
    SpecWizardService,
)


# ── Sample fixtures ───────────────────────────────────────────────

SAMPLE_CONFIG_YAML = """\
schema: spec-driven

context: |
  TestApp is a todo list application.
  Tech stack: React, Node.js, PostgreSQL

rules:
  proposal:
    - Include MVP scope boundaries
    - Reference existing features
  specs:
    - Use Given/When/Then format
  design:
    - Include database schema changes
  tasks:
    - Each task completable in one session
"""

SAMPLE_PROPOSAL = """\
# Proposal: User Authentication

## Problem
No auth system exists.

## Solution
Add JWT-based authentication.
"""

SAMPLE_SPECS = """\
# Specs: User Authentication

## ADDED Requirements

### Requirement: Login
Given a registered user
When they submit valid credentials
Then they receive a JWT token
"""

SAMPLE_DESIGN = """\
# Design: User Authentication

## Architecture
- AuthService handles JWT creation/validation
- Middleware extracts token from headers
"""


# ── Prompt template tests ─────────────────────────────────────────


class TestPromptTemplates:
    """Verify that prompt templates exist and contain expected placeholders."""

    def test_all_four_artifact_types_have_templates(self):
        for art_type in ARTIFACT_ORDER:
            assert art_type in PROMPT_TEMPLATES, f"Missing template for {art_type}"

    def test_proposal_template_contains_description(self):
        prompt = format_prompt("proposal", "Add auth", {})
        assert "Add auth" in prompt

    def test_proposal_template_contains_context(self):
        prompt = format_prompt("proposal", "Add auth", {}, context="MyApp context")
        assert "MyApp context" in prompt

    def test_proposal_template_no_context_shows_fallback(self):
        prompt = format_prompt("proposal", "Add auth", {}, context="")
        assert "no project context provided" in prompt

    def test_specs_template_chains_proposal(self):
        prompt = format_prompt(
            "specs",
            "Add auth",
            {"proposal": SAMPLE_PROPOSAL},
        )
        assert "JWT-based authentication" in prompt
        assert "Add auth" not in prompt  # description not in specs template directly

    def test_specs_template_missing_proposal_shows_placeholder(self):
        prompt = format_prompt("specs", "Add auth", {})
        assert "not yet generated" in prompt

    def test_design_template_chains_proposal_and_specs(self):
        prompt = format_prompt(
            "design",
            "Add auth",
            {"proposal": SAMPLE_PROPOSAL, "specs": SAMPLE_SPECS},
        )
        assert "JWT-based authentication" in prompt
        assert "Given a registered user" in prompt

    def test_tasks_template_chains_all_three(self):
        prompt = format_prompt(
            "tasks",
            "Add auth",
            {
                "proposal": SAMPLE_PROPOSAL,
                "specs": SAMPLE_SPECS,
                "design": SAMPLE_DESIGN,
            },
        )
        assert "JWT-based authentication" in prompt
        assert "Given a registered user" in prompt
        assert "AuthService" in prompt

    def test_rules_injected_when_provided(self):
        prompt = format_prompt(
            "proposal",
            "Add auth",
            {},
            rules="- Must include scope boundaries",
        )
        assert "Must include scope boundaries" in prompt
        assert "Additional rules:" in prompt

    def test_rules_omitted_when_empty(self):
        prompt = format_prompt("proposal", "Add auth", {}, rules="")
        assert "Additional rules:" not in prompt

    def test_feedback_injected_when_provided(self):
        prompt = format_prompt(
            "proposal",
            "Add auth",
            {},
            feedback="Make it more concise",
        )
        assert "Make it more concise" in prompt
        assert "User feedback" in prompt

    def test_feedback_omitted_when_none(self):
        prompt = format_prompt("proposal", "Add auth", {})
        assert "User feedback" not in prompt

    def test_invalid_artifact_type_raises(self):
        with pytest.raises(ValueError, match="Unknown artifact type"):
            format_prompt("invalid", "desc", {})

    def test_context_and_rules_combined(self):
        prompt = format_prompt(
            "proposal",
            "Add auth",
            {},
            context="TodoApp context",
            rules="- Include scope",
        )
        assert "TodoApp context" in prompt
        assert "Include scope" in prompt


# ── Config loading tests ──────────────────────────────────────────


class TestConfigLoading:
    """Test loading and parsing config.yaml."""

    def test_load_config_from_valid_yaml(self, tmp_path: Path):
        config_dir = tmp_path / "openspec"
        config_dir.mkdir()
        (config_dir / "config.yaml").write_text(SAMPLE_CONFIG_YAML)

        config = load_config(tmp_path)
        assert config["schema"] == "spec-driven"
        assert "TestApp" in config["context"]

    def test_load_config_missing_file_returns_empty(self, tmp_path: Path):
        config = load_config(tmp_path)
        assert config == {}

    def test_load_config_invalid_yaml_returns_empty(self, tmp_path: Path):
        config_dir = tmp_path / "openspec"
        config_dir.mkdir()
        (config_dir / "config.yaml").write_text(": invalid: yaml: [")

        config = load_config(tmp_path)
        assert config == {}

    def test_get_context_from_config(self):
        config = {"context": "My app context"}
        assert get_context_from_config(config) == "My app context"

    def test_get_context_from_empty_config(self):
        assert get_context_from_config({}) == ""

    def test_get_rules_for_proposal(self):
        config = {"rules": {"proposal": ["Rule 1", "Rule 2"]}}
        rules = get_rules_for_artifact(config, "proposal")
        assert "- Rule 1" in rules
        assert "- Rule 2" in rules

    def test_get_rules_for_missing_artifact(self):
        config = {"rules": {"proposal": ["Rule 1"]}}
        rules = get_rules_for_artifact(config, "design")
        assert rules == ""

    def test_get_rules_no_rules_section(self):
        config = {"context": "something"}
        rules = get_rules_for_artifact(config, "proposal")
        assert rules == ""

    def test_get_rules_string_value(self):
        config = {"rules": {"proposal": "Single rule as string"}}
        rules = get_rules_for_artifact(config, "proposal")
        assert rules == "Single rule as string"


# ── Service: create_change tests ──────────────────────────────────


class TestCreateChange:
    """Test change directory creation."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> SpecWizardService:
        changes_dir = tmp_path / "openspec" / "changes"
        changes_dir.mkdir(parents=True)
        return SpecWizardService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_creates_directory(self, service: SpecWizardService, tmp_path: Path):
        result = await service.create_change("my-feature", "Build a new feature")
        assert result.created is True
        assert result.name == "my-feature"
        assert (tmp_path / "openspec" / "changes" / "my-feature").is_dir()

    @pytest.mark.asyncio
    async def test_writes_description_file(self, service: SpecWizardService, tmp_path: Path):
        await service.create_change("my-feature", "Build a new feature")
        desc = (tmp_path / "openspec" / "changes" / "my-feature" / ".description").read_text()
        assert desc == "Build a new feature"

    @pytest.mark.asyncio
    async def test_rejects_duplicate(self, service: SpecWizardService, tmp_path: Path):
        await service.create_change("my-feature", "First")
        with pytest.raises(FileExistsError, match="already exists"):
            await service.create_change("my-feature", "Second")

    @pytest.mark.asyncio
    async def test_rejects_invalid_name_spaces(self, service: SpecWizardService):
        with pytest.raises(ValueError, match="Invalid change name"):
            await service.create_change("my feature", "Bad name")

    @pytest.mark.asyncio
    async def test_rejects_invalid_name_uppercase(self, service: SpecWizardService):
        with pytest.raises(ValueError, match="Invalid change name"):
            await service.create_change("MyFeature", "Bad name")

    @pytest.mark.asyncio
    async def test_accepts_single_char_name(self, service: SpecWizardService):
        result = await service.create_change("x", "Single char")
        assert result.created is True

    @pytest.mark.asyncio
    async def test_rejects_hyphen_start(self, service: SpecWizardService):
        with pytest.raises(ValueError, match="Invalid change name"):
            await service.create_change("-bad", "Bad name")


# ── Service: approve_artifact tests ───────────────────────────────


class TestApproveArtifact:
    """Test writing approved content to disk."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> SpecWizardService:
        changes_dir = tmp_path / "openspec" / "changes" / "test-change"
        changes_dir.mkdir(parents=True)
        return SpecWizardService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_writes_proposal(self, service: SpecWizardService, tmp_path: Path):
        await service.approve_artifact("test-change", "proposal", "# Proposal content")
        path = tmp_path / "openspec" / "changes" / "test-change" / "proposal.md"
        assert path.read_text() == "# Proposal content"

    @pytest.mark.asyncio
    async def test_writes_specs(self, service: SpecWizardService, tmp_path: Path):
        await service.approve_artifact("test-change", "specs", "# Specs content")
        path = tmp_path / "openspec" / "changes" / "test-change" / "specs" / "spec.md"
        assert path.read_text() == "# Specs content"

    @pytest.mark.asyncio
    async def test_writes_design(self, service: SpecWizardService, tmp_path: Path):
        await service.approve_artifact("test-change", "design", "# Design content")
        path = tmp_path / "openspec" / "changes" / "test-change" / "design.md"
        assert path.read_text() == "# Design content"

    @pytest.mark.asyncio
    async def test_writes_tasks(self, service: SpecWizardService, tmp_path: Path):
        await service.approve_artifact("test-change", "tasks", "# Tasks content")
        path = tmp_path / "openspec" / "changes" / "test-change" / "tasks.md"
        assert path.read_text() == "# Tasks content"

    @pytest.mark.asyncio
    async def test_rejects_unknown_artifact(self, service: SpecWizardService):
        with pytest.raises(ValueError, match="Unknown artifact type"):
            await service.approve_artifact("test-change", "readme", "content")

    @pytest.mark.asyncio
    async def test_rejects_missing_change(self, service: SpecWizardService):
        with pytest.raises(FileNotFoundError, match="does not exist"):
            await service.approve_artifact("nonexistent", "proposal", "content")


# ── Service: get_wizard_state tests ───────────────────────────────


class TestGetWizardState:
    """Test detecting wizard state from filesystem."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> SpecWizardService:
        changes_dir = tmp_path / "openspec" / "changes" / "test-change"
        changes_dir.mkdir(parents=True)
        return SpecWizardService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_empty_change_returns_step_0(self, service: SpecWizardService):
        state = await service.get_wizard_state("test-change")
        assert state.current_step == 0
        assert all(s.status == "pending" for s in state.steps)
        assert len(state.steps) == 4

    @pytest.mark.asyncio
    async def test_proposal_approved_returns_step_1(self, service: SpecWizardService, tmp_path: Path):
        change_dir = tmp_path / "openspec" / "changes" / "test-change"
        (change_dir / "proposal.md").write_text("# Proposal")
        state = await service.get_wizard_state("test-change")
        assert state.current_step == 1
        assert state.steps[0].status == "approved"
        assert state.steps[0].content == "# Proposal"
        assert state.steps[1].status == "pending"

    @pytest.mark.asyncio
    async def test_all_approved_returns_step_4(self, service: SpecWizardService, tmp_path: Path):
        change_dir = tmp_path / "openspec" / "changes" / "test-change"
        (change_dir / "proposal.md").write_text("# Proposal")
        specs_dir = change_dir / "specs"
        specs_dir.mkdir()
        (specs_dir / "spec.md").write_text("# Specs")
        (change_dir / "design.md").write_text("# Design")
        (change_dir / "tasks.md").write_text("# Tasks")

        state = await service.get_wizard_state("test-change")
        assert state.current_step == 4
        assert all(s.status == "approved" for s in state.steps)

    @pytest.mark.asyncio
    async def test_reads_description(self, service: SpecWizardService, tmp_path: Path):
        change_dir = tmp_path / "openspec" / "changes" / "test-change"
        (change_dir / ".description").write_text("Build user auth")
        state = await service.get_wizard_state("test-change")
        assert state.description == "Build user auth"

    @pytest.mark.asyncio
    async def test_missing_description_returns_none(self, service: SpecWizardService):
        state = await service.get_wizard_state("test-change")
        assert state.description is None

    @pytest.mark.asyncio
    async def test_missing_change_raises(self, service: SpecWizardService):
        with pytest.raises(FileNotFoundError, match="does not exist"):
            await service.get_wizard_state("nonexistent")


# ── Service: build_prompt integration ─────────────────────────────


class TestBuildPrompt:
    """Test build_prompt integrates config with format_prompt."""

    def test_injects_config_context(self, tmp_path: Path):
        config_dir = tmp_path / "openspec"
        config_dir.mkdir()
        (config_dir / "config.yaml").write_text(SAMPLE_CONFIG_YAML)

        service = SpecWizardService(project_path=tmp_path)
        prompt = service.build_prompt("proposal", "Add auth", {})
        assert "TestApp" in prompt

    def test_injects_config_rules(self, tmp_path: Path):
        config_dir = tmp_path / "openspec"
        config_dir.mkdir()
        (config_dir / "config.yaml").write_text(SAMPLE_CONFIG_YAML)

        service = SpecWizardService(project_path=tmp_path)
        prompt = service.build_prompt("proposal", "Add auth", {})
        assert "Include MVP scope boundaries" in prompt

    def test_no_config_still_works(self, tmp_path: Path):
        service = SpecWizardService(project_path=tmp_path)
        prompt = service.build_prompt("proposal", "Add auth", {})
        assert "Add auth" in prompt
        assert "no project context provided" in prompt


# ── Service: _run_agent tests ─────────────────────────────────────


class TestRunAgent:
    """Test agent subprocess execution."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> SpecWizardService:
        return SpecWizardService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_success(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"Generated content", b""))
        mock_proc.returncode = 0

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc):
            result = await service._run_agent("test prompt", "claude-code")
        assert result == "Generated content"

    @pytest.mark.asyncio
    async def test_timeout(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(side_effect=asyncio.TimeoutError())
        mock_proc.kill = MagicMock()
        mock_proc.wait = AsyncMock()

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc):
            with pytest.raises(TimeoutError, match="timed out"):
                await service._run_agent("test prompt", "claude-code", timeout=1)

    @pytest.mark.asyncio
    async def test_failure(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"", b"Error occurred"))
        mock_proc.returncode = 1

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc):
            with pytest.raises(RuntimeError, match="Agent failed"):
                await service._run_agent("test prompt", "claude-code")

    @pytest.mark.asyncio
    async def test_unknown_agent_raises(self, service: SpecWizardService):
        with pytest.raises(ValueError, match="doesn't support"):
            await service._run_agent("test prompt", "unknown-agent")

    @pytest.mark.asyncio
    async def test_codex_agent_type(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"Content", b""))
        mock_proc.returncode = 0

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
            await service._run_agent("test prompt", "codex")
        # Verify codex command format
        call_args = mock_exec.call_args
        assert call_args[0][0] == "codex"
        assert "--quiet" in call_args[0]


# ── Service: generate_artifact tests ──────────────────────────────


class TestGenerateArtifact:
    """Test full generation flow including fallback."""

    @pytest.fixture
    def service(self, tmp_path: Path) -> SpecWizardService:
        return SpecWizardService(project_path=tmp_path)

    @pytest.mark.asyncio
    async def test_returns_content_on_success(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"# Generated proposal", b""))
        mock_proc.returncode = 0

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc):
            result = await service.generate_artifact(
                "test", "proposal", "Add auth", {}
            )
        assert result.content == "# Generated proposal"
        assert result.agent_type == "claude-code"
        assert result.duration_ms >= 0

    @pytest.mark.asyncio
    async def test_returns_prompt_on_failure(self, service: SpecWizardService):
        mock_proc = AsyncMock()
        mock_proc.communicate = AsyncMock(return_value=(b"", b"Agent not found"))
        mock_proc.returncode = 1

        with patch("hangar.services.wizard.asyncio.create_subprocess_exec", return_value=mock_proc):
            result = await service.generate_artifact(
                "test", "proposal", "Add auth", {}
            )
        assert "[Agent failed:" in result.content
        assert "Add auth" in result.prompt_used
