"""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 datetime import datetime, timezone
from enum import Enum
from typing import Iterable, Optional

from agenteval.models import CampaignPlanEntry, CampaignStatus


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


def elapsed_seconds(*, now: datetime, started_at: datetime) -> float:
    """Real seconds between ``started_at`` and ``now``.

    The clock is injected (``now``) so this stays pure and unit-testable.
    ``started_at`` from SQLite may be naive; treat it as UTC (AGENTS.md #2).
    """
    if started_at.tzinfo is None:
        started_at = started_at.replace(tzinfo=timezone.utc)
    if now.tzinfo is None:
        now = now.replace(tzinfo=timezone.utc)
    return (now - started_at).total_seconds()


@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)


class TickAction(str, Enum):
    """What the durable loop should do after one tick's decision."""

    CONTINUE = "continue"  # still inside the window — spawn due, then sleep
    COMPLETE = "complete"  # window ended — mark the campaign completed
    STOP = "stop"          # not running (cancelled/absent) — exit, touch nothing


@dataclass(frozen=True)
class TickDecision:
    """The full per-tick decision: window position, what's due, and lifecycle."""

    offset: float
    due: tuple[DueEntry, ...]
    finished: bool
    action: TickAction


def decide_tick(
    *,
    now: datetime,
    started_at: Optional[datetime],
    status: CampaignStatus,
    window_seconds: int,
    time_scale: float,
    plan: list[CampaignPlanEntry],
    spawned_indices: Iterable[int] = (),
) -> TickDecision:
    """Decide everything one loop tick needs, purely from the clock + DB state.

    The shell reads ``now`` and the campaign row, calls this, then only does
    I/O (spawn due Runs, persist progress, write COMPLETED). A campaign that is
    not RUNNING or has no ``started_at`` yields ``STOP`` — the loop exits without
    touching status. Otherwise the window offset drives ``CONTINUE`` vs
    ``COMPLETE``. The COMPLETE write is still gated by ``resolve_finalize`` on a
    freshly re-read status, so a cancel arriving mid-tick is never overwritten.
    """
    if status != CampaignStatus.RUNNING or started_at is None:
        return TickDecision(offset=0.0, due=(), finished=False, action=TickAction.STOP)

    offset = clock_offset(
        elapsed_seconds=elapsed_seconds(now=now, started_at=started_at),
        time_scale=time_scale,
    )
    schedule = decide_schedule(
        plan=plan,
        window_seconds=window_seconds,
        clock_offset_seconds=offset,
        spawned_indices=spawned_indices,
    )
    action = TickAction.COMPLETE if schedule.finished else TickAction.CONTINUE
    return TickDecision(offset=offset, due=schedule.due, finished=schedule.finished, action=action)


def resolve_finalize(current_status: CampaignStatus) -> TickAction:
    """Cancel-race guard: complete only if the campaign is *still* RUNNING.

    Called by the shell after spawning, on a freshly re-read status, so a
    cancellation that landed between finish-detection and the COMPLETED write
    wins — a cancelled campaign stays cancelled.
    """
    return TickAction.COMPLETE if current_status == CampaignStatus.RUNNING else TickAction.STOP
