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

import pytest

from free_claude_code.cli.managed.manager import ManagedClaudeSessionManager
from free_claude_code.cli.managed.session import ManagedClaudeSession


def _manager() -> ManagedClaudeSessionManager:
    return ManagedClaudeSessionManager(
        workspace_path="/tmp",
        proxy_root_url="http://127.0.0.1:8082",
    )


def _mock_session(*stop_results: object) -> MagicMock:
    session = MagicMock(spec=ManagedClaudeSession)
    session.is_busy = False
    session.stop = AsyncMock(side_effect=list(stop_results))
    return session


def _completed_process(pid: int) -> MagicMock:
    process = MagicMock()
    process.pid = pid
    process.returncode = 0
    process.stdout.read = AsyncMock(return_value=b"")
    process.stderr = None
    process.wait = AsyncMock(return_value=0)
    return process


@pytest.mark.asyncio
async def test_stop_is_idempotent_without_a_live_process() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")

    assert await session.stop() is True

    process = MagicMock()
    process.pid = 101
    process.returncode = 0
    process.wait = AsyncMock()
    session.process = process

    with (
        patch(
            "free_claude_code.cli.managed.session.kill_pid_tree_best_effort"
        ) as kill_tree,
        patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
    ):
        assert await session.stop() is True

    kill_tree.assert_not_called()
    process.wait.assert_not_awaited()
    unregister.assert_called_once_with(101)


@pytest.mark.asyncio
async def test_stopped_session_reference_cannot_launch_a_new_process() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")

    assert await session.stop() is True

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            new=AsyncMock(return_value=_completed_process(100)),
        ) as create_process,
        patch("free_claude_code.cli.managed.session.trace_event") as trace,
        pytest.raises(RuntimeError, match="closed"),
    ):
        async for _ in session.start_task("must not launch"):
            pass

    create_process.assert_not_awaited()
    trace.assert_not_called()
    assert session.is_busy is False


@pytest.mark.asyncio
async def test_launch_publication_wins_before_concurrent_stop() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    launch_entered = asyncio.Event()
    release_launch = asyncio.Event()
    release_stream = asyncio.Event()
    process = MagicMock()
    process.pid = 102
    process.returncode = None
    process.stderr = None

    async def create_process(*_args: object, **_kwargs: object) -> MagicMock:
        launch_entered.set()
        await release_launch.wait()
        return process

    async def read_stdout(*_args: object, **_kwargs: object) -> bytes:
        await release_stream.wait()
        return b""

    async def wait_process() -> int:
        process.returncode = 0
        return 0

    process.stdout.read = AsyncMock(side_effect=read_stdout)
    process.wait = AsyncMock(side_effect=wait_process)

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            side_effect=create_process,
        ),
        patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
        patch("free_claude_code.cli.managed.session.register_pid"),
        patch("free_claude_code.cli.managed.session.unregister_pid"),
    ):
        stream_task = asyncio.create_task(
            _collect_session_events(session, "launch wins")
        )
        await launch_entered.wait()
        stop_task = asyncio.create_task(session.stop())
        await asyncio.sleep(0)
        stopped_before_publication = stop_task.done()

        release_launch.set()
        assert await asyncio.wait_for(stop_task, timeout=1) is True
        release_stream.set()
        events = await asyncio.wait_for(stream_task, timeout=1)

        with pytest.raises(RuntimeError, match="closed"):
            async for _ in session.start_task("cannot relaunch"):
                pass

    assert events == [{"type": "exit", "code": 0, "stderr": None}]
    assert stopped_before_publication is False


@pytest.mark.asyncio
async def test_concurrent_stop_wins_before_launch_publication() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    stop_entered = asyncio.Event()
    release_stop = asyncio.Event()
    process = MagicMock()
    process.pid = 103
    process.returncode = None

    async def wait_process() -> int:
        stop_entered.set()
        await release_stop.wait()
        process.returncode = 0
        return 0

    process.wait = AsyncMock(side_effect=wait_process)
    session.process = process
    unexpected_process = _completed_process(106)

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            new=AsyncMock(return_value=unexpected_process),
        ) as create_process,
        patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
        patch("free_claude_code.cli.managed.session.unregister_pid"),
        patch("free_claude_code.cli.managed.session.trace_event") as trace,
    ):
        stop_task = asyncio.create_task(session.stop())
        await stop_entered.wait()
        stream_task = asyncio.create_task(_collect_session_events(session, "stop wins"))
        await asyncio.sleep(0)

        release_stop.set()
        assert await asyncio.wait_for(stop_task, timeout=1) is True
        with pytest.raises(RuntimeError, match="closed"):
            await asyncio.wait_for(stream_task, timeout=1)

    create_process.assert_not_awaited()
    trace.assert_not_called()


