"""Phase 2 integration tests — skills, decomposition, and enrichment."""

from __future__ import annotations

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

import pytest

from loom.config import SkillsConfig
from loom.exceptions import DecompositionError, SkillNotFoundError
from loom.skills.loader import (
    BUILTIN_DIR,
    discover_skills,
    load_skill,
    parse_skill_file,
)
from loom.skills.runner import _extract_json


# -- Skill loader tests --


def test_discover_builtin_skills():
    """All 4 built-in skills should be discoverable."""
    skills = discover_skills()
    assert "decompose_project" in skills
    assert "write_task_context" in skills
    assert "define_done" in skills
    assert "estimate_complexity" in skills
    assert len(skills) >= 4


def test_parse_skill_file():
    """YAML frontmatter and prompt body are parsed correctly."""
    path = BUILTIN_DIR / "decompose_project.md"
    skill = parse_skill_file(path)
    assert skill.name == "decompose_project"
    assert skill.version == "1.0"
    assert "goal" in skill.inputs
    assert skill.model == "claude-opus-4-6"
    assert skill.temperature == 0.2
    assert "{{ goal }}" in skill.prompt_template
    assert skill.source_path == str(path)


def test_load_skill_not_found():
    """SkillNotFoundError raised for missing skill."""
    with pytest.raises(SkillNotFoundError):
        load_skill("nonexistent_skill_xyz")


def test_parse_all_builtin_skills():
    """Every built-in skill file parses without error."""
    for f in BUILTIN_DIR.glob("*.md"):
        skill = parse_skill_file(f)
        assert skill.name
        assert skill.prompt_template


# -- JSON extraction tests --


def test_extract_json_plain():
    """Extract JSON from plain text."""
    result = _extract_json('{"key": "value"}')
    assert result == {"key": "value"}


def test_extract_json_fenced():
    """Extract JSON from fenced code block."""
    text = 'Some text\n```json\n{"tasks": [1, 2]}\n```\nMore text'
    result = _extract_json(text)
    assert result == {"tasks": [1, 2]}


def test_extract_json_embedded():
    """Extract JSON from embedded braces in prose."""
    text = 'Here is the result: {"status": "ok", "count": 3} and more stuff.'
    result = _extract_json(text)
    assert result == {"status": "ok", "count": 3}


def test_extract_json_array():
    """Extract JSON array."""
    result = _extract_json('[1, 2, 3]')
    assert result == [1, 2, 3]


def test_extract_json_failure():
    """SkillError on unparseable text."""
    from loom.exceptions import SkillError
    with pytest.raises(SkillError):
        _extract_json("no json here at all")


# -- Decomposer tests (LLM mocked) --


MOCK_DECOMPOSE_OUTPUT = {
    "tasks": [
        {
            "title": "Backend API",
            "type": "epic",
            "parent_epic": None,
            "depends_on_titles": [],
            "priority": "p0",
            "context": {"description": "Build the REST API"},
            "done_when": "All API endpoints implemented and tested",
        },
        {
            "title": "Set up project structure",
            "type": "task",
            "parent_epic": "Backend API",
            "depends_on_titles": [],
            "priority": "p0",
            "context": {"description": "Initialize the project"},
            "done_when": "Project scaffolded with dependencies",
        },
        {
            "title": "Implement user endpoints",
            "type": "task",
            "parent_epic": "Backend API",
            "depends_on_titles": ["Set up project structure"],
            "priority": "p1",
            "context": {"description": "CRUD endpoints for users"},
            "done_when": "User CRUD endpoints pass integration tests",
        },
        {
            "title": "Add authentication",
            "type": "task",
            "parent_epic": "Backend API",
            "depends_on_titles": ["Implement user endpoints"],
            "priority": "p1",
            "context": {"description": "JWT auth middleware"},
            "done_when": "Auth middleware protects all endpoints",
        },
    ],
}


def _mock_anthropic():
    """Create a mock AsyncAnthropic that returns MOCK_DECOMPOSE_OUTPUT."""
    mock_response = MagicMock()
    mock_response.content = [MagicMock(text=json.dumps(MOCK_DECOMPOSE_OUTPUT))]
    mock_response.usage.input_tokens = 100
    mock_response.usage.output_tokens = 200

    mock_client = AsyncMock()
    mock_client.messages.create = AsyncMock(return_value=mock_response)

    mock_cls = MagicMock(return_value=mock_client)
    return mock_cls


@pytest.mark.asyncio
async def test_decomposer_confirm_mode(pool, redis_conn, project):
    """Confirm mode returns proposed tasks without writing to DB."""
    config = SkillsConfig(api_key="test-key")

    with patch("loom.skills.runner.AsyncAnthropic", _mock_anthropic()):
        from loom.skills.decomposer import decompose
        result = await decompose(
            goal="Build a REST API",
            project_id=project,
            config=config,
            pool=pool,
            redis=redis_conn,
            confirm=True,
            enrich=False,
            merge_trivial=False,
            merge_file_affinity=False,
        )

    assert result.written_to_db is False
    assert result.total_count == 4
    assert len(result.epics) == 1
    assert result.epics[0] == "Backend API"
    assert result.dependency_edges == 2  # two tasks have deps

    # Verify statuses
    statuses = {t["title"]: t["status"] for t in result.tasks}
    assert statuses["Backend API"] == "epic"
    assert statuses["Set up project structure"] == "pending"
    assert statuses["Implement user endpoints"] == "blocked"
    assert statuses["Add authentication"] == "blocked"


