"""Smoke tests for Loom CLI commands.

These tests run the CLI as a subprocess (just like a real user would)
and do NOT require Docker, Postgres, or Redis.
"""

from __future__ import annotations

import asyncio
import json
import subprocess
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


def _run_loom(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]:
    """Run ``python -m loom <args>`` in the given working directory."""
    return subprocess.run(
        [sys.executable, "-m", "loom", *args],
        cwd=str(cwd),
        capture_output=True,
        text=True,
    )


# ------------------------------------------------------------------
# loom init
# ------------------------------------------------------------------


class TestInit:
    """Tests for the ``loom init`` command."""

    def test_init_creates_loom_directory(self, tmp_path: Path) -> None:
        """Running ``loom init`` scaffolds a ``.loom/`` directory."""
        result = _run_loom("init", cwd=tmp_path)

        assert result.returncode == 0, f"stderr: {result.stderr}"
        assert (tmp_path / ".loom").is_dir()
        assert (tmp_path / ".loom" / "config.yaml").is_file()

    def test_init_idempotent(self, tmp_path: Path) -> None:
        """Running ``loom init`` twice does not error on the second run."""
        first = _run_loom("init", cwd=tmp_path)
        assert first.returncode == 0, f"stderr: {first.stderr}"

        second = _run_loom("init", cwd=tmp_path)
        assert second.returncode == 0, f"stderr: {second.stderr}"
        assert "already initialized" in second.stdout.lower()

    def test_init_creates_mcp_json(self, tmp_path: Path) -> None:
        """After init, ``.mcp.json`` exists at the project root."""
        result = _run_loom("init", cwd=tmp_path)

        assert result.returncode == 0, f"stderr: {result.stderr}"
        assert (tmp_path / ".mcp.json").is_file()


# ------------------------------------------------------------------
# loom up
# ------------------------------------------------------------------


class TestUp:
    """Tests for the ``loom up`` command."""

    def test_up_without_docker_exits_gracefully(self, tmp_path: Path) -> None:
        """``loom up`` in a directory without docker-compose.loom.yml
        exits with a non-zero code and does NOT produce an unhandled
        exception traceback.
        """
        result = _run_loom("up", cwd=tmp_path)

        assert result.returncode != 0
        # Should not contain a Python traceback
        assert "Traceback (most recent call last)" not in result.stderr
        assert "Traceback (most recent call last)" not in result.stdout


# ------------------------------------------------------------------
# loom decompose (mocked LLM + DB)
# ------------------------------------------------------------------


# Deterministic LLM response for decompose_project skill
_DECOMPOSE_LLM_RESPONSE = json.dumps({
    "tasks": [
        {
            "title": "Setup project structure",
            "type": "epic",
            "parent_epic": None,
            "depends_on_titles": [],
            "priority": "p0",
            "context": {},
            "done_when": None,
        },
        {
            "title": "Create database schema",
            "type": "task",
            "parent_epic": "Setup project structure",
            "depends_on_titles": [],
            "priority": "p0",
            "context": {"description": "Design and create the initial DB schema"},
            "done_when": "Schema migration runs successfully",
        },
        {
            "title": "Implement API endpoints",
            "type": "task",
            "parent_epic": "Setup project structure",
            "depends_on_titles": ["Create database schema"],
            "priority": "p1",
            "context": {"description": "Build REST API endpoints"},
            "done_when": "All endpoints return correct responses",
        },
    ]
})


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=10, output_tokens=20)
    return response


