"""Tests for child process dispatch, monitoring, and harvesting."""

from __future__ import annotations

import asyncio
import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest

from cleave.orchestrator.config import OrchestratorConfig
from cleave.orchestrator.dispatcher import (
    _build_child_env,
    _build_claude_args,
    _compute_dispatch_waves,
    dispatch_children,
    harvest_child_result,
    parse_claude_output,
    spawn_child,
    wait_for_child,
)
from cleave.orchestrator.errors import TimeoutExceededError
from cleave.orchestrator.state import ChildState


def _mock_config(tmp_path: Path) -> OrchestratorConfig:
    (tmp_path / ".git").mkdir(exist_ok=True)
    return OrchestratorConfig(
        directive="test",
        repo_path=tmp_path,
        child_budget_usd=5.0,
        child_timeout_seconds=60,
    )


class TestBuildClaudeArgs:
    def test_includes_required_flags(self, tmp_path: Path) -> None:
        config = _mock_config(tmp_path)
        args = _build_claude_args("/usr/bin/claude", "prompt", config, tmp_path)
        assert args[0] == "/usr/bin/claude"
        assert "--print" in args
        assert "--output-format" in args
        assert "json" in args
        assert "--model" in args
        assert "opus" in args
        assert "--permission-mode" in args
        assert "--max-budget-usd" in args
        assert "5.0" in args

    def test_includes_allowed_tools(self, tmp_path: Path) -> None:
        config = _mock_config(tmp_path)
        args = _build_claude_args("/usr/bin/claude", "prompt", config, tmp_path)
        assert "--allowedTools" in args


class TestParseclaudeOutput:
    def test_empty_output(self) -> None:
        result = parse_claude_output("")
        assert result["cost_usd"] == 0.0

    def test_json_object(self) -> None:
        data = {"cost_usd": 1.5, "result": "Done", "model": "opus"}
        result = parse_claude_output(json.dumps(data))
        assert result["cost_usd"] == 1.5
        assert result["result"] == "Done"
        assert result["model"] == "opus"

    def test_json_array_of_messages(self) -> None:
        data = [
            {"role": "user", "content": "Do stuff"},
            {"role": "assistant", "content": "I did the stuff"},
        ]
        result = parse_claude_output(json.dumps(data))
        assert result["result"] == "I did the stuff"

    def test_jsonlines(self) -> None:
        lines = [
            json.dumps({"type": "progress", "content": "working..."}),
            json.dumps({"cost_usd": 0.5, "result": "Final answer"}),
        ]
        result = parse_claude_output("\n".join(lines))
        assert result["cost_usd"] == 0.5
        assert result["result"] == "Final answer"

    def test_non_json_fallback(self) -> None:
        result = parse_claude_output("Just plain text output")
        assert result["result"] == "Just plain text output"


class TestHarvestChildResult:
    def test_success(self) -> None:
        child = ChildState(child_id=1, label="test")
        stdout = json.dumps({"cost_usd": 2.0, "result": "Done"})
        parsed = harvest_child_result(child, 0, stdout, "")
        assert child.status == "completed"
        assert child.exit_code == 0
        assert child.cost_usd == 2.0
        assert child.completed_at is not None

    def test_failure(self) -> None:
        child = ChildState(child_id=1, label="test")
        parsed = harvest_child_result(child, 1, "", "some error")
        assert child.status == "failed"
        assert child.exit_code == 1
        assert "some error" in (child.error_message or "")


