"""Campaign scheduling decision — pure functions (no I/O).

This is the key testable seam for the durable scheduler (ADR-0003), mirroring
``judgement.combine_case_outcome``. Given a campaign's static plan and the
current window-clock position, it decides which plan entries are due to spawn
now and whether the window has ended. The async loop and Run-spawning side
effects live in ``campaign_runner``; this module never touches the database,
the clock, or ``time_scale`` semantics beyond the offset mapping below.

Idempotency is the caller's responsibility: it passes the set of already-spawned
plan-entry indices, and this function excludes them from the due list.
"""

from dataclasses import dataclass
from typing import Iterable

from agenteval.models import CampaignPlanEntry


def clock_offset(*, elapsed_seconds: float, time_scale: float) -> float:
    """Map real elapsed wall-clock time to a window offset.

    ``time_scale`` only changes *when* an entry becomes due — 1.0 is real wall
    clock (production), larger compresses the window (development). It never
    touches judgement or report semantics.
    """
    return elapsed_seconds * time_scale


@dataclass(frozen=True)
class DueEntry:
    """A plan entry that is due to spawn, tagged with its plan index."""

    index: int
    entry: CampaignPlanEntry


@dataclass(frozen=True)
class ScheduleDecision:
    """What the scheduler should do at one clock position."""

    due: tuple[DueEntry, ...]
    finished: bool


def decide_schedule(
    *,
    plan: list[CampaignPlanEntry],
    window_seconds: int,
    clock_offset_seconds: float,
    spawned_indices: Iterable[int] = (),
) -> ScheduleDecision:
    """Decide which plan entries are due at ``clock_offset_seconds``.

    An entry is due once the clock reaches its offset and it hasn't been
    spawned yet. ``finished`` is True once the clock reaches the window end.
    """
    already = set(spawned_indices)
    due = tuple(
        DueEntry(index=i, entry=entry)
        for i, entry in enumerate(plan)
        if i not in already and entry.offset_seconds <= clock_offset_seconds
    )
    finished = clock_offset_seconds >= window_seconds
    return ScheduleDecision(due=due, finished=finished)