class TestDecompose:
    """Tests for the ``loom decompose`` command with mocked LLM and DB."""

    def test_decompose_creates_tasks(self, workspace: Path) -> None:
        """``loom decompose`` with mocked LLM produces tasks and writes them to DB."""
        from loom.skills.decomposer import DecompositionResult
        from loom.graph.task import Task

        # Build a deterministic DecompositionResult to return from mock
        project_id = "00000000-0000-0000-0000-000000000000"
        mock_result = DecompositionResult(
            tasks=[
                {
                    "id": "loom-aaa00001",
                    "project_id": project_id,
                    "title": "Setup project structure",
                    "status": "epic",
                    "priority": "p0",
                    "depends_on": [],
                    "context": {},
                },
                {
                    "id": "loom-aaa00002",
                    "project_id": project_id,
                    "title": "Create database schema",
                    "status": "pending",
                    "priority": "p0",
                    "parent_id": "loom-aaa00001",
                    "depends_on": [],
                    "context": {"description": "Design and create the initial DB schema"},
                    "done_when": "Schema migration runs successfully",
                },
                {
                    "id": "loom-aaa00003",
                    "project_id": project_id,
                    "title": "Implement API endpoints",
                    "status": "blocked",
                    "priority": "p1",
                    "parent_id": "loom-aaa00001",
                    "depends_on": ["loom-aaa00002"],
                    "context": {"description": "Build REST API endpoints"},
                    "done_when": "All endpoints return correct responses",
                },
            ],
            epics=["Setup project structure"],
            total_count=3,
            dependency_edges=1,
            written_to_db=False,
        )

        # Track what got written to DB
        written_tasks = []

        async def fake_write_to_db(tasks, pool, redis, pid):
            written_tasks.extend(tasks)

        mock_pool = AsyncMock()
        mock_pool.close = AsyncMock()
        mock_redis = AsyncMock()
        mock_redis.aclose = AsyncMock()

        with (
            patch("asyncpg.create_pool", new_callable=AsyncMock, return_value=mock_pool),
            patch("redis.asyncio.from_url", return_value=mock_redis),
            patch("loom.skills.decomposer.decompose", new_callable=AsyncMock, return_value=mock_result) as mock_decompose,
            patch("loom.skills.decomposer._write_to_db", side_effect=fake_write_to_db) as mock_write,
        ):
            from loom.cli import _run_decompose
            from loom.config import load_config

            config = load_config(str(workspace))
            config.skills.api_key = "test-key-fake"

            asyncio.run(
                _run_decompose(config, str(workspace), "Build a web app", 3, True, True)
            )

        # The decompose function should have been called
        mock_decompose.assert_called_once()
        call_kwargs = mock_decompose.call_args
        assert call_kwargs[1]["goal"] == "Build a web app" or call_kwargs[0][0] == "Build a web app"

        # _write_to_db should have been called because auto_confirm=True
        mock_write.assert_called_once()

        # Verify the written tasks
        assert len(written_tasks) == 3
        titles = [t.title for t in written_tasks]
        assert "Setup project structure" in titles
        assert "Create database schema" in titles
        assert "Implement API endpoints" in titles

    def test_decompose_with_llm_mock(self, workspace: Path) -> None:
        """Decompose with a fully mocked LLM call returns a deterministic task list."""
        from loom.skills.decomposer import decompose, DecompositionResult

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

        mock_pool = AsyncMock()
        mock_redis = AsyncMock()
        # Mock record_skill_run since pool is fake
        mock_pool.acquire = MagicMock()

        from loom.config import SkillsConfig

        config = SkillsConfig(api_key="test-key-fake", model="mock-model")

        with patch("loom.skills.runner.AsyncAnthropic", return_value=mock_client):
            # Also skip enrichment (it makes additional LLM calls)
            result = asyncio.run(
                decompose(
                    goal="Build a web app",
                    project_id="00000000-0000-0000-0000-000000000000",
                    config=config,
                    pool=mock_pool,
                    redis=mock_redis,
                    confirm=True,  # Don't write to DB
                    enrich=False,
                    merge_trivial=False,  # Preserve all tasks for assertion
                )
            )

        assert isinstance(result, DecompositionResult)
        assert result.total_count == 3
        assert len(result.epics) == 1
        assert result.epics[0] == "Setup project structure"
        assert result.dependency_edges == 1

        # Verify task statuses are correct
        statuses = {t["title"]: t["status"] for t in result.tasks}
        assert statuses["Setup project structure"] == "epic"
        assert statuses["Create database schema"] == "pending"
        assert statuses["Implement API endpoints"] == "blocked"


# ------------------------------------------------------------------
# loom claim (mocked store)
# ------------------------------------------------------------------


