"""Batched project summaries (#2384).

The bug was not wrong output — it was CONNECTION COUNT. A per-project summary
opened its own session and then one more per milestone, and the route fanned
that out with asyncio.gather. For 25 projects that asked for roughly 250
checkouts against a pool of 15, so most waited out the 30-second timeout and
every other route on the instance queued behind them:

    QueuePool limit of size 5 overflow 10 reached, connection timed out
    GET /api/settings  500  30584.0ms
    GET /api/projects  200  30882.9ms

So these tests assert the number of sessions opened, not only the values
returned. A version that produced identical output while opening a session per
project would pass a correctness test and reproduce the outage.
"""
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from tests.helpers import make_mock_session


def _session_factory(counter: list[int], results: list):
    """A session whose .execute() returns queued results, counting opens."""
    def _make():
        s = make_mock_session()
        counter[0] += 1

        async def _execute(*_a, **_kw):
            rows = results.pop(0) if results else []
            r = MagicMock()
            r.fetchall = MagicMock(return_value=rows)
            r.scalars = MagicMock(return_value=MagicMock(all=lambda: rows))
            return r
        s.execute = _execute
        return s
    return _make


@pytest.mark.asyncio
async def test_summaries_for_many_projects_open_ONE_session():
    """The whole point. Twenty-five projects must not mean twenty-five
    checkouts — that is the shape that exhausted the pool."""
    from scribe.services import projects as svc

    opened = [0]
    rows = [
        [(1, "todo", 3), (1, "done", 2), (2, "in_progress", 1)],   # task counts
        [(1, 7)],                                                   # note counts
        [],                                                         # last activity
    ]
    with patch.object(svc, "async_session", _session_factory(opened, rows)), \
         patch("scribe.services.milestones.get_project_milestone_summaries",
               AsyncMock(return_value={})):
        out = await svc.get_project_summaries(1, [1, 2])

    assert opened[0] == 1, f"opened {opened[0]} sessions for 2 projects"
    assert out[1]["task_counts"] == {"todo": 3, "in_progress": 0, "done": 2}
    assert out[1]["note_count"] == 7
    # A project with tasks but no notes still reports 0, not a missing key.
    assert out[2]["task_counts"] == {"todo": 0, "in_progress": 1, "done": 0}
    assert out[2]["note_count"] == 0


@pytest.mark.asyncio
async def test_every_requested_project_gets_an_entry():
    """A project with no notes at all must still appear. The frontend indexes
    by id and renders `undefined.task_counts` as a crash, not a blank."""
    from scribe.services import projects as svc

    opened = [0]
    with patch.object(svc, "async_session", _session_factory(opened, [[], [], []])), \
         patch("scribe.services.milestones.get_project_milestone_summaries",
               AsyncMock(return_value={})):
        out = await svc.get_project_summaries(1, [4, 5, 6])

    assert sorted(out) == [4, 5, 6]
    for entry in out.values():
        assert entry["task_counts"] == {"todo": 0, "in_progress": 0, "done": 0}
        assert entry["note_count"] == 0
        assert entry["last_activity"] is None


@pytest.mark.asyncio
async def test_no_projects_opens_no_session_at_all():
    """`.in_([])` is a valid but pointless query; the guard keeps an empty
    install from paying for a connection to learn it has nothing."""
    from scribe.services import projects as svc

    opened = [0]
    with patch.object(svc, "async_session", _session_factory(opened, [])):
        assert await svc.get_project_summaries(1, []) == {}
    assert opened[0] == 0


@pytest.mark.asyncio
async def test_milestone_summaries_for_many_projects_open_ONE_session():
    """Same property one level down — this was the nested half of the fan-out,
    a session per MILESTONE, which is what turned 25 into ~250."""
    from scribe.services import milestones as svc

    m1 = MagicMock(id=10, project_id=1)
    m1.to_dict = MagicMock(return_value={"id": 10, "title": "A"})
    m2 = MagicMock(id=11, project_id=2)
    m2.to_dict = MagicMock(return_value={"id": 11, "title": "B"})

    opened = [0]
    rows = [[m1, m2], [(10, "done", 2), (10, "todo", 1), (11, "cancelled", 1)]]
    with patch.object(svc, "async_session", _session_factory(opened, rows)):
        out = await svc.get_project_milestone_summaries(1, [1, 2])

    assert opened[0] == 1
    assert out[1][0]["completed"] == 2 and out[1][0]["total"] == 3
    # Cancelled is excluded from the denominator, so a milestone whose only
    # task was cancelled reads as complete rather than stalled at 0%.
    assert out[2][0]["pct"] == 0.0 and out[2][0]["status_counts"]["cancelled"] == 1


def test_both_progress_paths_share_one_rule():
    """get_milestone_progress and the batch path must not compute pct
    differently — two screens disagreeing about whether a milestone is done is
    exactly the drift this codebase keeps finding."""
    from scribe.services.milestones import _progress_from_counts

    assert _progress_from_counts({"done": 3, "cancelled": 1})["pct"] == 100.0
    assert _progress_from_counts({"cancelled": 2})["pct"] == 0.0
    assert _progress_from_counts({})["total"] == 0
