"""Tests for decompose proposal caching and loom_confirm flow.

Covers:
  - confirm=True caches proposal in Redis and returns proposal_id
  - confirm_proposal loads cached tasks and writes to DB
  - confirm_proposal deletes the cache key after writing
  - confirm_proposal raises ValueError for expired/missing proposals
  - loom_confirm tool returns error dict for missing proposals
"""

from __future__ import annotations

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

import pytest

from loom.config import SkillsConfig
from loom.skills.decomposer import (
    _DEFAULT_PROPOSAL_TTL,
    DecompositionResult,
    confirm_proposal,
    decompose,
)


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


def _make_config() -> SkillsConfig:
    return SkillsConfig(api_key="test-key", model="test-model")


def _patch_run_skill(titles: list[str]):
    tasks = [{"title": t, "type": "task", "done_when": "tests pass"} for t in titles]

    async def _fake(skill, inputs, config, pool=None, project_id=None):
        return {"tasks": tasks}

    return patch("loom.skills.decomposer.run_skill", side_effect=_fake)


def _patch_load_skill():
    return patch("loom.skills.decomposer.load_skill", return_value=MagicMock())


# ── Tests: proposal caching ─────────────────────────────────────────


class TestProposalCaching:
    """confirm=True stores proposal in Redis."""

    @_patch_run_skill(["Task A", "Task B"])
    @_patch_load_skill()
    async def test_confirm_true_returns_proposal_id(self, _load, _run):
        redis = AsyncMock()
        result = await decompose(
            goal="Build features",
            project_id="proj-1",
            config=_make_config(),
            pool=MagicMock(),
            redis=redis,
            enrich=False,
            confirm=True,
        )
        assert result.proposal_id is not None
        assert result.written_to_db is False
        # redis.set should have been called with the proposal
        redis.set.assert_called_once()
        call_args = redis.set.call_args
        assert call_args.kwargs.get("ex") == _DEFAULT_PROPOSAL_TTL or call_args[1].get("ex") == _DEFAULT_PROPOSAL_TTL

    @_patch_run_skill(["Task A"])
    @_patch_load_skill()
    async def test_confirm_false_no_proposal_id(self, _load, _run):
        redis = AsyncMock()
        with patch("loom.skills.decomposer._write_to_db", new_callable=AsyncMock):
            result = await decompose(
                goal="Build features",
                project_id="proj-1",
                config=_make_config(),
                pool=MagicMock(),
                redis=redis,
                enrich=False,
                confirm=False,
            )
        assert result.proposal_id is None
        assert result.written_to_db is True

    @_patch_run_skill(["Task A", "Task B"])
    @_patch_load_skill()
    async def test_cached_payload_is_valid_json(self, _load, _run):
        redis = AsyncMock()
        result = await decompose(
            goal="Build features",
            project_id="proj-1",
            config=_make_config(),
            pool=MagicMock(),
            redis=redis,
            enrich=False,
            confirm=True,
        )
        # The payload stored in redis.set should be parseable JSON with tasks
        payload = redis.set.call_args[0][1]
        tasks = json.loads(payload)
        assert len(tasks) == 2
        assert tasks[0]["title"] == "Task A"


# ── Tests: confirm_proposal ─────────────────────────────────────────


