"""Tests for ToolExecutor lifecycle, adapter coverage, and job processing.

Validates:
  - Executor auto-start/stop
  - All registered tools have adapters
  - Job state transitions: queued → running → done/failed
  - Ticket sync after job execution
  - ShellAdapter and VSCodeAdapter behavior
"""

import asyncio
import time
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from proxym.dashboard.tickets import Ticket, TicketType, TicketPriority, JobExecution
from proxym.tools import ToolCategory, ToolDefinition, ToolRegistry
from proxym.tools.adapters import (
    AiderAdapter,
    BaseAdapter,
    ClaudeCodeAdapter,
    ShellAdapter,
    ToolResult,
    VSCodeAdapter,
    _ADAPTER_MAP,
    get_adapter,
    register_adapter,
)
from proxym.tools.executor import JobStatus, ToolExecutor, ToolJob


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

@pytest.fixture(autouse=True)
def reset_singletons():
    """Reset singletons between tests."""
    ToolRegistry._instance = None
    ToolExecutor._instance = None
    yield
    ToolRegistry._instance = None
    ToolExecutor._instance = None


@pytest.fixture
def registry(monkeypatch):
    """Registry with all tools force-available."""
    monkeypatch.setenv("PROXYM_TOOLS_AVAILABLE", "*")
    return ToolRegistry.get()


@pytest.fixture
def sample_ticket():
    return Ticket(
        task="Fix the login bug in auth module",
        type=TicketType.BUG,
        priority=TicketPriority.MEDIUM,
        tags=["test"],
        files=["src/auth.py"],
    )


# ══════════════════════════════════════════════════════════
# 1. Adapter Coverage — every registered tool has an adapter
# ══════════════════════════════════════════════════════════

class TestAdapterCoverage:
    """Every builtin tool must have a registered adapter."""

    def test_all_builtins_have_adapters(self, registry):
        """Critical: no tool should return None from get_adapter()."""
        for tool in registry.list_tools():
            adapter = get_adapter(tool.name)
            assert adapter is not None, (
                f"Tool '{tool.name}' has no adapter in _ADAPTER_MAP. "
                f"Add it to adapters.py _ADAPTER_MAP."
            )

    def test_adapter_map_matches_registry(self, registry):
        """_ADAPTER_MAP should cover all builtin tool names."""
        registry_names = {t.name for t in registry.list_tools()}
        adapter_names = set(_ADAPTER_MAP.keys())
        missing = registry_names - adapter_names
        assert not missing, f"Tools without adapters: {missing}"

    def test_aider_adapter_type(self, registry):
        adapter = get_adapter("aider")
        assert isinstance(adapter, AiderAdapter)

    def test_claude_code_adapter_type(self, registry):
        adapter = get_adapter("claude-code")
        assert isinstance(adapter, ClaudeCodeAdapter)

    def test_vscode_adapter_type(self, registry):
        adapter = get_adapter("vscode")
        assert isinstance(adapter, VSCodeAdapter)

    def test_shell_adapter_tools(self, registry):
        for name in ("roo-code", "cline", "windsurf", "cursor", "browser"):
            adapter = get_adapter(name)
            assert isinstance(adapter, ShellAdapter), f"{name} should use ShellAdapter"

    def test_get_adapter_missing_tool(self):
        assert get_adapter("nonexistent-tool-xyz") is None

    def test_register_custom_adapter(self, registry):
        class CustomAdapter(BaseAdapter):
            def build_args(self, prompt, project_dir, mode="", model="", files=None):
                return ["echo", prompt]

        register_adapter("custom-test", CustomAdapter)
        assert "custom-test" in _ADAPTER_MAP
        # Cleanup
        del _ADAPTER_MAP["custom-test"]


# ══════════════════════════════════════════════════════════
# 2. Adapter Build Args
# ══════════════════════════════════════════════════════════

