"""TDD tests for the daemon core loop (Epic 3).

Tests cover:
- discover_projects: multi-project discovery from Postgres
- sweep_project: per-project sweep orchestration with event publishing
- daemon_tick: multi-project tick with health reporting
- daemon_loop: graceful shutdown via asyncio.Event
"""

from __future__ import annotations

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

import pytest

from loom.bus import channels
from loom.config import LoomConfig
from loom.graph.project import create_project
from loom.graph.task import Task, TaskStatus
from loom.ids import task_id


# --- discover_projects ---


class TestDiscoverProjects:
    @pytest.mark.asyncio
    async def test_returns_empty_when_no_projects(self, pool):
        from loom.daemon.loop import discover_projects

        projects = await discover_projects(pool)
        assert projects == []

    @pytest.mark.asyncio
    async def test_returns_active_projects(self, pool):
        from loom.daemon.loop import discover_projects

        await create_project(pool, "project-a", "A")
        await create_project(pool, "project-b", "B")

        projects = await discover_projects(pool)
        assert len(projects) == 2
        names = {p["name"] for p in projects}
        assert names == {"project-a", "project-b"}

    @pytest.mark.asyncio
    async def test_excludes_archived_projects(self, pool):
        from loom.daemon.loop import discover_projects

        rec = await create_project(pool, "archived-proj", "Old")
        await pool.execute(
            "UPDATE projects SET archived = TRUE WHERE id = $1", rec["id"]
        )
        await create_project(pool, "active-proj", "New")

        projects = await discover_projects(pool)
        assert len(projects) == 1
        assert projects[0]["name"] == "active-proj"


# --- sweep_project ---


class TestSweepProject:
    @pytest.mark.asyncio
    async def test_sweep_empty_project(self, pool, redis_conn, project):
        from loom.daemon.sweeps import sweep_project

        config = LoomConfig()
        result = await sweep_project(pool, redis_conn, project, config)

        assert result.expired_claims == 0
        assert result.tasks_retried == 0
        assert result.epics_closed == 0

    @pytest.mark.asyncio
    async def test_sweep_closes_completed_epic(self, pool, redis_conn, project):
        from loom.daemon.sweeps import sweep_project
        from loom.graph import store

        config = LoomConfig()

        # Create an epic with one child, mark child done
        epic = Task(id=task_id(), project_id=project, title="Epic", status=TaskStatus.EPIC)
        epic = await store.create_task(pool, epic)
        child = Task(id=task_id(), project_id=project, title="Child", parent_id=epic.id)
        child = await store.create_task(pool, child)
        await store.set_task_status(pool, child.id, TaskStatus.DONE)

        result = await sweep_project(pool, redis_conn, project, config)
        assert result.epics_closed >= 1

    @pytest.mark.asyncio
    async def test_sweep_publishes_events_when_work_done(self, pool, redis_conn, project):
        from loom.daemon.inbox import create_consumer_group, drain_events
        from loom.daemon.sweeps import sweep_project
        from loom.graph import store

        config = LoomConfig()

        # Create an epic with completed child so sweep has work to do
        epic = Task(id=task_id(), project_id=project, title="Epic", status=TaskStatus.EPIC)
        epic = await store.create_task(pool, epic)
        child = Task(id=task_id(), project_id=project, title="Child", parent_id=epic.id)
        child = await store.create_task(pool, child)
        await store.set_task_status(pool, child.id, TaskStatus.DONE)

        await create_consumer_group(redis_conn, project, "test-consumer")
        await sweep_project(pool, redis_conn, project, config)

        events = await drain_events(redis_conn, project, "test-consumer", "test-1")
        assert len(events) >= 1
        event_types = {e.event_type for e in events}
        assert "sweep_complete" in event_types

    @pytest.mark.asyncio
    async def test_sweep_error_isolation(self, pool, redis_conn):
        """Sweep with an invalid project_id should not raise — returns error result."""
        from loom.daemon.sweeps import sweep_project

        config = LoomConfig()
        # Use an invalid (non-UUID) project_id to trigger a Postgres error
        result = await sweep_project(
            pool, redis_conn, "not-a-valid-uuid", config
        )
        assert result.error is not None