class TestConfirmProposal:
    """confirm_proposal materializes cached proposals."""

    async def test_confirm_writes_tasks_to_db(self):
        from loom.graph.task import Task, TaskStatus

        tasks = [
            Task(
                id="task-1", project_id="proj-1", title="Task A",
                status=TaskStatus.PENDING, priority="p1",
            ).model_dump(mode="json"),
            Task(
                id="task-2", project_id="proj-1", title="Task B",
                status=TaskStatus.PENDING, priority="p1",
                depends_on=["task-1"],
            ).model_dump(mode="json"),
        ]
        redis = AsyncMock()
        redis.get.return_value = json.dumps(tasks)
        pool = MagicMock()

        with patch("loom.skills.decomposer._write_to_db", new_callable=AsyncMock) as mock_write:
            result = await confirm_proposal(redis, pool, "proj-1", "proposal-123")

        assert result.written_to_db is True
        assert result.proposal_id == "proposal-123"
        assert result.total_count == 2
        assert result.dependency_edges == 1
        mock_write.assert_called_once()
        # Should delete the cached key after writing
        redis.delete.assert_called_once()

    async def test_confirm_expired_proposal_raises(self):
        redis = AsyncMock()
        redis.get.return_value = None

        with pytest.raises(ValueError, match="not found or expired"):
            await confirm_proposal(redis, MagicMock(), "proj-1", "expired-id")

    async def test_confirm_deletes_cache_key(self):
        from loom.graph.task import Task, TaskStatus

        tasks = [
            Task(
                id="task-1", project_id="proj-1", title="Task A",
                status=TaskStatus.PENDING, priority="p1",
            ).model_dump(mode="json"),
        ]
        redis = AsyncMock()
        redis.get.return_value = json.dumps(tasks)

        with patch("loom.skills.decomposer._write_to_db", new_callable=AsyncMock):
            await confirm_proposal(redis, MagicMock(), "proj-1", "prop-456")

        # Key should be deleted after confirmation
        redis.delete.assert_called_once()
        key_arg = redis.delete.call_args[0][0]
        assert "prop-456" in key_arg


# ── Tests: proposal_id in DecompositionResult ────────────────────────


class TestDecompositionResultProposalId:
    """DecompositionResult includes proposal_id field."""

    def test_proposal_id_default_none(self):
        result = DecompositionResult()
        assert result.proposal_id is None

    def test_proposal_id_serializes(self):
        result = DecompositionResult(proposal_id="abc-123")
        d = result.model_dump(mode="json")
        assert d["proposal_id"] == "abc-123"


# ── Tests: concise proposal ─────────────────────────────────────────


class TestConciseProposal:
    """confirm=True strips heavy context from proposal response."""

    @_patch_load_skill()
    async def test_context_stripped_from_proposal(self, _load):
        """Heavy context fields are removed, lightweight ones kept."""
        tasks_output = [
            {
                "title": "Add widget API",
                "type": "task",
                "depends_on_titles": [],
                "priority": "p1",
                "context": {
                    "description": "Build the widget CRUD",
                    "instructions": "Long detailed instructions...",
                    "edge_cases": ["case 1", "case 2"],
                    "testing_strategy": "Unit tests for all endpoints",
                    "files": ["src/widget.py", "tests/test_widget.py"],
                    "complexity": 3,
                    "tdd_recommended": True,
                },
                "done_when": "tests pass",
            },
        ]

        async def _fake(skill, inputs, config, pool=None, project_id=None):
            return {"tasks": tasks_output}

        with patch("loom.skills.decomposer.run_skill", side_effect=_fake):
            redis = AsyncMock()
            result = await decompose(
                goal="Build widgets",
                project_id="proj-1",
                config=_make_config(),
                pool=MagicMock(),
                redis=redis,
                enrich=False,
                confirm=True,
            )

        task = result.tasks[0]
        ctx = task.get("context", {})
        # Kept: lightweight review-useful fields
        assert ctx.get("files") == ["src/widget.py", "tests/test_widget.py"]
        assert ctx.get("complexity") == 3
        assert ctx.get("tdd_recommended") is True
        # Stripped: heavy enrichment fields
        assert "instructions" not in ctx
        assert "edge_cases" not in ctx
        assert "testing_strategy" not in ctx
        assert "description" not in ctx
        # Tree fields preserved
        assert task["title"] == "Add widget API"
        assert task["done_when"] == "tests pass"

    @_patch_load_skill()
    async def test_cached_proposal_has_full_context(self, _load):
        """Redis cache has full context even though response is concise."""
        tasks_output = [
            {
                "title": "Task with detail",
                "type": "task",
                "depends_on_titles": [],
                "context": {"instructions": "Do XYZ", "files": ["a.py"]},
            },
        ]

        async def _fake(skill, inputs, config, pool=None, project_id=None):
            return {"tasks": tasks_output}

        with patch("loom.skills.decomposer.run_skill", side_effect=_fake):
            redis = AsyncMock()
            await decompose(
                goal="Build it",
                project_id="proj-1",
                config=_make_config(),
                pool=MagicMock(),
                redis=redis,
                enrich=False,
                confirm=True,
            )

        # The payload cached in Redis should have the full context
        cached = json.loads(redis.set.call_args[0][1])
        assert cached[0]["context"]["instructions"] == "Do XYZ"