class TestAdapterBuildArgs:
    def test_aider_basic_args(self, registry):
        adapter = get_adapter("aider")
        args = adapter.build_args("Fix bug", "/tmp/project")
        assert args[0] == "aider"
        assert "--yes-always" in args
        assert "--message" in args
        assert "Fix bug" in args

    def test_aider_architect_mode(self, registry):
        adapter = get_adapter("aider")
        args = adapter.build_args("Design", "/tmp", mode="architect")
        assert "--architect" in args

    def test_aider_with_model(self, registry):
        adapter = get_adapter("aider")
        args = adapter.build_args("Fix", "/tmp", model="gpt-4")
        assert "--model" in args
        assert "gpt-4" in args

    def test_aider_with_files(self, registry):
        adapter = get_adapter("aider")
        args = adapter.build_args("Fix", "/tmp", files=["a.py", "b.py"])
        assert "a.py" in args
        assert "b.py" in args

    def test_claude_code_basic_args(self, registry):
        adapter = get_adapter("claude-code")
        args = adapter.build_args("Fix bug", "/tmp/project")
        assert args[0] == "claude"
        assert "--print" in args
        assert "Fix bug" in args

    def test_claude_code_with_model(self, registry):
        adapter = get_adapter("claude-code")
        args = adapter.build_args("Fix", "/tmp", model="opus")
        assert "--model" in args
        assert "opus" in args

    def test_vscode_adapter_args(self, registry):
        adapter = get_adapter("vscode")
        args = adapter.build_args("Fix bug", "/tmp/project")
        assert "docker" in args[0]
        assert "vscode" in args

    def test_shell_adapter_no_binary_returns_empty(self, registry):
        adapter = get_adapter("roo-code")
        with patch("shutil.which", return_value=None):
            args = adapter.build_args("Fix bug", "/tmp/project")
        # When binary not found, build_args returns [] (LLM fallback handles execution)
        assert args == []


# ══════════════════════════════════════════════════════════
# 3. Executor Lifecycle
# ══════════════════════════════════════════════════════════

class TestExecutorLifecycle:
    def test_singleton(self):
        e1 = ToolExecutor.get()
        e2 = ToolExecutor.get()
        assert e1 is e2

    @pytest.mark.asyncio
    async def test_start_stop(self):
        executor = ToolExecutor.get()
        assert executor._running is False
        await executor.start()
        assert executor._running is True
        await executor.stop()
        assert executor._running is False

    @pytest.mark.asyncio
    async def test_start_idempotent(self):
        executor = ToolExecutor.get()
        await executor.start()
        task1 = executor._worker_task
        await executor.start()  # second call should be no-op
        assert executor._worker_task is task1
        await executor.stop()

    @pytest.mark.asyncio
    async def test_stop_idempotent(self):
        executor = ToolExecutor.get()
        await executor.stop()  # should not raise even if not started
        assert executor._running is False

    def test_queue_summary_initial(self):
        executor = ToolExecutor.get()
        summary = executor.queue_summary()
        assert summary["total_jobs"] == 0
        assert summary["running"] is False
        assert summary["queue_size"] == 0


# ══════════════════════════════════════════════════════════
# 4. Job Enqueue & State Transitions
# ══════════════════════════════════════════════════════════

