"""
Stimulus engine — Phase 3. The PsychoPy / Study-Builder replacement.

A study presents a timed sequence of stimuli and pushes an LSL marker
(`stim_on:<name>` / `stim_off:<name>`) at each onset/offset. Those markers are the
SAME shape the recorder and `analysis/` already consume (see `synthetic.StimulusMarkers`
and `analysis.load.markers`) — so every recording becomes event-locked end to end
with nothing downstream changing.

Two ways to run, identical marker output:

  * `StudySource`     — a managed biosync Source (subclass), runs the timeline in a
                        background thread, pushes markers. Use for headless / dry runs,
                        integration tests, or hardware-in-the-loop without visuals.
  * `run_study(...)`  — runs in the MAIN thread (where PsychoPy's GL window must live)
                        presenting real stimuli via `psychopy_present`, pushing to the
                        same marker outlet.

Both reuse the canonical Markers `StreamSpec`, so a study is just another LSL outlet.
"""

from __future__ import annotations

import time
from dataclasses import dataclass
from typing import Callable, Sequence

from pylsl import StreamOutlet, local_clock

from ..core.source import Source, StreamSpec

# The one canonical marker stream description (matches StimulusMarkers exactly).
MARKER_SPEC = StreamSpec(
    name="Markers", stype="Markers", channels=["marker"],
    nominal_srate=0.0, channel_format="string", source_id="biosync-study-markers",
)


@dataclass
class Trial:
    """One stimulus presentation."""
    name: str                 # marker label, e.g. "face_A"
    image: str | None = None  # path to image (used by psychopy_present); None = blank
    duration: float = 2.0     # seconds on screen
    isi: float = 1.0          # inter-stimulus interval (blank) after offset


def make_study(stimuli: Sequence[str | Trial], duration=2.0, isi=1.0,
               repeats=1) -> list[Trial]:
    """Convenience: build a trial list from names or Trials, repeated/looped."""
    base = [s if isinstance(s, Trial) else Trial(s, duration=duration, isi=isi)
            for s in stimuli]
    return [Trial(t.name, t.image, t.duration, t.isi)
            for _ in range(repeats) for t in base]


# --------------------------------------------------------------------------
# Presenters: a presenter is `present(trial) -> None` that blocks for the
# stimulus duration. The driver pushes markers around the call.
# --------------------------------------------------------------------------
def headless_present(trial: Trial) -> None:
    """No display — just hold for the duration. Dry runs, tests, HIL."""
    time.sleep(trial.duration)


def psychopy_present_factory(size=(1280, 720), fullscr=False, color=(0, 0, 0)):
    """
    Build a PsychoPy presenter bound to one window. PsychoPy is imported lazily so
    the package has no hard dependency on it (and headless runs need nothing).

    Returns (present, close). Call `close()` when the study ends.
    """
    from psychopy import visual, core  # noqa: lazy, optional dependency

    win = visual.Window(size=size, fullscr=fullscr, color=color, units="norm")
    cache: dict[str, object] = {}

    def present(trial: Trial) -> None:
        if trial.image:
            stim = cache.get(trial.image)
            if stim is None:
                stim = visual.ImageStim(win, image=trial.image)
                cache[trial.image] = stim
            stim.draw()
        win.flip()
        core.wait(trial.duration)
        win.flip()  # clear to blank for the ISI

    def close() -> None:
        win.close()

    return present, close


# kept as a name for the public API / docs; real use goes through the factory
def psychopy_present(trial: Trial) -> None:  # pragma: no cover - needs a display
    present, close = psychopy_present_factory()
    try:
        present(trial)
    finally:
        close()


# --------------------------------------------------------------------------
# Drivers
# --------------------------------------------------------------------------
def _drive(trials, outlet: StreamOutlet, present: Callable[[Trial], None],
           should_stop=lambda: False) -> list[tuple]:
    """Core timeline: push onset, present, push offset, ISI. Returns the log."""
    log = []
    for tr in trials:
        if should_stop():
            break
        on = f"stim_on:{tr.name}"
        outlet.push_sample([on], local_clock())
        log.append((local_clock(), on))
        present(tr)
        off = f"stim_off:{tr.name}"
        outlet.push_sample([off], local_clock())
        log.append((local_clock(), off))
        if tr.isi:
            time.sleep(tr.isi)
    return log


def run_study(trials: Sequence[Trial], present: Callable[[Trial], None] | None = None
              ) -> list[tuple]:
    """
    Main-thread study runner (use with `psychopy_present_factory` for real visuals).
    Creates the marker outlet, drives the timeline, returns the marker log.
    """
    present = present or headless_present
    outlet = StreamOutlet(MARKER_SPEC.to_lsl())
    return _drive(trials, outlet, present)


class StudySource(Source):
    """
    Run a study as a managed biosync Source (background thread). Pushes the same
    Markers stream as everything else, so it slots straight into a multi-source
    recording / the spine demo.
    """

    def __init__(self, trials: Sequence[Trial],
                 present: Callable[[Trial], None] | None = None,
                 loop: bool = True):
        super().__init__(MARKER_SPEC)
        self.trials = list(trials)
        self.present = present or headless_present
        self.loop = loop

    def read(self):
        while not self.stopping:
            for tr in self.trials:
                if self.stopping:
                    return
                yield [f"stim_on:{tr.name}"], local_clock()
                self.present(tr)
                yield [f"stim_off:{tr.name}"], local_clock()
                if tr.isi and not self.stopping:
                    time.sleep(tr.isi)
            if not self.loop:
                return