class TestClaim:
    """Tests for the claim functionality with mocked store."""

    def test_claim_changes_task_status(self) -> None:
        """Claiming a pending task changes its status to 'claimed'."""
        from loom.graph.task import Task, TaskStatus, Priority

        project_id = "00000000-0000-0000-0000-000000000000"
        task_id = "loom-claim001"

        # Simulate a task that starts as pending
        original_task = Task(
            id=task_id,
            project_id=project_id,
            title="Test task for claiming",
            status=TaskStatus.PENDING,
            priority=Priority.P1,
        )

        # After claim, the task should be claimed with an assignee
        claimed_task = Task(
            id=task_id,
            project_id=project_id,
            title="Test task for claiming",
            status=TaskStatus.CLAIMED,
            priority=Priority.P1,
            assignee="agent-test",
            claimed_at=datetime.now(timezone.utc),
        )

        with patch("loom.graph.store.claim_task", new_callable=AsyncMock, return_value=claimed_task) as mock_claim:
            from loom.graph.store import claim_task

            mock_pool = AsyncMock()
            result = asyncio.run(claim_task(mock_pool, task_id, "agent-test"))

        assert result.status == TaskStatus.CLAIMED
        assert result.assignee == "agent-test"
        assert result.id == task_id
        mock_claim.assert_called_once_with(mock_pool, task_id, "agent-test")

    def test_claim_via_mcp_tool(self) -> None:
        """The loom_claim MCP tool calls store.claim_task and syncs cache."""
        from loom.graph.task import Task, TaskStatus, Priority

        project_id = "00000000-0000-0000-0000-000000000000"
        task_id = "loom-claim002"

        claimed_task = Task(
            id=task_id,
            project_id=project_id,
            title="Task to claim via MCP",
            status=TaskStatus.CLAIMED,
            priority=Priority.P1,
            assignee="agent-mcp",
            claimed_at=datetime.now(timezone.utc),
        )

        # Build a mock AppContext
        mock_pool = AsyncMock()
        mock_redis = AsyncMock()

        from loom.mcp.server import AppContext

        # Need a config with orchestration settings
        mock_config = MagicMock()
        mock_config.orchestration.claim_ttl_seconds = 300

        app = AppContext(
            pool=mock_pool,
            redis=mock_redis,
            project_id=project_id,
            config=mock_config,
        )

        # Mock the Context object
        mock_ctx = MagicMock()
        mock_ctx.request_context.lifespan_context = app

        with (
            patch("loom.mcp.tools.store.claim_task", new_callable=AsyncMock, return_value=claimed_task),
            patch("loom.mcp.tools.cache.sync_task", new_callable=AsyncMock),
            patch("loom.mcp.tools.cache.remove_from_ready_queue", new_callable=AsyncMock),
            patch("loom.mcp.tools.publish_event", new_callable=AsyncMock),
            patch("loom.mcp.tools.store.record_event", new_callable=AsyncMock),
        ):
            from loom.mcp.tools import loom_claim

            result = asyncio.run(loom_claim(mock_ctx, task_id, "agent-mcp"))

        assert result["status"] == "claimed"
        assert result["assignee"] == "agent-mcp"
        assert result["id"] == task_id


# ------------------------------------------------------------------
# loom done (mocked store)
# ------------------------------------------------------------------


