"""Integration tests for codebase-aware decomposition pipeline.

Mocks the LLM (Anthropic API) but lets scanner, validator, and merger
run for real against the actual repository file tree.
"""

from __future__ import annotations

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

import pytest

from loom.config import SkillsConfig
from loom.skills.decomposer import DecompositionResult, decompose

# ── Paths ──────────────────────────────────────────────────────────────

# The repository root (two levels up from this file).
REPO_ROOT = Path(__file__).resolve().parent.parent
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "recorded_anthropic_response.json"


# ── Helpers ────────────────────────────────────────────────────────────


def load_fixture() -> dict:
    """Load the recorded Anthropic response fixture."""
    with open(FIXTURE_PATH) as f:
        return json.load(f)


def assert_valid_dag(tasks: list[dict]) -> None:
    """Verify that the task list forms a valid DAG.

    Checks:
    1. No dependency cycles (DFS-based detection).
    2. All depends_on references point to existing task IDs.
    3. All parent_id references point to existing task IDs (when not None).
    """
    all_ids = {t["id"] for t in tasks}

    # Check referential integrity
    for t in tasks:
        for dep_id in t.get("depends_on", []):
            assert dep_id in all_ids, (
                f"Task {t['id']} depends on {dep_id} which is not in the graph"
            )
        parent_id = t.get("parent_id")
        if parent_id is not None:
            assert parent_id in all_ids, (
                f"Task {t['id']} has parent_id {parent_id} which is not in the graph"
            )

    # DFS cycle detection
    WHITE, GRAY, BLACK = 0, 1, 2
    adj: dict[str, list[str]] = {t["id"]: list(t.get("depends_on", [])) for t in tasks}
    color: dict[str, int] = {tid: WHITE for tid in all_ids}

    def dfs(node: str, path: list[str]) -> None:
        color[node] = GRAY
        path.append(node)
        for neighbor in adj.get(node, []):
            if neighbor not in all_ids:
                continue
            if color[neighbor] == GRAY:
                cycle_start = path.index(neighbor)
                cycle = " -> ".join(path[cycle_start:] + [neighbor])
                raise AssertionError(f"Dependency cycle detected: {cycle}")
            if color[neighbor] == WHITE:
                dfs(neighbor, path)
        path.pop()
        color[node] = BLACK

    for tid in all_ids:
        if color[tid] == WHITE:
            dfs(tid, [])


def _make_mock_anthropic_response(text: str) -> MagicMock:
    """Build a mock Anthropic API response with the given text content."""
    content_block = MagicMock()
    content_block.text = text
    response = MagicMock()
    response.content = [content_block]
    response.stop_reason = "end_turn"
    response.model = "mock-model"
    response.usage = MagicMock(input_tokens=100, output_tokens=500)
    return response


# ── Shared fixtures ────────────────────────────────────────────────────


@pytest.fixture
def mock_config() -> SkillsConfig:
    """A SkillsConfig with a fake API key."""
    return SkillsConfig(api_key="test-key-fake", model="mock-model")


@pytest.fixture
def mock_pool() -> AsyncMock:
    """A mocked asyncpg pool."""
    pool = AsyncMock()
    pool.acquire = MagicMock()
    return pool


@pytest.fixture
def mock_redis() -> AsyncMock:
    """A mocked Redis connection."""
    return AsyncMock()


@pytest.fixture
def patched_llm():
    """Patch AsyncAnthropic to return the fixture data for decompose_project.

    The LLM is called multiple times during decompose (decompose_project,
    write_task_context, define_done, estimate_complexity).  We return the
    fixture for the first call and a neutral JSON dict for enrichment calls.
    """
    fixture = load_fixture()
    fixture_text = json.dumps(fixture)
    enrichment_text = json.dumps({"context": {}, "done_when": "done", "complexity": 5.0, "reasoning": "moderate"})

    mock_client = AsyncMock()

    # First call = decompose_project; subsequent calls = enrichment skills.
    mock_client.messages.create = AsyncMock(
        side_effect=[
            _make_mock_anthropic_response(fixture_text),
        ]
        + [_make_mock_anthropic_response(enrichment_text)] * 50  # plenty for enrichment
    )

    with patch("loom.skills.runner.AsyncAnthropic", return_value=mock_client):
        yield mock_client


