"""Bounded observations and explicit episode state, independent of Event I/O."""
from collections import deque
from features import features,match_cost


class Episode:
    def __init__(self,cfg):
        self.cfg=cfg;self.latched=False;self.last_event=None;self.last_hit=None
        self.last_valid=None;self.last_time=None;self.previous_quiet=False;self.quiet=0.

    def step(self,t,hit,quiet_valid,allow_event=True):
        cfg=self.cfg;dt=0 if self.last_time is None else t-self.last_time
        contiguous=0<dt<=cfg.max_step_gap+1e-8
        self.last_time=t;event=False;transition=None
        if hit or quiet_valid:self.last_valid=t
        if hit:
            self.last_hit=t;self.quiet=0.;self.previous_quiet=False
            if not self.latched and allow_event and (self.last_event is None or t-self.last_event>=cfg.event_cooldown-1e-8):
                self.latched=True;self.last_event=t;event=True;transition='NEW_TEST_EVENT'
        else:
            self.quiet=self.quiet+dt if quiet_valid and self.previous_quiet and contiguous else 0.
            self.previous_quiet=quiet_valid
            if self.latched and self.quiet>=cfg.rearm_quiet-1e-8:
                self.latched=False;transition='REARMED_OBSERVED_QUIET'
            elif self.latched and self.last_valid is not None and t-self.last_valid>cfg.pair_retention:
                self.latched=False;transition='EXPIRED_NOT_NORMAL'
        display=hit or (self.latched and self.last_hit is not None and t-self.last_hit<=cfg.display_hold+1e-8)
        status=('ALERT' if event else 'RULE_HIT_LATCHED' if hit and self.latched else 'RULE_HIT_COOLDOWN' if hit
                else 'DISPLAY_HOLD' if display else 'UNKNOWN_LATCHED' if self.latched and not quiet_valid
                else 'QUIET_REARM_WAIT' if self.latched else 'MONITORING' if quiet_valid else 'INSUFFICIENT')
        return dict(status=status,event=event,display=display,latched=self.latched,quiet_seconds=self.quiet,transition=transition)


class PairState:
    def __init__(self,token,cfg):
        self.token=token;self.cfg=cfg;self.history=deque(maxlen=40);self.records=deque(maxlen=40)
        self.pulses=deque(maxlen=16);self.high=False;self.last_pulse=None;self.last_seen=None;self.last_bodies=None
        self.close_seconds=0.;self.episode=Episode(cfg)

    def step(self,t,current):
        cfg=self.cfg;dt=0 if self.last_seen is None else t-self.last_seen
        contiguous=0<dt<=cfg.max_step_gap+1e-8
        sane=self.last_bodies is None or match_cost(self.last_bodies,current,cfg) is not None
        reason=None
        if not contiguous or not sane:
            self.history.clear();self.records.clear();self.high=False;self.close_seconds=0.
            reason='NEW_OR_GAPPED_PAIR' if sane else 'GEOMETRY_DISCONTINUITY'
            if not sane:self.pulses.clear()
        else:self.close_seconds+=dt
        while self.history and t-self.history[0][0]>cfg.window_seconds+1e-8:self.history.popleft()
        while self.records and t-self.records[0][0]>cfg.window_seconds+1e-8:self.records.popleft()
        while self.pulses and t-self.pulses[0]>cfg.window_seconds+1e-8:self.pulses.popleft()
        baseline=next(((ts,b) for ts,b in reversed(self.history) if cfg.motion_lag_seconds-1e-8<=t-ts<=cfg.motion_lag_seconds+cfg.max_step_gap+1e-8),None)
        f=None
        if baseline:
            f=features(baseline[1],current,t-baseline[0],cfg)
            if not any(f['observable']):reason='ARM_OR_ANCHOR_UNOBSERVABLE'
        elif reason is None:reason='WARMUP'
        raw_strike=bool(f and f['strike']);new_pulse=False
        if raw_strike and not self.high and self.close_seconds>=cfg.pair_close_hold-1e-8 and (self.last_pulse is None or t-self.last_pulse>=cfg.strike_interval-1e-8):
            self.pulses.append(t);self.last_pulse=t;new_pulse=True
        self.high=raw_strike
        observed_dt=dt if contiguous and sane and f and any(f['observable']) else 0.
        self.records.append((t,observed_dt,bool(f and f['contact']),bool(f and f['dynamic'])))
        # Clip each measured interval to the current window. Gaps never contribute duration.
        contact_s=sum(max(0,min(d,ts-(t-cfg.window_seconds))) for ts,d,c,m in self.records if c)
        dynamic_s=sum(max(0,min(d,ts-(t-cfg.window_seconds))) for ts,d,c,m in self.records if m)
        dynamic_n=sum(m and d>0 for ts,d,c,m in self.records)
        strike=raw_strike and len(self.pulses)>=cfg.required_strike_pulses
        contact=bool(f and f['dynamic'] and contact_s>=cfg.contact_seconds-1e-8 and dynamic_s>=cfg.dynamic_contact_seconds-1e-8 and dynamic_n>=cfg.min_dynamic_samples)
        rules=(['STRIKE'] if strike else [])+(['PUSH_GRAPPLE'] if contact else [])
        quiet=bool(f and all(f['observable']) and not raw_strike and not f['dynamic'] and not self.pulses)
        self.history.append((t,current));self.last_seen=t;self.last_bodies=current
        return dict(token=self.token,refs=[b['ref'] for b in current],orders=[b['order'] for b in current],
            ids=[b['tid'] for b in current],bboxes=[b['bbox'] for b in current],rules=rules,hit=bool(rules),quiet_valid=quiet,
            reason=reason or 'OBSERVED',close_seconds=self.close_seconds,pulses=len(self.pulses),new_pulse=new_pulse,
            contact_seconds=contact_s,dynamic_seconds=dynamic_s,dynamic_samples=dynamic_n,
            input_start=None if baseline is None else baseline[0],input_end=t,window_start=t-cfg.window_seconds,
            pulse_times=list(self.pulses),features=f)