class TestDone:
    """Tests for the done functionality with mocked store."""

    def test_done_changes_task_status(self) -> None:
        """Completing a claimed task changes its status to 'done'."""
        from loom.graph.task import Task, TaskStatus, Priority

        project_id = "00000000-0000-0000-0000-000000000000"
        task_id = "loom-done0001"

        # After completion, the task should be done with output
        done_task = Task(
            id=task_id,
            project_id=project_id,
            title="Test task for completion",
            status=TaskStatus.DONE,
            priority=Priority.P1,
            assignee="agent-test",
            output={"summary": "Task completed successfully"},
            done_at=datetime.now(timezone.utc),
        )

        with patch(
            "loom.graph.store.complete_task",
            new_callable=AsyncMock,
            return_value=done_task,
        ) as mock_complete:
            from loom.graph.store import complete_task

            mock_pool = AsyncMock()
            result = asyncio.run(
                complete_task(mock_pool, task_id, {"summary": "Task completed successfully"})
            )

        assert result.status == TaskStatus.DONE
        assert result.output == {"summary": "Task completed successfully"}
        assert result.id == task_id
        mock_complete.assert_called_once_with(
            mock_pool, task_id, {"summary": "Task completed successfully"}
        )

    def test_done_via_mcp_tool(self) -> None:
        """The loom_done MCP tool calls store.complete_task and syncs cache."""
        from loom.graph.task import Task, TaskStatus, Priority

        project_id = "00000000-0000-0000-0000-000000000000"
        task_id = "loom-done0002"

        done_task = Task(
            id=task_id,
            project_id=project_id,
            title="Task to complete via MCP",
            status=TaskStatus.DONE,
            priority=Priority.P1,
            assignee="agent-mcp",
            output={"result": "all tests passed"},
            done_at=datetime.now(timezone.utc),
        )

        # Build a mock AppContext
        mock_pool = AsyncMock()
        mock_redis = AsyncMock()

        from loom.mcp.server import AppContext

        mock_config = MagicMock()
        mock_config.orchestration.claim_ttl_seconds = 300

        app = AppContext(
            pool=mock_pool,
            redis=mock_redis,
            project_id=project_id,
            config=mock_config,
        )

        # Mock the Context object
        mock_ctx = MagicMock()
        mock_ctx.request_context.lifespan_context = app

        # loom_done reads the task for verify_command check (unless skip_verify=True)
        # We need to mock cache.get_task to return a task with no verify_command
        with (
            patch("loom.mcp.tools.cache.get_task", new_callable=AsyncMock, return_value=done_task),
            patch("loom.mcp.tools.store.complete_task", new_callable=AsyncMock, return_value=done_task),
            patch("loom.mcp.tools.cache.sync_task", new_callable=AsyncMock),
            patch("loom.mcp.tools.cache.remove_from_ready_queue", new_callable=AsyncMock),
            patch("loom.mcp.tools.publish_event", new_callable=AsyncMock),
            patch("loom.mcp.tools.store.record_event", new_callable=AsyncMock),
            patch("loom.mcp.tools.deps.check_and_unblock", new_callable=AsyncMock),
            patch("loom.mcp.tools.Metrics") as mock_metrics_cls,
        ):
            mock_metrics_cls.return_value.inc = MagicMock()

            from loom.mcp.tools import loom_done

            result = asyncio.run(
                loom_done(mock_ctx, task_id, {"result": "all tests passed"})
            )

        assert result["status"] == "done"
        assert result["output"] == {"result": "all tests passed"}
        assert result["id"] == task_id

    def test_full_workflow_assertions(self, workspace: Path) -> None:
        """After marking a task done, status command exits 0 and shows count."""
        # Verify that the status CLI command does not crash after a task
        # has been completed. We mock the entire async status flow.
        # Uses Click CliRunner (in-process) so that patches are effective.
        from click.testing import CliRunner as _CliRunner
        from loom.cli import cli
        from loom.graph.task import ProjectStatus

        mock_ps = ProjectStatus(
            project_id="00000000-0000-0000-0000-000000000000",
            project_name="e2e-test",
            total=3,
            pending=0,
            claimed=0,
            done=2,
            failed=0,
            blocked=0,
            epic=1,
            ready_count=0,
        )

        mock_pool = AsyncMock()
        mock_pool.close = AsyncMock()

        runner = _CliRunner()

        import os
        env_backup = os.getcwd()
        try:
            os.chdir(workspace)
            with (
                patch("asyncpg.create_pool", new_callable=AsyncMock, return_value=mock_pool),
                patch("loom.graph.project.get_project_status", new_callable=AsyncMock, return_value=mock_ps),
                patch("loom.graph.project.get_project", new_callable=AsyncMock, return_value={"created_at": None}),
            ):
                result = runner.invoke(cli, ["status"])
        finally:
            os.chdir(env_backup)

        # The status command should exit successfully
        assert result.exit_code == 0, f"output: {result.output}\nexception: {result.exception}"
        # Output should contain completion count (2 done out of 2 actionable)
        assert "2" in result.output