class TestJobEnqueue:
    def test_enqueue_creates_job(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("roo-code")
        job = executor.enqueue(sample_ticket, tool, "/tmp/project")
        assert job.status == JobStatus.QUEUED
        assert job.ticket_id == sample_ticket.id
        assert job.tool_name == "roo-code"
        assert job.prompt  # should have generated prompt

    def test_enqueue_stores_in_jobs(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")
        assert executor.get_job(job.id) is job

    def test_list_jobs(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")
        jobs = executor.list_jobs()
        assert any(j.id == job.id for j in jobs)

    def test_list_jobs_by_status(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")
        queued = executor.list_jobs(status=JobStatus.QUEUED)
        assert any(j.id == job.id for j in queued)
        done = executor.list_jobs(status=JobStatus.DONE)
        assert not any(j.id == job.id for j in done)

    def test_cancel_job(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")
        assert executor.cancel_job(job.id) is True
        assert job.status == JobStatus.CANCELLED

    def test_cancel_nonexistent_job(self):
        executor = ToolExecutor.get()
        assert executor.cancel_job("nonexistent-id") is False

    def test_get_jobs_for_ticket(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        executor.enqueue(sample_ticket, tool, "/tmp")
        executor.enqueue(sample_ticket, tool, "/tmp")
        jobs = executor.get_jobs_for_ticket(sample_ticket.id)
        assert len(jobs) == 2


class TestJobSummary:
    def test_summary_fields(self):
        job = ToolJob(
            ticket_id="TKT-TEST",
            tool_name="aider",
            mode="code",
        )
        s = job.summary()
        assert s["id"] == job.id
        assert s["ticket_id"] == "TKT-TEST"
        assert s["tool"] == "aider"
        assert s["status"] == "queued"
        assert s["duration_s"] == 0

    def test_summary_with_result(self):
        job = ToolJob(
            ticket_id="TKT-TEST",
            tool_name="aider",
            started_at=100.0,
            finished_at=105.5,
            status=JobStatus.DONE,
            result=ToolResult(
                tool="aider", ticket_id="TKT-TEST", exit_code=0,
                stdout="done", duration_s=5.5,
            ),
        )
        s = job.summary()
        assert s["status"] == "done"
        assert s["duration_s"] == 5.5
        assert s["result"]["success"] is True


# ══════════════════════════════════════════════════════════
# 5. Job Execution (mocked)
# ══════════════════════════════════════════════════════════

class TestJobExecution:
    @pytest.mark.asyncio
    async def test_execute_job_success(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("roo-code")
        job = executor.enqueue(sample_ticket, tool, "/tmp")

        mock_result = ToolResult(
            tool="roo-code", ticket_id=sample_ticket.id,
            exit_code=0, stdout="Task completed", duration_s=1.0,
        )

        with patch("proxym.tools.executor.get_adapter") as mock_get:
            mock_adapter = AsyncMock()
            mock_adapter.run.return_value = mock_result
            mock_get.return_value = mock_adapter

            await executor._execute_job(job)

        assert job.status == JobStatus.DONE
        assert job.started_at > 0
        assert job.finished_at > job.started_at
        assert job.result is mock_result

    @pytest.mark.asyncio
    async def test_execute_job_failure(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")

        mock_result = ToolResult(
            tool="aider", ticket_id=sample_ticket.id,
            exit_code=1, stderr="Error: file not found", duration_s=0.5,
        )

        with patch("proxym.tools.executor.get_adapter") as mock_get:
            mock_adapter = AsyncMock()
            mock_adapter.run.return_value = mock_result
            mock_get.return_value = mock_adapter

            await executor._execute_job(job)

        assert job.status == JobStatus.FAILED
        assert "file not found" in job.error

    @pytest.mark.asyncio
    async def test_execute_job_no_adapter(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")

        with patch("proxym.tools.executor.get_adapter", return_value=None):
            await executor._execute_job(job)

        assert job.status == JobStatus.FAILED
        assert "No adapter" in job.error

    @pytest.mark.asyncio
    async def test_execute_job_exception(self, registry, sample_ticket):
        executor = ToolExecutor.get()
        tool = registry.get_tool("aider")
        job = executor.enqueue(sample_ticket, tool, "/tmp")

        with patch("proxym.tools.executor.get_adapter") as mock_get:
            mock_adapter = AsyncMock()
            mock_adapter.run.side_effect = RuntimeError("connection lost")
            mock_get.return_value = mock_adapter

            await executor._execute_job(job)

        assert job.status == JobStatus.FAILED
        assert "connection lost" in job.error

    @pytest.mark.asyncio
    async def test_worker_loop_processes_job(self, registry, sample_ticket):
        """Verify the worker loop picks up and processes a queued job."""
        executor = ToolExecutor.get()
        tool = registry.get_tool("roo-code")

        mock_result = ToolResult(
            tool="roo-code", ticket_id=sample_ticket.id,
            exit_code=0, stdout="OK", duration_s=0.1,
        )

        with patch("proxym.tools.executor.get_adapter") as mock_get:
            mock_adapter = AsyncMock()
            mock_adapter.run.return_value = mock_result
            mock_get.return_value = mock_adapter

            await executor.start()
            job = executor.enqueue(sample_ticket, tool, "/tmp")

            # Wait for job to be processed
            for _ in range(20):
                await asyncio.sleep(0.1)
                if job.status != JobStatus.QUEUED:
                    break

            await executor.stop()

        assert job.status == JobStatus.DONE


# ══════════════════════════════════════════════════════════
# 6. ToolResult
# ══════════════════════════════════════════════════════════

class TestToolResult:
    def test_success_on_zero_exit(self):
        r = ToolResult(tool="x", ticket_id="t", exit_code=0)
        assert r.success is True

    def test_failure_on_nonzero_exit(self):
        r = ToolResult(tool="x", ticket_id="t", exit_code=1)
        assert r.success is False

    def test_failure_on_negative_exit(self):
        r = ToolResult(tool="x", ticket_id="t", exit_code=-1)
        assert r.success is False

    def test_summary(self):
        r = ToolResult(
            tool="aider", ticket_id="TKT-1",
            exit_code=0, stdout="line1\nline2", stderr="",
            duration_s=2.345, files_changed=["a.py"],
        )
        s = r.summary()
        assert s["success"] is True
        assert s["stdout_lines"] == 2
        assert s["files_changed"] == ["a.py"]
        assert s["duration_s"] == 2.35


# ══════════════════════════════════════════════════════════
# 7. ShellAdapter run
# ══════════════════════════════════════════════════════════

class TestShellAdapterRun:
    @pytest.mark.asyncio
    async def test_llm_fallback_dispatch(self, registry):
        """When binary not found, ShellAdapter delegates to LLM fallback."""
        mock_result = ToolResult(
            tool="roo-code", ticket_id="TKT-TEST",
            exit_code=0, stdout="LLM analysis of project...", duration_s=2.0,
            execution_metadata={"adapter": "llm_fallback", "model": "test-model"},
        )
        adapter = get_adapter("roo-code")
        with patch("shutil.which", return_value=None), \
             patch("proxym.tools.adapters._llm_fallback_execute", new_callable=AsyncMock, return_value=mock_result):
            result = await adapter.run(
                prompt="Analyze project",
                project_dir="/tmp",
                ticket_id="TKT-TEST",
            )
        assert result.exit_code == 0
        assert result.execution_metadata.get("adapter") == "llm_fallback"
        assert result.duration_s > 0

    @pytest.mark.asyncio
    async def test_browser_llm_fallback(self, registry):
        """Browser tool also uses LLM fallback when binary missing."""
        mock_result = ToolResult(
            tool="browser", ticket_id="TKT-B",
            exit_code=0, stdout="LLM review of code...", duration_s=1.5,
            execution_metadata={"adapter": "llm_fallback", "model": "test-model"},
        )
        adapter = get_adapter("browser")
        with patch("shutil.which", return_value=None), \
             patch("proxym.tools.adapters._llm_fallback_execute", new_callable=AsyncMock, return_value=mock_result):
            result = await adapter.run(
                prompt="Review code",
                project_dir="/tmp",
                ticket_id="TKT-B",
            )
        assert result.exit_code == 0
        assert "browser" in result.tool


# ══════════════════════════════════════════════════════════
# 8. VSCodeAdapter
# ══════════════════════════════════════════════════════════

class TestVSCodeAdapter:
    def test_find_compose_dir(self, registry):
        adapter = get_adapter("vscode")
        # Should find the repo root which has docker-compose.yml
        repo_root = Path(__file__).resolve().parent.parent
        if (repo_root / "docker-compose.yml").exists():
            result = adapter._find_compose_dir(str(repo_root / "src"))
            assert Path(result, "docker-compose.yml").exists()

    def test_build_args_has_docker(self, registry):
        adapter = get_adapter("vscode")
        args = adapter.build_args("test", "/tmp")
        assert args[0] == "docker"
        assert "compose" in args
        assert "vscode" in args