@pytest.mark.asyncio
async def test_decomposer_writes_to_db(pool, redis_conn, project):
    """With confirm=False, tasks are written to Postgres."""
    config = SkillsConfig(api_key="test-key")

    with patch("loom.skills.runner.AsyncAnthropic", _mock_anthropic()):
        from loom.skills.decomposer import decompose
        result = await decompose(
            goal="Build a REST API",
            project_id=project,
            config=config,
            pool=pool,
            redis=redis_conn,
            confirm=False,
            enrich=False,
            merge_trivial=False,
            merge_file_affinity=False,
        )

    assert result.written_to_db is True

    # Verify tasks exist in Postgres
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT * FROM tasks WHERE project_id = $1::uuid", project
        )
    assert len(rows) == 4

    titles = {r["title"] for r in rows}
    assert "Backend API" in titles
    assert "Set up project structure" in titles
    assert "Implement user endpoints" in titles
    assert "Add authentication" in titles

    # Verify deps in Postgres
    async with pool.acquire() as conn:
        deps = await conn.fetch(
            """
            SELECT td.* FROM task_deps td
            JOIN tasks t ON t.id = td.task_id
            WHERE t.project_id = $1::uuid
            """,
            project,
        )
    assert len(deps) == 2

    # Verify an event was recorded
    async with pool.acquire() as conn:
        events = await conn.fetch(
            "SELECT * FROM events WHERE project_id = $1::uuid AND event_type = $2",
            project, "project.decomposed",
        )
    assert len(events) == 1


@pytest.mark.asyncio
async def test_decomposer_cycle_detection(pool, redis_conn, project):
    """DecompositionError raised when task graph has cycles."""
    cyclic_output = {
        "tasks": [
            {
                "title": "Task A",
                "type": "task",
                "depends_on_titles": ["Task B"],
                "priority": "p1",
                "context": {},
                "done_when": "Done",
            },
            {
                "title": "Task B",
                "type": "task",
                "depends_on_titles": ["Task A"],
                "priority": "p1",
                "context": {},
                "done_when": "Done",
            },
        ],
    }

    mock_response = MagicMock()
    mock_response.content = [MagicMock(text=json.dumps(cyclic_output))]
    mock_response.usage.input_tokens = 50
    mock_response.usage.output_tokens = 100

    mock_client = AsyncMock()
    mock_client.messages.create = AsyncMock(return_value=mock_response)
    mock_cls = MagicMock(return_value=mock_client)

    config = SkillsConfig(api_key="test-key")

    with patch("loom.skills.runner.AsyncAnthropic", mock_cls):
        from loom.skills.decomposer import decompose
        with pytest.raises(DecompositionError, match="cycle"):
            await decompose(
                goal="Cyclic test",
                project_id=project,
                config=config,
                pool=pool,
                redis=redis_conn,
                confirm=True,
                enrich=False,
            )


@pytest.mark.asyncio
async def test_skill_run_logged(pool, redis_conn, project):
    """Skill execution is logged to skill_runs table."""
    config = SkillsConfig(api_key="test-key")

    with patch("loom.skills.runner.AsyncAnthropic", _mock_anthropic()):
        from loom.skills.decomposer import decompose
        await decompose(
            goal="Build something",
            project_id=project,
            config=config,
            pool=pool,
            redis=redis_conn,
            confirm=True,
            enrich=False,
        )

    async with pool.acquire() as conn:
        runs = await conn.fetch(
            "SELECT * FROM skill_runs WHERE project_id = $1::uuid", project
        )
    assert len(runs) >= 1
    assert runs[0]["skill_name"] == "decompose_project"
    assert runs[0]["tokens_used"] == 300  # 100 input + 200 output
    assert runs[0]["model"] == "claude-opus-4-6"


@pytest.mark.asyncio
async def test_decomposer_unknown_deps_dropped(pool, redis_conn, project):
    """Unknown dependency titles are silently dropped (hallucination resilience)."""
    output = {
        "tasks": [
            {
                "title": "Real Task",
                "type": "task",
                "depends_on_titles": ["Nonexistent Task", "Also Fake"],
                "priority": "p1",
                "context": {},
                "done_when": "Done",
            },
        ],
    }

    mock_response = MagicMock()
    mock_response.content = [MagicMock(text=json.dumps(output))]
    mock_response.usage.input_tokens = 50
    mock_response.usage.output_tokens = 100
    mock_client = AsyncMock()
    mock_client.messages.create = AsyncMock(return_value=mock_response)
    mock_cls = MagicMock(return_value=mock_client)

    config = SkillsConfig(api_key="test-key")

    with patch("loom.skills.runner.AsyncAnthropic", mock_cls):
        from loom.skills.decomposer import decompose
        result = await decompose(
            goal="Test",
            project_id=project,
            config=config,
            pool=pool,
            redis=redis_conn,
            confirm=True,
            enrich=False,
        )

    assert result.total_count == 1
    assert result.dependency_edges == 0
    # Task should be pending since its deps were dropped
    assert result.tasks[0]["status"] == "pending"
