# #region Test.Core.TaskManager.Lifecycle [C:3] [TYPE Module] [SEMANTICS test,lifecycle,task,execution]
# @BRIEF Verify JobLifecycle contracts: create, run, pause/resume, input, completion.
# @RELATION BINDS_TO -> [JobLifecycle]
# @TEST_EDGE: unknown_plugin -> create_task raises ValueError
# @TEST_EDGE: invalid_params -> create_task raises ValueError
# @TEST_EDGE: task_not_found -> _run_task returns early
# @TEST_EDGE: plugin_execution_failure -> task transitions to FAILED
# @TEST_EDGE: resolve_not_awaiting -> resolve_task raises ValueError
# @TEST_EDGE: await_input_not_running -> await_input raises ValueError
# @TEST_EDGE: resume_empty_passwords -> resume_task_with_password raises ValueError
# @TEST_EDGE: dataset_broadcast -> mapper/doc plugins broadcast dataset.updated
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))

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

from src.core.task_manager.lifecycle import JobLifecycle
from src.core.task_manager.models import Task, TaskStatus


# ── Hardcoded fixtures ───────────────────────────────────────

@pytest.fixture
def mock_plugin_loader():
    loader = MagicMock()
    loader.has_plugin.return_value = True
    plugin = MagicMock()
    plugin.name = "test_plugin"
    plugin.execute = MagicMock(return_value={"result": "ok"})
    loader.get_plugin.return_value = plugin
    return loader


@pytest.fixture
def mock_graph():
    graph = MagicMock()
    graph.get_task.return_value = None  # default: not found
    graph.tasks = {}
    return graph


@pytest.fixture
def mock_event_bus():
    bus = MagicMock()
    bus.broadcast_status = AsyncMock()
    bus.flush_task_logs = AsyncMock()
    return bus


@pytest.fixture
def mock_persistence():
    return MagicMock()