@pytest.mark.asyncio
async def test_normal_sequential_starts_remain_allowed_before_stop() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    processes = [_completed_process(104), _completed_process(105)]

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            new=AsyncMock(side_effect=processes),
        ) as create_process,
        patch("free_claude_code.cli.managed.session.register_pid"),
        patch("free_claude_code.cli.managed.session.unregister_pid"),
    ):
        first = await _collect_session_events(session, "first")
        second = await _collect_session_events(session, "second")

    assert first == [{"type": "exit", "code": 0, "stderr": None}]
    assert second == [{"type": "exit", "code": 0, "stderr": None}]
    assert create_process.await_count == 2


async def _collect_session_events(
    session: ManagedClaudeSession,
    prompt: str,
) -> list[dict]:
    return [event async for event in session.start_task(prompt)]


@pytest.mark.asyncio
async def test_failed_stop_keeps_pid_registered_until_retry_confirms_exit() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    process = MagicMock()
    process.pid = 202
    process.returncode = None
    process.wait = AsyncMock(side_effect=[RuntimeError("wait failed"), 0])
    session.process = process

    with (
        patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
        patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
    ):
        assert await session.stop() is False
        unregister.assert_not_called()

        assert await session.stop() is True

    unregister.assert_called_once_with(202)


@pytest.mark.asyncio
async def test_cancelled_stop_keeps_pid_registered_and_can_be_retried() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    process = MagicMock()
    process.pid = 303
    process.returncode = None
    wait_entered = asyncio.Event()

    async def wait_until_cancelled() -> int:
        wait_entered.set()
        await asyncio.Event().wait()
        return 0

    process.wait = AsyncMock(side_effect=wait_until_cancelled)
    session.process = process

    with (
        patch("free_claude_code.cli.managed.session.kill_pid_tree_best_effort"),
        patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
    ):
        stopping = asyncio.create_task(session.stop())
        await wait_entered.wait()
        stopping.cancel()
        with pytest.raises(asyncio.CancelledError):
            await stopping
        unregister.assert_not_called()

        process.wait = AsyncMock(return_value=0)
        assert await session.stop() is True

    unregister.assert_called_once_with(303)


@pytest.mark.asyncio
async def test_task_failure_keeps_unconfirmed_process_pid_registered() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    process = MagicMock()
    process.pid = 404
    process.returncode = None
    process.stdout.read = AsyncMock(side_effect=RuntimeError("read failed"))
    process.stderr = None

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            new=AsyncMock(return_value=process),
        ),
        patch("free_claude_code.cli.managed.session.register_pid") as register,
        patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
        pytest.raises(RuntimeError, match="read failed"),
    ):
        async for _ in session.start_task("hello"):
            pass

    register.assert_called_once_with(404)
    unregister.assert_not_called()


@pytest.mark.asyncio
async def test_completed_task_wait_unregisters_confirmed_process_pid() -> None:
    session = ManagedClaudeSession("/tmp", "http://127.0.0.1:8082")
    process = MagicMock()
    process.pid = 405
    process.returncode = None
    process.stdout.read = AsyncMock(return_value=b"")
    process.stderr = None
    process.wait = AsyncMock(return_value=0)

    with (
        patch(
            "free_claude_code.cli.managed.session.asyncio.create_subprocess_exec",
            new=AsyncMock(return_value=process),
        ),
        patch("free_claude_code.cli.managed.session.register_pid") as register,
        patch("free_claude_code.cli.managed.session.unregister_pid") as unregister,
    ):
        events = [event async for event in session.start_task("hello")]

    assert events == [{"type": "exit", "code": 0, "stderr": None}]
    register.assert_called_once_with(405)
    unregister.assert_called_once_with(405)


@pytest.mark.parametrize(
    ("first_stop", "raised"),
    [
        (False, None),
        (RuntimeError("stop failed"), RuntimeError),
        (asyncio.CancelledError(), asyncio.CancelledError),
    ],
)
@pytest.mark.asyncio
async def test_remove_failure_retains_exact_aliases_and_closes_session_for_reuse(
    first_stop: object,
    raised: type[BaseException] | None,
) -> None:
    manager = _manager()
    session = _mock_session(first_stop, True)
    manager._sessions["real_1"] = session
    manager._temp_to_real["temp_1"] = "real_1"
    manager._real_to_temp["real_1"] = "temp_1"
    expected_sessions = dict(manager._sessions)
    expected_temp_to_real = dict(manager._temp_to_real)
    expected_real_to_temp = dict(manager._real_to_temp)

    if raised is None:
        assert await manager.remove_session("real_1") is False
    else:
        with pytest.raises(raised):
            await manager.remove_session("real_1")

    assert manager._sessions == expected_sessions
    assert manager._temp_to_real == expected_temp_to_real
    assert manager._real_to_temp == expected_real_to_temp
    with pytest.raises(RuntimeError, match="closing"):
        await manager.get_or_create_session("real_1")
    with pytest.raises(RuntimeError, match="closing"):
        await manager.get_or_create_session("temp_1")

    assert await manager.remove_session("temp_1") is True
    assert manager._sessions == {}
    assert manager._temp_to_real == {}
    assert manager._real_to_temp == {}
    assert session.stop.await_count == 2