# ── Tests ──────────────────────────────────────────────────────────────


class TestDecomposerIntegration:
    """End-to-end integration tests for the decomposition pipeline."""

    @pytest.mark.asyncio
    async def test_mocked_decompose_returns_result(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """Mock LLM, call decompose() with scan_root pointing to the real
        repo root, and confirm we get a DecompositionResult back."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=False,
        )

        assert isinstance(result, DecompositionResult)
        assert result.total_count > 0
        assert len(result.tasks) > 0

    @pytest.mark.asyncio
    async def test_existing_file_tasks_pruned(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """Tasks referencing existing files (loom/__main__.py, loom/scanner.py)
        should be marked pre_completed by the validator or pruned."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=False,
        )

        # The tasks referencing existing files should have been annotated.
        # In "mark" mode (the default in decompose), pruned tasks stay in
        # the list but get context.pruned = True or
        # context.validation_status = "pre_completed".
        existing_file_titles = {
            "Create loom/__main__.py entry point",
            "Implement scan_codebase in loom/scanner.py",
        }

        for task in result.tasks:
            if task["title"] in existing_file_titles:
                ctx = task.get("context", {})
                # Either marked by the validator pass (step 3b) or
                # by the pruner pass (step 5b).
                is_pruned = ctx.get("pruned") is True
                is_pre_completed = ctx.get("validation_status") == "pre_completed"
                assert is_pruned or is_pre_completed, (
                    f"Task '{task['title']}' references an existing file but was "
                    f"not marked as pruned or pre_completed. Context: {ctx}"
                )

    @pytest.mark.asyncio
    async def test_trivial_tasks_merged(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """The 'Add rich dependency' task should be merged into its parent
        when merge_trivial=True and min_complexity is set high enough."""
        # First, run WITHOUT merging to get the baseline count.
        result_no_merge = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=False,
        )

        # Reset the mock for the second call.
        fixture = load_fixture()
        fixture_text = json.dumps(fixture)
        enrichment_text = json.dumps({"context": {}, "done_when": "done", "complexity": 5.0})
        patched_llm.messages.create = AsyncMock(
            side_effect=[
                _make_mock_anthropic_response(fixture_text),
            ]
            + [_make_mock_anthropic_response(enrichment_text)] * 50
        )

        # Now run WITH merging using a high threshold to merge more tasks.
        result_merged = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=True,
            min_complexity=5,  # Higher threshold = more tasks considered trivial
        )

        # The merged result should have fewer tasks.
        assert result_merged.total_count <= result_no_merge.total_count, (
            f"Expected fewer or equal tasks after merge: "
            f"{result_merged.total_count} > {result_no_merge.total_count}"
        )

        # Specifically, "Add rich dependency to pyproject.toml" should be gone.
        merged_titles = {t["title"] for t in result_merged.tasks}
        assert "Add rich dependency to pyproject.toml" not in merged_titles, (
            "Trivial task 'Add rich dependency to pyproject.toml' should have been merged"
        )

    @pytest.mark.asyncio
    async def test_valid_dag_no_cycles(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """The returned task graph should be a valid DAG with no cycles."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=True,
        )

        assert_valid_dag(result.tasks)

    @pytest.mark.asyncio
    async def test_non_redundant_tasks_preserved(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """'Build REST API authentication layer' should survive the pipeline
        because it is genuinely new work not matched by existing code.

        Uses merge_trivial=False so the trivial-task merger does not
        interfere with testing the validator/pruner logic specifically.
        """
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=False,
        )

        titles = {t["title"] for t in result.tasks}
        assert "Build REST API authentication layer" in titles, (
            "Non-redundant task 'Build REST API authentication layer' "
            "was incorrectly removed from the graph"
        )

        # Verify it was NOT pruned.
        for t in result.tasks:
            if t["title"] == "Build REST API authentication layer":
                ctx = t.get("context", {})
                assert ctx.get("pruned") is not True, (
                    "Task should not be pruned — it represents genuinely new work"
                )

    @pytest.mark.asyncio
    async def test_at_least_n_tasks_in_output(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """Guard against vacuous pass: at least 2 tasks in output."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=REPO_ROOT,
            merge_trivial=False,
        )

        assert result.total_count >= 2, (
            f"Expected at least 2 tasks in output, got {result.total_count}"
        )
        assert len(result.tasks) >= 2

    @pytest.mark.asyncio
    async def test_decompose_without_scan_root(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """decompose() without scan_root behaves identically to before
        (backward compat) -- no validation or pruning occurs."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=None,  # No codebase scanning
            merge_trivial=False,
        )

        assert isinstance(result, DecompositionResult)
        # Without scan_root, no validation should have run.
        assert result.validation_summary is None, (
            "Expected no validation_summary when scan_root is None"
        )
        # All 6 tasks from the fixture should be present.
        assert result.total_count == 6
        assert len(result.tasks) == 6

    @pytest.mark.asyncio
    async def test_dependency_edges_preserved(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """Dependency edges from the fixture should be resolved into the graph."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=None,
            merge_trivial=False,
        )

        # The fixture has "Write integration tests for auth" depending on
        # "Build REST API authentication layer".
        auth_task = None
        test_task = None
        for t in result.tasks:
            if t["title"] == "Build REST API authentication layer":
                auth_task = t
            if t["title"] == "Write integration tests for auth":
                test_task = t

        assert auth_task is not None, "Auth task not found in output"
        assert test_task is not None, "Test task not found in output"
        assert auth_task["id"] in test_task["depends_on"], (
            f"Test task should depend on auth task. "
            f"depends_on={test_task['depends_on']}, auth_id={auth_task['id']}"
        )

    @pytest.mark.asyncio
    async def test_epics_identified(
        self, mock_config, mock_pool, mock_redis, patched_llm
    ):
        """The epic from the fixture should appear in result.epics."""
        result = await decompose(
            goal="Build a web application with authentication",
            project_id="test-project-001",
            config=mock_config,
            pool=mock_pool,
            redis=mock_redis,
            confirm=True,
            enrich=False,
            scan_root=None,
            merge_trivial=False,
        )

        assert "Setup project structure" in result.epics


# ── Live test ──────────────────────────────────────────────────────────


@pytest.mark.live
@pytest.mark.asyncio
async def test_live_decompose():
    """Call decompose() for real with the Anthropic API.

    Skip unless ANTHROPIC_API_KEY is set in the environment.
    Assert structural properties only (valid DAG, no cycles, at least 3 tasks).
    """
    api_key = os.environ.get("ANTHROPIC_API_KEY")
    if not api_key:
        pytest.skip("ANTHROPIC_API_KEY not set — skipping live test")

    config = SkillsConfig(api_key=api_key)
    mock_pool = AsyncMock()
    mock_redis = AsyncMock()

    result = await decompose(
        goal="Build a simple CLI task manager with add, list, and complete commands",
        project_id="live-test-project",
        config=config,
        pool=mock_pool,
        redis=mock_redis,
        confirm=True,
        enrich=False,
        scan_root=REPO_ROOT,
        merge_trivial=True,
    )

    assert isinstance(result, DecompositionResult)
    assert result.total_count >= 3, (
        f"Live decompose should produce at least 3 tasks, got {result.total_count}"
    )
    assert_valid_dag(result.tasks)
