"""PollScheduler -- the prioritized acquisition engine.

The ELM327 is a one-request-at-a-time straw (~7-15 reads/sec total).  This
scheduler holds a subscription set (PID key -> target Hz) and, each tick,
reads the PIDs that are due, round-robin, pushing timestamped samples into the
TimeSeriesStore.  Dead PIDs (4 consecutive no-responses) are parked and
periodically revived so they don't starve the sample rate.

Acquisition runs on a background thread; tick() is also public so tests can
drive it deterministically with a fake clock (no threads, no sleeps).
"""
import queue
import threading
import time


class _OneOff:
    """A single command to run once on the polling thread (DTC read/clear,
    probe, etc).  The submitter blocks on `done` until the thread has run it."""
    __slots__ = ("fn", "done", "result", "error")

    def __init__(self, fn):
        self.fn = fn
        self.done = threading.Event()
        self.result = None
        self.error = None


class _Sub:
    __slots__ = ("key", "period", "next_due", "fails", "active")

    def __init__(self, key, period):
        self.key = key
        self.period = period
        self.next_due = 0.0
        self.fails = 0
        self.active = True


class PollScheduler:
    def __init__(self, link, registry, store, clock=time.time, dead_after=4,
                 revive_every=5.0):
        self.link = link
        self.reg = registry
        self.store = store
        self.clock = clock
        self.dead_after = dead_after
        self.revive_every = revive_every
        self._subs = {}
        self._lock = threading.Lock()
        self._thread = None
        self._running = False
        self._last_revive = 0.0
        self._oneoffs = queue.Queue()

    # -- subscription management --
    def set_subscriptions(self, specs):
        """specs: iterable of (key, hz).  Replaces the whole set."""
        with self._lock:
            self._subs = {k: _Sub(k, (1.0 / hz) if hz > 0 else 0.5) for k, hz in specs}

    def subscribe(self, key, hz):
        with self._lock:
            self._subs[key] = _Sub(key, (1.0 / hz) if hz > 0 else 0.5)

    def unsubscribe(self, key):
        with self._lock:
            self._subs.pop(key, None)

    def subscriptions(self):
        with self._lock:
            return list(self._subs.keys())

    # -- the core read of a single PID --
    def _read(self, p, frame_vals):
        if p.mode == "atrv":
            return self.link.read_atrv()
        if p.mode == "derived":
            vals = [frame_vals.get(d, self.store.latest(d)) for d in p.deps]
            if any(v is None for v in vals):
                return None
            try:
                return p.decode(vals)
            except Exception:
                return None
        raw = (self.link.read_m01(p.pid, p.nbytes) if p.mode == "01"
               else self.link.read_m22(p.pid))
        if not raw:
            return None
        try:
            return p.decode(raw)
        except Exception:
            return None

    # -- one-off commands (thread-safe, serialized onto the polling thread) --
    def _drain_oneoffs(self):
        """Run every queued one-off on the *calling* thread.  Invoked at the top
        of tick() so one-offs interleave with polling on the same thread that
        owns the serial link -- never concurrently with a PID read."""
        while True:
            try:
                job = self._oneoffs.get_nowait()
            except queue.Empty:
                return
            try:
                job.result = job.fn()
            except Exception as e:                    # hand the failure back
                job.error = e
            finally:
                job.done.set()

    def run_oneoff(self, fn, timeout=8.0):
        """Enqueue `fn` to run once on the polling thread and block for its
        result (or re-raise its exception).  When the scheduler thread isn't
        running, the job is drained inline on the caller -- still serialized
        against tick(), and safe because nothing else is touching the link."""
        job = _OneOff(fn)
        self._oneoffs.put(job)
        if not self._running:
            self._drain_oneoffs()
        if not job.done.wait(timeout):
            raise TimeoutError("one-off command timed out")
        if job.error is not None:
            raise job.error
        return job.result

    def tick(self, now=None):
        """Read all due PIDs once.  Returns number of PIDs read."""
        self._drain_oneoffs()                         # one-offs first, same thread
        now = self.clock() if now is None else now
        if now - self._last_revive >= self.revive_every:
            with self._lock:
                for s in self._subs.values():
                    if not s.active:
                        s.active, s.fails = True, 0
            self._last_revive = now

        with self._lock:
            due = [s for s in self._subs.values() if s.active and now >= s.next_due]
        due.sort(key=lambda s: s.next_due)

        frame_vals = {}
        # non-derived first so derived channels can use this frame's values
        order = sorted(due, key=lambda s: 1 if _is_derived(self.reg, s.key) else 0)
        for s in order:
            p = self.reg.get(s.key)
            if p is None:
                continue
            v = self._read(p, frame_vals)
            frame_vals[s.key] = v
            self.store.push(s.key, self.clock(), v)
            s.next_due = now + s.period
            if v is None:
                s.fails += 1
                if s.fails >= self.dead_after:
                    s.active = False
            else:
                s.fails = 0
        return len(order)

    # -- background thread --
    def start(self):
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()

    def _loop(self):
        while self._running:
            n = self.tick()
            if n == 0:
                time.sleep(0.005)            # nothing due; yield

    def stop(self):
        self._running = False
        if self._thread:
            self._thread.join(timeout=2.0)
            self._thread = None


def _is_derived(reg, key):
    p = reg.get(key)
    return bool(p and p.mode == "derived")