class TestWaitForChild:
    @pytest.mark.asyncio
    async def test_normal_completion(self) -> None:
        proc = AsyncMock()
        proc.communicate = AsyncMock(return_value=(b"output", b""))
        proc.returncode = 0
        child = ChildState(child_id=1, label="test")
        rc, stdout, stderr = await wait_for_child(proc, child, 60)
        assert rc == 0
        assert stdout == "output"

    @pytest.mark.skipif(sys.platform == "win32", reason="os.killpg not available on Windows")
    @pytest.mark.asyncio
    async def test_timeout_sends_sigterm(self) -> None:
        call_count = 0

        async def slow_then_fast(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                await asyncio.sleep(100)  # First call: hangs (triggers timeout)
            return (b"", b"")  # Subsequent calls: return immediately

        proc = AsyncMock()
        proc.communicate = slow_then_fast
        proc.terminate = AsyncMock()
        proc.kill = AsyncMock()
        proc.pid = 99999
        child = ChildState(child_id=1, label="test")
        with patch("cleave.orchestrator.dispatcher.os.killpg"), \
             patch("cleave.orchestrator.dispatcher.os.getpgid", return_value=99999):
            with pytest.raises(TimeoutExceededError):
                await wait_for_child(proc, child, 1)


class TestComputeDispatchWaves:
    def test_no_deps_single_wave(self) -> None:
        children = [
            ChildState(child_id=0, label="a"),
            ChildState(child_id=1, label="b"),
            ChildState(child_id=2, label="c"),
        ]
        waves = _compute_dispatch_waves(children)
        assert len(waves) == 1
        assert len(waves[0]) == 3

    def test_linear_chain(self) -> None:
        children = [
            ChildState(child_id=0, label="a"),
            ChildState(child_id=1, label="b", depends_on=["a"]),
            ChildState(child_id=2, label="c", depends_on=["b"]),
        ]
        waves = _compute_dispatch_waves(children)
        assert len(waves) == 3
        assert waves[0][0].label == "a"
        assert waves[1][0].label == "b"
        assert waves[2][0].label == "c"

    def test_diamond_pattern(self) -> None:
        children = [
            ChildState(child_id=0, label="a"),
            ChildState(child_id=1, label="b", depends_on=["a"]),
            ChildState(child_id=2, label="c", depends_on=["a"]),
            ChildState(child_id=3, label="d", depends_on=["b", "c"]),
        ]
        waves = _compute_dispatch_waves(children)
        assert len(waves) == 3
        assert waves[0][0].label == "a"
        wave1_labels = {c.label for c in waves[1]}
        assert wave1_labels == {"b", "c"}
        assert waves[2][0].label == "d"

    def test_deadlock_breaker(self) -> None:
        # Create a cycle that wouldn't be caught by planner (simulating edge case)
        children = [
            ChildState(child_id=0, label="a", depends_on=["b"]),
            ChildState(child_id=1, label="b", depends_on=["a"]),
        ]
        waves = _compute_dispatch_waves(children)
        # Deadlock breaker should dispatch all remaining in one wave
        assert len(waves) == 1
        assert len(waves[0]) == 2

    def test_empty_children(self) -> None:
        waves = _compute_dispatch_waves([])
        assert waves == []


class TestDispatchChildren:
    def test_dispatches_pending_only(self, tmp_path: Path) -> None:
        config = _mock_config(tmp_path)
        children = [
            ChildState(child_id=1, label="a", status="pending"),
            ChildState(child_id=2, label="b", status="completed"),
            ChildState(child_id=3, label="c", status="pending"),
        ]
        prompts = {1: "do a", 3: "do c"}
        worktree_paths = {1: tmp_path, 3: tmp_path}

        spawn_count = 0

        async def mock_exec(*args, **kwargs):
            nonlocal spawn_count
            spawn_count += 1
            proc = AsyncMock()
            proc.stdin = AsyncMock()
            proc.stdin.write = lambda x: None
            proc.stdin.drain = AsyncMock()
            proc.stdin.close = lambda: None
            proc.pid = 1000 + spawn_count
            proc.communicate = AsyncMock(
                return_value=(json.dumps({"cost_usd": 0.1, "result": "ok"}).encode(), b"")
            )
            proc.returncode = 0
            return proc

        with patch("cleave.orchestrator.dispatcher.asyncio.create_subprocess_exec", side_effect=mock_exec):
            with patch("shutil.which", return_value="/usr/bin/claude"):
                results = asyncio.run(
                    dispatch_children("/usr/bin/claude", children, prompts, worktree_paths, config)
                )

        assert spawn_count == 2  # only pending children
        assert children[0].status == "completed"
        assert children[1].status == "completed"  # unchanged
        assert children[2].status == "completed"


class TestBuildChildEnv:
    def test_basic_env_has_cleave_vars(self) -> None:
        child = ChildState(child_id=0, label="test")
        env = _build_child_env(child, run_id="run-123", depth=1)
        assert env["CLEAVE_RUN_ID"] == "run-123"
        assert env["CLEAVE_CHILD_ID"] == "0"
        assert env["CLEAVE_DEPTH"] == "1"

    def test_venv_injection_when_present(self, tmp_path: Path) -> None:
        # Create .venv/bin directory
        venv_bin = tmp_path / ".venv" / "bin"
        venv_bin.mkdir(parents=True)

        child = ChildState(child_id=0, label="test")
        env = _build_child_env(child, repo_path=tmp_path)

        assert env["VIRTUAL_ENV"] == str(tmp_path / ".venv")
        assert str(venv_bin) in env["PATH"]
        # venv/bin should be at the front of PATH
        assert env["PATH"].startswith(str(venv_bin))

    def test_no_venv_injection_without_venv(self, tmp_path: Path) -> None:
        child = ChildState(child_id=0, label="test")
        env = _build_child_env(child, repo_path=tmp_path)
        assert "VIRTUAL_ENV" not in env

    def test_no_venv_injection_without_repo_path(self) -> None:
        child = ChildState(child_id=0, label="test")
        env = _build_child_env(child)
        assert "VIRTUAL_ENV" not in env