@pytest.fixture
def lifecycle(mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
    return JobLifecycle(
        plugin_loader=mock_plugin_loader,
        graph=mock_graph,
        event_bus=mock_event_bus,
        persistence_service=mock_persistence,
    )


def _make_task(**overrides) -> Task:
    params = {"plugin_id": "test_plugin", "params": {}}
    params.update(overrides)
    t = Task(**params)
    t.id = "task-test-1"
    return t


# ═══════════════════════════════════════════════════════════════
# Init
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleInit:
    """Verify __init__ stores dependencies."""

    def test_init_stores_deps(self, lifecycle, mock_plugin_loader, mock_graph, mock_event_bus, mock_persistence):
        assert lifecycle.plugin_loader is mock_plugin_loader
        assert lifecycle.graph is mock_graph
        assert lifecycle.event_bus is mock_event_bus
        assert lifecycle.persistence_service is mock_persistence
        assert lifecycle._dataset_subscribers == {}


# ═══════════════════════════════════════════════════════════════
# create_task
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleCreateTask:
    """Verify create_task @POST: task created, added to graph, persisted."""

    @pytest.mark.asyncio
    async def test_create_task_success(self, lifecycle, mock_plugin_loader, mock_graph, mock_persistence):
        task = await lifecycle.create_task("test_plugin", {"key": "val"}, user_id="user1")
        assert task.plugin_id == "test_plugin"
        assert task.params == {"key": "val"}
        assert task.user_id == "user1"
        assert task.status == TaskStatus.PENDING
        mock_graph.add_task.assert_called_once_with(task)
        mock_persistence.persist_task.assert_called_once_with(task)

    @pytest.mark.asyncio
    async def test_create_task_unknown_plugin_raises(self, lifecycle, mock_plugin_loader):
        mock_plugin_loader.has_plugin.return_value = False
        with pytest.raises(ValueError, match="not found"):
            await lifecycle.create_task("ghost", {})

    @pytest.mark.asyncio
    async def test_create_task_invalid_params_raises(self, lifecycle):
        with pytest.raises(ValueError, match="dictionary"):
            await lifecycle.create_task("test_plugin", "not-a-dict")

    @pytest.mark.asyncio
    async def test_create_task_no_user_id(self, lifecycle, mock_graph, mock_persistence):
        task = await lifecycle.create_task("test_plugin", {})
        assert task.user_id is None

    @pytest.mark.asyncio
    async def test_create_task_with_add_log_callback(self, lifecycle, mock_graph, mock_persistence):
        add_log = AsyncMock()
        task = await lifecycle.create_task("test_plugin", {}, add_log_callback=add_log)
        assert task is not None


# ═══════════════════════════════════════════════════════════════
# _run_task — execution paths
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleRunTask:
    """Verify _run_task @POST: success, failure, context dispatch, dataset broadcast."""

    # ── Setup: inject a task into the graph ──

    def _inject_task(self, lifecycle, mock_graph, **overrides):
        task = _make_task(**overrides)
        mock_graph.get_task.return_value = task
        return task

    @pytest.mark.asyncio
    async def test_run_task_success(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        task = self._inject_task(lifecycle, mock_graph)
        await lifecycle._run_task(task.id)

        assert task.status == TaskStatus.SUCCESS
        assert task.finished_at is not None
        assert task.result == {"result": "ok"}
        mock_event_bus.broadcast_status.assert_called()
        mock_event_bus.flush_task_logs.assert_called_with(task.id)
        mock_persistence.persist_task.assert_called()
        # Verify RUNNING -> SUCCESS transition persisted
        persist_calls = mock_persistence.persist_task.call_args_list
        assert len(persist_calls) >= 2

    @pytest.mark.asyncio
    async def test_run_task_not_found(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
        mock_graph.get_task.return_value = None
        await lifecycle._run_task("no-such-task")
        mock_event_bus.broadcast_status.assert_not_called()
        mock_persistence.persist_task.assert_not_called()

    @pytest.mark.asyncio
    async def test_run_task_plugin_failure(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        task = self._inject_task(lifecycle, mock_graph)
        mock_plugin_loader.get_plugin().execute.side_effect = Exception("Plugin crashed")

        await lifecycle._run_task(task.id)

        assert task.status == TaskStatus.FAILED
        assert task.finished_at is not None
        mock_event_bus.broadcast_status.assert_called()

    @pytest.mark.asyncio
    async def test_run_task_with_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
        task = self._inject_task(lifecycle, mock_graph)
        add_log = AsyncMock()
        await lifecycle._run_task(task.id, add_log_callback=add_log)
        assert add_log.call_count >= 2  # started + completed

    @pytest.mark.asyncio
    async def test_run_task_async_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Plugin that accepts context parameter AND is a coroutine function."""
        task = self._inject_task(lifecycle, mock_graph)
        async def async_execute(params, context=None):
            return {"async": True}
        mock_plugin_loader.get_plugin().execute = async_execute

        await lifecycle._run_task(task.id)
        assert task.result == {"async": True}
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_sync_plugin_with_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Plugin that accepts context but is a sync function — uses asyncio.to_thread."""
        task = self._inject_task(lifecycle, mock_graph)

        def sync_execute(params, context=None):
            return {"sync": True}
        mock_plugin_loader.get_plugin().execute = sync_execute

        await lifecycle._run_task(task.id)
        assert task.result == {"sync": True}

    @pytest.mark.asyncio
    async def test_run_task_async_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Plugin without context parameter — async variant."""
        task = self._inject_task(lifecycle, mock_graph)

        async def async_execute_no_ctx(params):
            return {"no_ctx": True}
        mock_plugin_loader.get_plugin().execute = async_execute_no_ctx

        await lifecycle._run_task(task.id)
        assert task.result == {"no_ctx": True}

    @pytest.mark.asyncio
    async def test_run_task_sync_plugin_without_context(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Plugin without context — sync variant, runs via to_thread."""
        task = self._inject_task(lifecycle, mock_graph)

        def sync_execute_no_ctx(params):
            return {"sync_no_ctx": True}
        mock_plugin_loader.get_plugin().execute = sync_execute_no_ctx

        await lifecycle._run_task(task.id)
        assert task.result == {"sync_no_ctx": True}

    @pytest.mark.asyncio
    async def test_run_task_with_failed_add_log_callback(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
        """Failure in add_log_callback during startup propagates — source doesn't catch it there."""
        task = self._inject_task(lifecycle, mock_graph)
        # The source calls add_log_callback for "Task started" before try/except.
        # If that fails, the exception propagates directly.
        add_log = AsyncMock(side_effect=Exception("log err"))

        with pytest.raises(Exception, match="log err"):
            await lifecycle._run_task(task.id, add_log_callback=add_log)

    @pytest.mark.asyncio
    async def test_run_task_plugin_failure_with_add_log(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Failed plugin should still call add_log with error info."""
        task = self._inject_task(lifecycle, mock_graph)
        mock_plugin_loader.get_plugin().execute.side_effect = ValueError("bad value")
        add_log = AsyncMock()

        await lifecycle._run_task(task.id, add_log_callback=add_log)

        assert task.status == TaskStatus.FAILED
        # Error log should have been sent
        error_calls = [c for c in add_log.call_args_list if c[0][1] == "ERROR"]
        assert len(error_calls) >= 1

    # ── Dataset broadcast tests ──

    @pytest.mark.asyncio
    async def test_run_task_broadcasts_dataset_mapper(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """dataset-mapper plugin broadcasts dataset.updated event."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
                                 params={"dataset_ids": [1, 2], "env": "prod"})

        await lifecycle._run_task(task.id)

        # _broadcast_dataset_updated should have been called
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_broadcasts_llm_documentation(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """llm_documentation plugin broadcasts dataset.updated event."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="llm_documentation",
                                 params={"dataset_id": 5, "environment_id": "staging"})

        await lifecycle._run_task(task.id)
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_skip_dataset_broadcast_wrong_plugin(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """Non-mapper plugin should not broadcast dataset events."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="backup",
                                 params={"dataset_ids": [1], "env": "prod"})

        await lifecycle._run_task(task.id)
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_skip_dataset_broadcast_missing_env(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """dataset-mapper without env_id should skip broadcast."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
                                 params={"dataset_ids": [1]})

        await lifecycle._run_task(task.id)
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_skip_dataset_broadcast_empty_datasets(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """dataset-mapper with empty dataset_ids should skip broadcast."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
                                 params={"dataset_ids": [], "env": "prod"})

        await lifecycle._run_task(task.id)
        assert task.status == TaskStatus.SUCCESS

    @pytest.mark.asyncio
    async def test_run_task_broadcasts_single_dataset_id(self, lifecycle, mock_graph, mock_event_bus, mock_persistence, mock_plugin_loader):
        """plugin with dataset_id (single int) + env should broadcast."""
        task = self._inject_task(lifecycle, mock_graph, plugin_id="dataset-mapper",
                                 params={"dataset_id": 42, "env": "prod"})

        await lifecycle._run_task(task.id)
        assert task.status == TaskStatus.SUCCESS


# ═══════════════════════════════════════════════════════════════
# resolve_task
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleResolveTask:
    """Verify resolve_task @POST: updates params, sets RUNNING, resolves future."""

    @pytest.mark.asyncio
    async def test_resolve_task_success(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.AWAITING_MAPPING
        mock_graph.get_task.return_value = task

        await lifecycle.resolve_task(task.id, {"mapping": "done"})

        assert task.params["mapping"] == "done"
        assert task.status == TaskStatus.RUNNING
        mock_graph.resolve_future.assert_called_with(task.id, True)

    @pytest.mark.asyncio
    async def test_resolve_task_not_awaiting_raises(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.RUNNING
        mock_graph.get_task.return_value = task

        with pytest.raises(ValueError, match="not awaiting mapping"):
            await lifecycle.resolve_task(task.id, {})

    @pytest.mark.asyncio
    async def test_resolve_task_not_found_raises(self, lifecycle, mock_graph):
        mock_graph.get_task.return_value = None
        with pytest.raises(ValueError, match="not awaiting mapping"):
            await lifecycle.resolve_task("no-such", {})


# ═══════════════════════════════════════════════════════════════
# wait_for_resolution
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleWaitForResolution:
    """Verify wait_for_resolution @POST: creates future, waits, then resolves."""

    @pytest.mark.asyncio
    async def test_wait_for_resolution_success(self, lifecycle, mock_graph, mock_event_bus):
        task = _make_task()
        mock_graph.get_task.return_value = task

        # Run wait_for_resolution and resolve it after short delay
        async def resolve_later():
            await asyncio.sleep(0.05)
            future = mock_graph.create_future.call_args[0][1]
            future.set_result(True)

        async with asyncio.TaskGroup() as tg:
            tg.create_task(resolve_later())
            await lifecycle.wait_for_resolution(task.id)

        assert task.status == TaskStatus.AWAITING_MAPPING
        mock_graph.remove_future.assert_called_with(task.id)

    @pytest.mark.asyncio
    async def test_wait_for_resolution_no_task(self, lifecycle, mock_graph):
        mock_graph.get_task.return_value = None
        await lifecycle.wait_for_resolution("no-such")
        # no-op, should return immediately

    @pytest.mark.asyncio
    async def test_wait_for_resolution_persists_and_broadcasts(self, lifecycle, mock_graph, mock_event_bus, mock_persistence):
        task = _make_task()
        mock_graph.get_task.return_value = task

        async def resolve_later():
            await asyncio.sleep(0.05)
            future = mock_graph.create_future.call_args[0][1]
            future.set_result(True)

        async with asyncio.TaskGroup() as tg:
            tg.create_task(resolve_later())
            await lifecycle.wait_for_resolution(task.id)

        mock_persistence.persist_task.assert_called()
        mock_event_bus.broadcast_status.assert_called()


# ═══════════════════════════════════════════════════════════════
# wait_for_input
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleWaitForInput:
    """Verify wait_for_input @POST: creates future, waits, removes future."""

    @pytest.mark.asyncio
    async def test_wait_for_input_success(self, lifecycle, mock_graph):
        task = _make_task()
        mock_graph.get_task.return_value = task
        task.status = TaskStatus.AWAITING_INPUT

        async def resolve_later():
            await asyncio.sleep(0.05)
            future = mock_graph.create_future.call_args[0][1]
            future.set_result(True)

        async with asyncio.TaskGroup() as tg:
            tg.create_task(resolve_later())
            await lifecycle.wait_for_input(task.id)

        mock_graph.remove_future.assert_called_with(task.id)

    @pytest.mark.asyncio
    async def test_wait_for_input_no_task(self, lifecycle, mock_graph):
        mock_graph.get_task.return_value = None
        await lifecycle.wait_for_input("no-such")
        # no-op, should return immediately


# ═══════════════════════════════════════════════════════════════
# await_input
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleAwaitInput:
    """Verify await_input @POST: sets AWAITING_INPUT with input_request."""

    @pytest.mark.asyncio
    async def test_await_input_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
        task = _make_task()
        task.status = TaskStatus.RUNNING
        mock_graph.get_task.return_value = task

        await lifecycle.await_input(task.id, {"prompt": "Enter password"}, add_log_callback=AsyncMock())

        assert task.status == TaskStatus.AWAITING_INPUT
        assert task.input_required is True
        assert task.input_request == {"prompt": "Enter password"}
        mock_persistence.persist_task.assert_called()
        mock_event_bus.broadcast_status.assert_called()

    @pytest.mark.asyncio
    async def test_await_input_not_found_raises(self, lifecycle, mock_graph):
        mock_graph.get_task.return_value = None
        with pytest.raises(ValueError, match="not found"):
            await lifecycle.await_input("no-such", {})

    @pytest.mark.asyncio
    async def test_await_input_not_running_raises(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.PENDING
        mock_graph.get_task.return_value = task

        with pytest.raises(ValueError, match="not RUNNING"):
            await lifecycle.await_input(task.id, {})

    @pytest.mark.asyncio
    async def test_await_input_with_add_log(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.RUNNING
        mock_graph.get_task.return_value = task
        add_log = AsyncMock()

        await lifecycle.await_input(task.id, {"prompt": "pwd"}, add_log_callback=add_log)

        add_log.assert_called_once()
        assert "paused for user input" in add_log.call_args[0][2].lower()


# ═══════════════════════════════════════════════════════════════
# resume_task_with_password
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleResumeWithPassword:
    """Verify resume_task_with_password @POST: status, params, future resolution."""

    @pytest.mark.asyncio
    async def test_resume_with_password_success(self, lifecycle, mock_graph, mock_persistence, mock_event_bus):
        task = _make_task()
        task.status = TaskStatus.AWAITING_INPUT
        mock_graph.get_task.return_value = task

        await lifecycle.resume_task_with_password(
            task.id, {"db1": "pass123"}, add_log_callback=AsyncMock()
        )

        assert task.status == TaskStatus.RUNNING
        assert task.params.get("passwords") == {"db1": "pass123"}
        assert task.input_required is False
        assert task.input_request is None
        mock_persistence.persist_task.assert_called()
        mock_event_bus.broadcast_status.assert_called()
        mock_graph.resolve_future.assert_called_with(task.id, True)

    @pytest.mark.asyncio
    async def test_resume_not_found_raises(self, lifecycle, mock_graph):
        mock_graph.get_task.return_value = None
        with pytest.raises(ValueError, match="not found"):
            await lifecycle.resume_task_with_password("no-such", {})

    @pytest.mark.asyncio
    async def test_resume_not_awaiting_input_raises(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.RUNNING
        mock_graph.get_task.return_value = task

        with pytest.raises(ValueError, match="not AWAITING_INPUT"):
            await lifecycle.resume_task_with_password(task.id, {"db": "pass"})

    @pytest.mark.asyncio
    async def test_resume_empty_passwords_raises(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.AWAITING_INPUT
        mock_graph.get_task.return_value = task

        with pytest.raises(ValueError, match="non-empty"):
            await lifecycle.resume_task_with_password(task.id, {})

    @pytest.mark.asyncio
    async def test_resume_passwords_not_dict_raises(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.AWAITING_INPUT
        mock_graph.get_task.return_value = task

        with pytest.raises(ValueError, match="non-empty"):
            await lifecycle.resume_task_with_password(task.id, "not-a-dict")

    @pytest.mark.asyncio
    async def test_resume_with_add_log(self, lifecycle, mock_graph):
        task = _make_task()
        task.status = TaskStatus.AWAITING_INPUT
        mock_graph.get_task.return_value = task
        add_log = AsyncMock()

        await lifecycle.resume_task_with_password(task.id, {"db": "pass"}, add_log_callback=add_log)

        add_log.assert_called_once()
        assert "resumed with passwords" in add_log.call_args[0][2].lower()


# ═══════════════════════════════════════════════════════════════
# Dataset Events
# ═══════════════════════════════════════════════════════════════

class TestJobLifecycleDatasetEvents:
    """Verify subscribe/unsubscribe/broadcast dataset events."""

    @pytest.mark.asyncio
    async def test_subscribe_dataset_events_creates_queue(self, lifecycle):
        queue = await lifecycle.subscribe_dataset_events("env-1")
        assert isinstance(queue, asyncio.Queue)
        assert "env-1" in lifecycle._dataset_subscribers
        assert queue in lifecycle._dataset_subscribers["env-1"]

    @pytest.mark.asyncio
    async def test_subscribe_dataset_events_multiple(self, lifecycle):
        q1 = await lifecycle.subscribe_dataset_events("env-1")
        q2 = await lifecycle.subscribe_dataset_events("env-1")
        assert len(lifecycle._dataset_subscribers["env-1"]) == 2

    def test_unsubscribe_dataset_events_removes_queue(self, lifecycle):
        queue = asyncio.Queue()
        lifecycle._dataset_subscribers["env-1"] = [queue]
        lifecycle.unsubscribe_dataset_events("env-1", queue)
        assert "env-1" not in lifecycle._dataset_subscribers

    def test_unsubscribe_dataset_events_not_found(self, lifecycle):
        queue = asyncio.Queue()
        lifecycle.unsubscribe_dataset_events("no-such", queue)  # must not raise

    def test_unsubscribe_dataset_events_not_in_list(self, lifecycle):
        queue = asyncio.Queue()
        lifecycle._dataset_subscribers["env-1"] = [asyncio.Queue()]
        lifecycle.unsubscribe_dataset_events("env-1", queue)
        assert len(lifecycle._dataset_subscribers["env-1"]) == 1

    @pytest.mark.asyncio
    async def test_broadcast_dataset_updated(self, lifecycle):
        queue = await lifecycle.subscribe_dataset_events("env-1")
        await lifecycle._broadcast_dataset_updated("env-1", [1, 2, 3])
        event = await asyncio.wait_for(queue.get(), timeout=0.5)
        assert event["type"] == "dataset.updated"
        assert event["payload"]["env_id"] == "env-1"
        assert event["payload"]["dataset_ids"] == [1, 2, 3]

    @pytest.mark.asyncio
    async def test_broadcast_dataset_updated_no_subscribers(self, lifecycle):
        await lifecycle._broadcast_dataset_updated("orphan", [1])
        # no-op, must not raise

    @pytest.mark.asyncio
    async def test_broadcast_dataset_updated_queue_full_blocks(self, lifecycle):
        full = asyncio.Queue(maxsize=1)
        await full.put("fill")
        lifecycle._dataset_subscribers["env-1"] = [full]
        with pytest.raises(asyncio.TimeoutError):
            await asyncio.wait_for(
                lifecycle._broadcast_dataset_updated("env-1", [1]),
                timeout=0.3,
            )


# ═══════════════════════════════════════════════════════════════
# _task_to_dict helper (tested indirectly via _run_task)
# ═══════════════════════════════════════════════════════════════

class TestInternalHelpers:
    """Verify _task_to_dict and _broadcast_task_status."""

    def test_task_to_dict_includes_all_fields(self):
        from src.core.task_manager.lifecycle import _task_to_dict, _task_to_dict
        from datetime import datetime
        t = _make_task()
        t.status = TaskStatus.RUNNING
        t.started_at = datetime(2024, 1, 1, 12, 0, 0)
        t.result = {"some": "data"}

        d = _task_to_dict(t)
        assert d["id"] == t.id
        assert d["status"] == "RUNNING"
        assert d["started_at"] == "2024-01-01T12:00:00"
        assert d["finished_at"] is None
        assert d["result"] == {"some": "data"}

    def test_task_to_dict_failed_status(self):
        from src.core.task_manager.lifecycle import _task_to_dict
        t = _make_task()
        t.status = TaskStatus.FAILED
        t.result = "Error: something broke"

        d = _task_to_dict(t)
        assert d["status"] == "FAILED"
        assert "Error" in d.get("error", "")

    def test_task_to_dict_empty_dates(self):
        from src.core.task_manager.lifecycle import _task_to_dict
        t = _make_task()
        d = _task_to_dict(t)
        assert d["started_at"] is None
        assert d["finished_at"] is None
        assert d["error"] is None

    @pytest.mark.asyncio
    async def test_broadcast_task_status(self, lifecycle, mock_event_bus):
        from src.core.task_manager.lifecycle import _task_to_dict
        task = _make_task()
        task.status = TaskStatus.RUNNING
        await lifecycle._broadcast_task_status(task)
        mock_event_bus.broadcast_status.assert_called_once()

# #endregion Test.Core.TaskManager.Lifecycle