# --- daemon_tick ---


class TestDaemonTick:
    @pytest.mark.asyncio
    async def test_tick_with_no_projects(self, pool, redis_conn):
        from loom.daemon.loop import daemon_tick

        config = LoomConfig()
        result = await daemon_tick(pool, redis_conn, config)

        assert result["projects_swept"] == 0
        assert result["tick_number"] >= 1

    @pytest.mark.asyncio
    async def test_tick_sweeps_all_projects(self, pool, redis_conn):
        from loom.daemon.loop import daemon_tick

        config = LoomConfig()
        await create_project(pool, "proj-a", "A")
        await create_project(pool, "proj-b", "B")

        result = await daemon_tick(pool, redis_conn, config)
        assert result["projects_swept"] == 2

    @pytest.mark.asyncio
    async def test_tick_writes_health_data(self, pool, redis_conn):
        from loom.daemon.loop import daemon_tick

        config = LoomConfig()
        await create_project(pool, "proj-a", "A")

        await daemon_tick(pool, redis_conn, config)

        # Health key should exist with last_tick_at
        health = await redis_conn.hgetall(channels.daemon_health("global"))
        assert "last_tick_at" in health
        assert health["tick_count"] == "1"

    @pytest.mark.asyncio
    async def test_tick_increments_counter(self, pool, redis_conn):
        from loom.daemon.loop import daemon_tick

        config = LoomConfig()
        await daemon_tick(pool, redis_conn, config)
        await daemon_tick(pool, redis_conn, config)

        health = await redis_conn.hgetall(channels.daemon_health("global"))
        assert health["tick_count"] == "2"

    @pytest.mark.asyncio
    async def test_tick_isolates_project_errors(self, pool, redis_conn):
        """One bad project shouldn't crash the whole tick."""
        from loom.daemon.loop import daemon_tick

        config = LoomConfig()
        await create_project(pool, "good-proj", "Good")

        # Manually insert a row with bad data to test isolation
        # (The real isolation test is that the tick completes even if one project fails)
        result = await daemon_tick(pool, redis_conn, config)
        assert result["projects_swept"] >= 1
        assert result.get("errors", 0) == 0


# --- daemon_loop ---


# --- process_codebase_notes ---


