"""Tests for the decompose() pipeline — merge_trivial_tasks wiring.

Covers:
  - Default behavior calls merge_trivial_tasks with COMPLEXITY_THRESHOLD
  - min_complexity override threads through correctly
  - min_complexity=0 is respected (not treated as falsy/None)
  - merge_trivial=False skips the merge call entirely
  - Pipeline order: validate_task_graph before merge_trivial_tasks
  - Integration test with real merge_trivial_tasks execution
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from loom.complexity import COMPLEXITY_THRESHOLD
from loom.config import SkillsConfig
from loom.skills.decomposer import decompose


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


def _make_llm_tasks(
    *titles: str,
    parent_map: dict[str, str] | None = None,
    deps_map: dict[str, list[str]] | None = None,
) -> list[dict]:
    """Build a list of raw LLM task dicts (as returned by run_skill)."""
    parent_map = parent_map or {}
    deps_map = deps_map or {}
    tasks = []
    for title in titles:
        t = {
            "title": title,
            "type": "task",
            "priority": "p1",
            "context": {},
            "depends_on_titles": deps_map.get(title, []),
        }
        if title in parent_map:
            t["parent_epic"] = parent_map[title]
        tasks.append(t)
    return tasks


def _make_config() -> SkillsConfig:
    """Build a minimal SkillsConfig for testing."""
    return SkillsConfig(
        api_key="test-key",
        model="test-model",
    )


# ── Fixtures ─────────────────────────────────────────────────────────


@pytest.fixture()
def mock_pool():
    """Fake asyncpg pool — decompose() only touches it via run_skill and store."""
    return MagicMock()


@pytest.fixture()
def mock_redis():
    """Fake Redis connection."""
    return AsyncMock()


@pytest.fixture()
def config():
    return _make_config()


# ── Patch helpers ────────────────────────────────────────────────────

# We need to patch:
# 1. run_skill — to return our fixture tasks
# 2. load_skill — to return a dummy skill definition
# Since enrich=False skips extra LLM calls, we only need those two.


def _patch_run_skill(llm_tasks: list[dict]):
    """Return a context manager that patches run_skill to return *llm_tasks*."""
    async def _fake_run_skill(skill, inputs, config, pool=None, project_id=None):
        return {"tasks": llm_tasks}
    return patch("loom.skills.decomposer.run_skill", side_effect=_fake_run_skill)


def _patch_load_skill():
    """Patch load_skill to return a dummy skill definition."""
    return patch("loom.skills.decomposer.load_skill", return_value=MagicMock())


# ── Tests: default behavior ─────────────────────────────────────────


class TestMergeTrivialDefaultBehavior:
    """By default, decompose() calls merge_trivial_tasks with COMPLEXITY_THRESHOLD."""

    @pytest.mark.asyncio
    async def test_merge_called_with_default_threshold(
        self, mock_pool, mock_redis, config
    ):
        """merge_trivial_tasks is called with COMPLEXITY_THRESHOLD when no override."""
        llm_tasks = _make_llm_tasks("Build auth system", "Deploy to production")

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.merge_trivial_tasks", wraps=None) as mock_merge,
        ):
            # merge_trivial_tasks returns (tasks, report)
            mock_merge.return_value = (
                [{"id": "t1", "title": "Build auth system", "project_id": "p1",
                  "status": "pending", "priority": "p1", "depends_on": [], "context": {}}],
                [],
            )

            await decompose(
                goal="Build a thing",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
            )

            mock_merge.assert_called_once()
            call_kwargs = mock_merge.call_args
            # threshold is passed as a keyword argument
            assert call_kwargs.kwargs.get("threshold") == COMPLEXITY_THRESHOLD


# ── Tests: min_complexity override ───────────────────────────────────


class TestMinComplexityOverride:
    """Passing min_complexity threads through to merge_trivial_tasks."""

    @pytest.mark.asyncio
    async def test_custom_threshold_passed(
        self, mock_pool, mock_redis, config
    ):
        """min_complexity=5 is forwarded as the threshold."""
        llm_tasks = _make_llm_tasks("Refactor cache layer")

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.merge_trivial_tasks") as mock_merge,
        ):
            mock_merge.return_value = (
                [{"id": "t1", "title": "Refactor cache layer", "project_id": "p1",
                  "status": "pending", "priority": "p1", "depends_on": [], "context": {}}],
                [],
            )

            await decompose(
                goal="Refactor",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                min_complexity=5,
            )

            mock_merge.assert_called_once()
            assert mock_merge.call_args.kwargs["threshold"] == 5

    @pytest.mark.asyncio
    async def test_min_complexity_zero_respected(
        self, mock_pool, mock_redis, config
    ):
        """min_complexity=0 must be used as-is, not treated as None/falsy."""
        llm_tasks = _make_llm_tasks("Fix typo in readme")

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.merge_trivial_tasks") as mock_merge,
        ):
            mock_merge.return_value = (
                [{"id": "t1", "title": "Fix typo in readme", "project_id": "p1",
                  "status": "pending", "priority": "p1", "depends_on": [], "context": {}}],
                [],
            )

            await decompose(
                goal="Fix",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                min_complexity=0,
            )

            mock_merge.assert_called_once()
            # The key test: 0 should be passed, not COMPLEXITY_THRESHOLD
            assert mock_merge.call_args.kwargs["threshold"] == 0


# ── Tests: merge_trivial=False ───────────────────────────────────────


class TestMergeTrivialDisabled:
    """Setting merge_trivial=False skips merge_trivial_tasks entirely."""

    @pytest.mark.asyncio
    async def test_merge_not_called_when_disabled(
        self, mock_pool, mock_redis, config
    ):
        """merge_trivial_tasks should not be called when merge_trivial=False."""
        llm_tasks = _make_llm_tasks("Build API layer")

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.merge_trivial_tasks") as mock_merge,
        ):
            await decompose(
                goal="Build API",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                merge_trivial=False,
            )

            mock_merge.assert_not_called()


# ── Tests: pipeline order ────────────────────────────────────────────


class TestPipelineOrder:
    """validate_task_graph must be called before merge_trivial_tasks."""

    @pytest.mark.asyncio
    async def test_validate_before_merge(
        self, mock_pool, mock_redis, config
    ):
        """When codebase_summary is provided, validate_task_graph runs first."""
        from loom.scanner import CodebaseSummary, FileSymbols

        summary = CodebaseSummary(
            root="/tmp/test",
            files=[FileSymbols(path="app.py", functions=["main"], classes=[])],
        )

        llm_tasks = _make_llm_tasks("Deploy to cloud")
        call_order: list[str] = []

        original_validate = None
        original_merge = None

        def track_validate(*args, **kwargs):
            call_order.append("validate_task_graph")
            # Return a minimal ValidationResult
            from loom.validator import ValidationResult
            task_dicts = args[0] if args else kwargs.get("tasks", [])
            return ValidationResult(
                tasks=task_dicts,
                pruned=[],
                folded=[],
                stats={"pre_completed_count": 0, "folded_count": 0, "remaining_count": len(task_dicts)},
            )

        def track_merge(tasks, **kwargs):
            call_order.append("merge_trivial_tasks")
            return (tasks, [])

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.validate_task_graph", side_effect=track_validate),
            patch("loom.skills.decomposer.merge_trivial_tasks", side_effect=track_merge),
        ):
            await decompose(
                goal="Deploy",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                codebase_summary=summary,
            )

        assert "validate_task_graph" in call_order
        assert "merge_trivial_tasks" in call_order
        assert call_order.index("validate_task_graph") < call_order.index("merge_trivial_tasks")


# ── Tests: integration with real merge_trivial_tasks ─────────────────


class TestMergeTrivialIntegration:
    """Integration test — run merge_trivial_tasks for real, not mocked."""

    @pytest.mark.asyncio
    async def test_trivial_tasks_actually_merged(
        self, mock_pool, mock_redis, config
    ):
        """A task graph with trivial child tasks should have them merged.

        Note: Task.model_dump() nests description inside ``context``, but
        ``compute_complexity_score`` reads ``task["description"]`` at the
        top level.  Without a top-level description, all tasks score 1.0,
        which is below COMPLEXITY_THRESHOLD (2).  Epics are never merged,
        but non-epic children with parent_id ARE merged.

        This test verifies the merge step runs end-to-end and reduces
        the task count when tasks have a parent and low complexity.
        """
        llm_tasks = [
            {
                "title": "Infrastructure epic",
                "type": "epic",
                "priority": "p1",
                "context": {},
                "depends_on_titles": [],
            },
            {
                "title": "Add import",
                "type": "task",
                "parent_epic": "Infrastructure epic",
                "priority": "p1",
                "context": {"description": "add import"},
                "depends_on_titles": [],
            },
            {
                "title": "Configure linting rules",
                "type": "task",
                "parent_epic": "Infrastructure epic",
                "priority": "p1",
                "context": {"description": "set up linting"},
                "depends_on_titles": [],
            },
        ]

        with _patch_run_skill(llm_tasks), _patch_load_skill():
            result_merged = await decompose(
                goal="Build infrastructure",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                merge_trivial=True,
            )

        with _patch_run_skill(llm_tasks), _patch_load_skill():
            result_unmerged = await decompose(
                goal="Build infrastructure",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                merge_trivial=False,
            )

        # Without merging, all 3 tasks (epic + 2 children) are present
        assert result_unmerged.total_count == 3

        # With merging, the trivial children should be folded into the epic.
        # The epic is always preserved (type=epic), children are merged.
        assert result_merged.total_count < result_unmerged.total_count

        # The epic must survive
        merged_titles = [t["title"] for t in result_merged.tasks]
        assert "Infrastructure epic" in merged_titles

    @pytest.mark.asyncio
    async def test_high_threshold_prevents_merge(
        self, mock_pool, mock_redis, config
    ):
        """With min_complexity=0, no tasks are considered trivial (all score >= 1.0)."""
        llm_tasks = [
            {
                "title": "Infrastructure epic",
                "type": "epic",
                "priority": "p1",
                "context": {},
                "depends_on_titles": [],
            },
            {
                "title": "Add import statement",
                "type": "task",
                "parent_epic": "Infrastructure epic",
                "priority": "p1",
                "context": {"description": "add import"},
                "depends_on_titles": [],
            },
        ]

        with _patch_run_skill(llm_tasks), _patch_load_skill():
            result = await decompose(
                goal="Build infrastructure",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                merge_trivial=True,
                min_complexity=0,  # All scores >= 1.0, so nothing is trivial
            )

        # Both tasks should be present since threshold is 0
        assert result.total_count == 2

    @pytest.mark.asyncio
    async def test_no_merge_when_disabled(
        self, mock_pool, mock_redis, config
    ):
        """With merge_trivial=False, trivial tasks are preserved."""
        llm_tasks = [
            {
                "title": "Infrastructure epic",
                "type": "epic",
                "priority": "p1",
                "context": {},
                "depends_on_titles": [],
            },
            {
                "title": "Add import statement",
                "type": "task",
                "parent_epic": "Infrastructure epic",
                "priority": "p1",
                "context": {"description": "add import"},
                "depends_on_titles": [],
            },
        ]

        with _patch_run_skill(llm_tasks), _patch_load_skill():
            result = await decompose(
                goal="Build infrastructure",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
                merge_trivial=False,
            )

        # Both tasks should be present since merging is disabled
        assert result.total_count == 2


# ── Tests: backward compatibility ────────────────────────────────────


class TestBackwardCompatibility:
    """Existing callers without the new params should behave identically."""

    @pytest.mark.asyncio
    async def test_no_new_params_defaults_work(
        self, mock_pool, mock_redis, config
    ):
        """Calling decompose() without min_complexity or merge_trivial still works."""
        llm_tasks = _make_llm_tasks("Build API", "Write tests")

        with (
            _patch_run_skill(llm_tasks),
            _patch_load_skill(),
            patch("loom.skills.decomposer.merge_trivial_tasks") as mock_merge,
        ):
            mock_merge.return_value = (
                [
                    {"id": "t1", "title": "Build API", "project_id": "p1",
                     "status": "pending", "priority": "p1", "depends_on": [], "context": {}},
                    {"id": "t2", "title": "Write tests", "project_id": "p1",
                     "status": "pending", "priority": "p1", "depends_on": [], "context": {}},
                ],
                [],
            )

            result = await decompose(
                goal="Build a thing",
                project_id="test-proj",
                config=config,
                pool=mock_pool,
                redis=mock_redis,
                confirm=True,
                enrich=False,
            )

        assert result.total_count == 2
        assert result.written_to_db is False