@pytest.mark.asyncio
async def test_register_rejects_a_pending_session_after_stop_failure() -> None:
    manager = _manager()
    session = _mock_session(False)
    manager._pending_sessions["temp_1"] = session

    assert await manager.remove_session("temp_1") is False
    assert await manager.register_real_session_id("temp_1", "real_1") is False
    assert manager._pending_sessions == {"temp_1": session}
    assert manager._sessions == {}
    assert manager._temp_to_real == {}
    assert manager._real_to_temp == {}


@pytest.mark.asyncio
async def test_register_rejects_real_id_owned_by_a_different_session() -> None:
    manager = _manager()
    existing = _mock_session(False)
    pending = _mock_session(True)
    manager._sessions["real_1"] = existing
    manager._pending_sessions["temp_2"] = pending

    assert await manager.register_real_session_id("temp_2", "real_1") is False

    assert manager._sessions == {"real_1": existing}
    assert manager._pending_sessions == {"temp_2": pending}
    assert manager._temp_to_real == {}
    assert manager._real_to_temp == {}


@pytest.mark.asyncio
async def test_stop_all_never_loses_a_closing_owner_outside_identity_maps() -> None:
    manager = _manager()
    orphaned_owner = _mock_session(False)
    mapped_owner = _mock_session(True)
    manager._closing_sessions.add(orphaned_owner)
    manager._sessions["real_1"] = mapped_owner

    with pytest.raises(
        RuntimeError,
        match=r"^Managed Claude session shutdown failures: 1\.$",
    ):
        await manager.stop_all()

    orphaned_owner.stop.assert_awaited_once()
    mapped_owner.stop.assert_awaited_once()
    assert manager._sessions == {}
    assert manager._closing_sessions == {orphaned_owner}

    orphaned_owner.stop = AsyncMock(return_value=True)
    await manager.stop_all()

    orphaned_owner.stop.assert_awaited_once()
    assert manager._closing_sessions == set()


@pytest.mark.asyncio
async def test_stop_all_attempts_every_session_and_retains_only_failures() -> None:
    manager = _manager()
    returned_false = _mock_session(False)
    raised_error = _mock_session(RuntimeError("secret failure detail"))
    stopped = _mock_session(True)
    manager._sessions.update(
        {
            "real_error": raised_error,
            "real_false": returned_false,
            "real_stopped": stopped,
        }
    )
    manager._temp_to_real.update(
        {
            "temp_error": "real_error",
            "temp_false": "real_false",
            "temp_stopped": "real_stopped",
        }
    )
    manager._real_to_temp.update(
        {
            "real_error": "temp_error",
            "real_false": "temp_false",
            "real_stopped": "temp_stopped",
        }
    )

    with pytest.raises(RuntimeError) as exc_info:
        await manager.stop_all()

    assert str(exc_info.value) == "Managed Claude session shutdown failures: 2."
    returned_false.stop.assert_awaited_once()
    raised_error.stop.assert_awaited_once()
    stopped.stop.assert_awaited_once()
    assert manager._sessions == {
        "real_error": raised_error,
        "real_false": returned_false,
    }
    assert manager._pending_sessions == {}
    assert manager._temp_to_real == {
        "temp_error": "real_error",
        "temp_false": "real_false",
    }
    assert manager._real_to_temp == {
        "real_error": "temp_error",
        "real_false": "temp_false",
    }
    with pytest.raises(RuntimeError, match="closing"):
        await manager.get_or_create_session("temp_false")
    with pytest.raises(RuntimeError, match="closing"):
        await manager.get_or_create_session("temp_error")

    returned_false.stop = AsyncMock(return_value=True)
    raised_error.stop = AsyncMock(return_value=True)
    await manager.stop_all()

    assert manager._sessions == {}
    assert manager._pending_sessions == {}
    assert manager._temp_to_real == {}
    assert manager._real_to_temp == {}
