"""ACERunner — shared runner infrastructure for all ACE runners."""

from __future__ import annotations

import logging
from collections.abc import Iterable, Sequence
from typing import Any

from pipeline import Pipeline
from pipeline.errors import CancellationToken
from pipeline.protocol import SampleResult

from ..core.context import ACEStepContext, SkillbookView
from ..core.skillbook import Skillbook

logger = logging.getLogger(__name__)


class ACERunner:
    """Shared runner infrastructure for all ACE runners.

    Composes a ``Pipeline`` (does not extend it).  Manages the epoch loop
    and delegates per-sample iteration, error isolation, foreground/background
    split, and concurrent workers to ``Pipeline.run()``.

    Subclasses override two methods:

    - ``run()`` — public API with a subclass-specific signature.
    - ``_build_context()`` — maps a single input item to ``ACEStepContext``.

    You can also construct an ``ACERunner`` directly with a hand-composed
    pipeline::

        from ace import Pipeline, ACERunner, AgentStep, learning_tail

        pipe = Pipeline([AgentStep(agent), *learning_tail(reflector, sm, sb)])
        runner = ACERunner(pipeline=pipe, skillbook=sb)

    Attributes:
        pipeline: The composed ``Pipeline`` instance.  Accessible for
            inspection after construction.
        skillbook: The ``Skillbook`` this runner operates on.
    """

    def __init__(
        self,
        pipeline: Pipeline,
        skillbook: Skillbook,
    ) -> None:
        self.pipeline = pipeline
        self.skillbook = skillbook

    # ------------------------------------------------------------------
    # Lifecycle helpers
    # ------------------------------------------------------------------

    def save(self, path: str) -> None:
        """Save the current skillbook to disk."""
        self.skillbook.save_to_file(path)

    def load(self, path: str) -> None:
        """Load a skillbook from disk, replacing the current one."""
        self.skillbook = Skillbook.load_from_file(path)

    def wait_for_background(self, timeout: float | None = None) -> None:
        """Block until all background learning tasks complete.

        Delegates to ``Pipeline.wait_for_background()``.  Call after
        ``run(wait=False)`` before saving the skillbook or reading final
        results.
        """
        self.pipeline.wait_for_background(timeout)

    @property
    def learning_stats(self) -> dict[str, int]:
        """Return background learning progress.

        Delegates to ``Pipeline.background_stats()``.
        """
        return self.pipeline.background_stats()

    # ------------------------------------------------------------------
    # Generic epoch loop (called by subclasses)
    # ------------------------------------------------------------------

    def _run(
        self,
        items: Sequence[Any] | Iterable[Any],
        *,
        epochs: int,
        wait: bool = True,
        cancel_token: CancellationToken | None = None,
        **kwargs: Any,
    ) -> list[SampleResult]:
        """Generic run loop handling epochs and Iterable validation.

        Returns when ``wait=True`` (default).  Returns after foreground
        steps when ``wait=False`` — background learning continues.

        Args:
            cancel_token: Optional cancellation signal.  Forwarded to
                ``Pipeline.run()`` — checked between steps and inside
                LLM calls (via contextvar).

        Raises ``ValueError`` if ``epochs > 1`` and *items* is not a
        ``Sequence``.
        """
        if epochs > 1 and not isinstance(items, Sequence):
            raise ValueError(
                "Multi-epoch requires a Sequence, not a consumed Iterable."
            )

        results: list[SampleResult] = []
        n: int | None = len(items) if isinstance(items, Sequence) else None

        for epoch in range(1, epochs + 1):
            if cancel_token is not None and cancel_token.is_cancelled:
                break
            logger.info(
                "Epoch %d/%d: processing %s samples",
                epoch,
                epochs,
                n if n is not None else "unknown",
            )
            contexts: list[ACEStepContext] = [
                self._build_context(
                    item,
                    epoch=epoch,
                    total_epochs=epochs,
                    index=idx,
                    total=n,
                    global_sample_index=(
                        (epoch - 1) * n + idx if n is not None else idx
                    ),
                    **kwargs,
                )
                for idx, item in enumerate(items, start=1)
            ]
            epoch_results = self.pipeline.run(contexts, cancel_token=cancel_token)
            results.extend(epoch_results)

        if wait:
            self.pipeline.wait_for_background()

        return results

    # ------------------------------------------------------------------
    # Subclass interface
    # ------------------------------------------------------------------

    def _build_context(
        self,
        item: Any,
        *,
        epoch: int,
        total_epochs: int,
        index: int,
        total: int | None,
        global_sample_index: int,
        **kwargs: Any,
    ) -> ACEStepContext:
        """Map a single input item to an ``ACEStepContext``.

        Must be overridden by subclasses.  Stateless — depends only on
        the item and the provided counters.
        """
        raise NotImplementedError