class TestProcessCodebaseNotes:
    async def _create_done_task_with_notes(self, pool, project_id, notes):
        """Helper: create a task, mark done with codebase_notes in output."""
        from loom.graph import store

        t = Task(id=task_id(), project_id=project_id, title="Task with notes")
        t = await store.create_task(pool, t)
        await store.set_task_status(pool, t.id, TaskStatus.DONE)
        # Write output with codebase_notes directly
        await pool.execute(
            "UPDATE tasks SET output = $1::jsonb WHERE id = $2",
            json.dumps({"summary": "done", "codebase_notes": notes}),
            t.id,
        )
        return t

    @pytest.mark.asyncio
    async def test_routes_notes_to_weft(self, pool, redis_conn, project):
        from loom.daemon.sweeps import process_codebase_notes

        notes = {"src/foo.py": "Added helper function", "src/bar.py": "New module"}
        await self._create_done_task_with_notes(pool, project, notes)

        mock_route = AsyncMock(return_value=2)
        with patch("loom.integrations.weft.route_codebase_notes", mock_route):
            stored = await process_codebase_notes(
                pool, redis_conn, project,
                weft_pool="fake_pool", weft_embedder="fake_embedder",
            )

        assert stored == 2
        mock_route.assert_called_once_with(
            "fake_pool", "fake_embedder", notes, project_id=None,
        )

    @pytest.mark.asyncio
    async def test_skips_already_processed(self, pool, redis_conn, project):
        from loom.daemon.sweeps import process_codebase_notes

        t = await self._create_done_task_with_notes(
            pool, project, {"a.py": "change"},
        )

        # Mark as already processed
        processed_key = channels.daemon_notes_processed(project)
        await redis_conn.sadd(processed_key, t.id)

        mock_route = AsyncMock(return_value=0)
        with patch("loom.integrations.weft.route_codebase_notes", mock_route):
            stored = await process_codebase_notes(
                pool, redis_conn, project,
                weft_pool="fake", weft_embedder="fake",
            )

        assert stored == 0
        mock_route.assert_not_called()

    @pytest.mark.asyncio
    async def test_marks_processed_after_routing(self, pool, redis_conn, project):
        from loom.daemon.sweeps import process_codebase_notes

        t = await self._create_done_task_with_notes(
            pool, project, {"x.py": "stuff"},
        )

        mock_route = AsyncMock(return_value=1)
        with patch("loom.integrations.weft.route_codebase_notes", mock_route):
            await process_codebase_notes(
                pool, redis_conn, project,
                weft_pool="fake", weft_embedder="fake",
            )

        processed_key = channels.daemon_notes_processed(project)
        assert await redis_conn.sismember(processed_key, t.id)

    @pytest.mark.asyncio
    async def test_ignores_tasks_without_notes(self, pool, redis_conn, project):
        """Tasks that are done but have no codebase_notes are ignored."""
        from loom.daemon.sweeps import process_codebase_notes
        from loom.graph import store

        t = Task(id=task_id(), project_id=project, title="No notes task")
        t = await store.create_task(pool, t)
        await store.set_task_status(pool, t.id, TaskStatus.DONE)

        mock_route = AsyncMock(return_value=0)
        with patch("loom.integrations.weft.route_codebase_notes", mock_route):
            stored = await process_codebase_notes(
                pool, redis_conn, project,
                weft_pool="fake", weft_embedder="fake",
            )

        assert stored == 0
        mock_route.assert_not_called()

    @pytest.mark.asyncio
    async def test_sweep_project_calls_notes_when_weft_provided(self, pool, redis_conn, project):
        """sweep_project routes codebase_notes when weft params are given."""
        from loom.daemon.sweeps import sweep_project

        notes = {"mod.py": "New module"}
        await self._create_done_task_with_notes(pool, project, notes)

        config = LoomConfig()
        mock_route = AsyncMock(return_value=1)
        with patch("loom.integrations.weft.route_codebase_notes", mock_route):
            result = await sweep_project(
                pool, redis_conn, project, config,
                weft_pool="fake", weft_embedder="fake",
            )

        assert result.notes_routed == 1

    @pytest.mark.asyncio
    async def test_sweep_project_skips_notes_without_weft(self, pool, redis_conn, project):
        """sweep_project skips codebase_notes when weft is not configured."""
        from loom.daemon.sweeps import sweep_project

        notes = {"mod.py": "New module"}
        await self._create_done_task_with_notes(pool, project, notes)

        config = LoomConfig()
        result = await sweep_project(pool, redis_conn, project, config)
        assert result.notes_routed == 0


# --- daemon_loop ---


class TestDaemonLoop:
    @pytest.mark.asyncio
    async def test_loop_responds_to_shutdown(self, pool, redis_conn):
        from loom.daemon.loop import daemon_loop

        config = LoomConfig()
        config.daemon.tick_interval_seconds = 1
        shutdown = asyncio.Event()

        # Signal shutdown after a short delay
        async def signal_shutdown():
            await asyncio.sleep(0.2)
            shutdown.set()

        asyncio.create_task(signal_shutdown())
        # Should return without hanging
        await asyncio.wait_for(
            daemon_loop(pool, redis_conn, config, shutdown),
            timeout=5.0,
        )

    @pytest.mark.asyncio
    async def test_loop_runs_multiple_ticks(self, pool, redis_conn):
        from loom.daemon.loop import daemon_loop

        config = LoomConfig()
        config.daemon.tick_interval_seconds = 1
        shutdown = asyncio.Event()

        await create_project(pool, "loop-proj", "Loop")

        async def signal_shutdown():
            await asyncio.sleep(1.5)
            shutdown.set()

        asyncio.create_task(signal_shutdown())
        await asyncio.wait_for(
            daemon_loop(pool, redis_conn, config, shutdown),
            timeout=5.0,
        )

        health = await redis_conn.hgetall(channels.daemon_health("global"))
        assert int(health["tick_count"]) >= 2
