"""Deterministic, run-local FALL core. No Runtime wiring, I/O, tracker or wall clock.

Call evaluate once per accepted source Frame, including empty observations.
An omitted/transport-dropped interval must be declared with gap_before=True;
frame_seq jumps alone cannot distinguish planned sampling from missing evidence.
The owning result worker serializes calls. This engine creates internal candidates,
not durable Events or externally visible schemas.
"""
from collections import Counter, deque
from dataclasses import dataclass, field
from statistics import median
from math import isfinite

from app.contracts.frames import FrameEnvelope, PoseObservation
from app.fall_config import FallConfig, seconds_ns
from app.fall_geometry import EPS, SPAN_SOURCES, PoseFeatures, extract_features
from app.pose_config import POSE_MODEL


@dataclass(frozen=True)
class FallEvidence:
    geometry: PoseFeatures
    torso_delta: float | None = None
    hip_drop_ratio: float | None = None
    body_scale_source: str | None = None
    body_scale_value: float | None = None
    recent_span_ratio: float | None = None
    upright_span_ratio: float | None = None
    cues: tuple[bool | None, ...] = (None, None, None)
    down: bool | None = None
    down_source: str | None = None

    @property
    def available_count(self):
        return sum(value is not None for value in self.cues)

    @property
    def votes(self):
        return sum(value is True for value in self.cues)

    @property
    def transition(self):
        return (self.available_count >= 2 and self.votes >= 2
                and (self.geometry.knee_guard is not True or self.cues[0] is True))


@dataclass(frozen=True)
class FallDecision:
    track_id: str | int | None
    object_ref: str
    state: str
    alert: bool
    evidence: FallEvidence
    down_hold_ns: int = 0
    recovery_hold_ns: int = 0


@dataclass(frozen=True)
class FallEventCandidate:
    """Internal candidate. Runtime assigns the common durable eventId later."""
    frame: FrameEnvelope
    track_id: str | int
    object_ref: str
    bbox_xyxy: tuple[float, float, float, float]
    person_score: float
    episode: int
    scenario: str = 'FALL'


@dataclass(frozen=True)
class FallEvaluation:
    frame: FrameEnvelope
    accepted: bool
    decisions: tuple[FallDecision, ...] = ()
    event_candidates: tuple[FallEventCandidate, ...] = ()
    reason: str | None = None


@dataclass
class _Hold:
    total: int = 0
    last_true: int | None = None
    last_known: int | None = None
    source: str | None = None

    def reset(self):
        self.total = 0
        self.last_true = self.last_known = self.source = None

    def step(self, value, pts, grace, *, source=None, gap=False):
        expired = self.last_known is not None and pts - self.last_known > grace
        if expired:
            self.reset()
        if value is None:
            self.last_true = None  # Never count the edges around an unavailable interval.
            return expired
        self.last_known = pts
        if value is False:
            self.total = 0
            self.last_true = self.source = None
            return expired
        if (not gap and self.last_true is not None and self.source == source
                and 0 < pts - self.last_true <= grace):
            self.total += pts - self.last_true
        self.last_true, self.source = pts, source
        return expired


@dataclass
class _Recent:
    samples: deque = field(default_factory=deque)
    source: str | None = None
    last_valid: int | None = None

    def clear(self):
        self.samples.clear()
        self.source = self.last_valid = None

    def observe(self, pts, value, source, config, *, scale=None, gap=False):
        window, grace = seconds_ns(config.transition_window_seconds), seconds_ns(config.missing_grace_seconds)
        if gap or (self.last_valid is not None and pts - self.last_valid > grace):
            self.clear()
        while self.samples and pts - self.samples[0][0] > window:
            self.samples.popleft()
        if value is None or source is None:
            return ()
        if self.source != source:
            self.clear()
        self.source, self.last_valid = source, pts
        if len(self.samples) >= config.history_capacity:
            self.samples.popleft()
        prior = tuple(self.samples)
        self.samples.append((pts, value, scale))
        return prior


@dataclass
class _Track:
    state: str = 'UNKNOWN'
    last_present: int = 0
    episode: int = 0
    latched: bool = False
    direct_pending: bool = False  # Alert may exist before cooldown permits an Event.
    temporal_pending: bool = False
    transition_at: int | None = None
    arm: _Hold = field(default_factory=_Hold)
    down: _Hold = field(default_factory=_Hold)
    recovery: _Hold = field(default_factory=_Hold)
    angles: _Recent = field(default_factory=_Recent)
    hips: _Recent = field(default_factory=_Recent)
    spans: _Recent = field(default_factory=_Recent)
    references: dict = field(default_factory=dict)
    reference_samples: deque = field(default_factory=deque)
    scale: tuple[str, float] | None = None

    def break_recent(self):
        self.angles.clear()
        self.hips.clear()
        self.spans.clear()

    def reset_unconfirmed(self):
        self.direct_pending = False
        self.temporal_pending = False
        self.state = 'UNKNOWN'
        self.transition_at = None
        self.arm.reset()
        self.down.reset()
        self.break_recent()
        self.references.clear()
        self.reference_samples.clear()
        self.scale = None


def _compare(current, prior, threshold, *, ratio=False):
    if current is None or not prior:
        return None, None
    baseline = prior[0][1]
    if ratio and baseline <= EPS:
        return None, None
    value = current / baseline if ratio else current - baseline
    # Only floating-point boundary tolerance, not a tuning margin.
    positive = value <= threshold + 1e-12 if ratio else value + 1e-12 >= threshold
    return value, positive


class FallEngine:
    def __init__(self, config=FallConfig(), *, direct_down=False,
                 direct_down_cooldown_seconds=2.0):
        if not isinstance(config, FallConfig):
            raise ValueError('Expected FallConfig.')
        if type(direct_down) is not bool:
            raise ValueError('Invalid FALL direct-down toggle.')
        if (type(direct_down_cooldown_seconds) not in (int, float)
                or not isfinite(direct_down_cooldown_seconds)
                or not 0 <= direct_down_cooldown_seconds <= 30):
            raise ValueError('Invalid FALL direct-down cooldown.')
        # This is not a shared/global limiter. The owner supplies private policy.
        self._direct_down = direct_down
        self._direct_cooldown_ns = seconds_ns(direct_down_cooldown_seconds)
        self._last_direct_event_pts = None
        self.config = config
        self._tracks = {}
        self._identity = None
        self._last_pts = self._last_seq = None
        self._gap_pending = False
        self._metrics = Counter()

    def clear(self):
        """Only the owner may switch runs/configurations. Also resets diagnostics."""
        self._tracks.clear()
        self._last_direct_event_pts = None
        self._identity = self._last_pts = self._last_seq = None
        self._gap_pending = False
        self._metrics.clear()

    def status(self):
        return dict(self._metrics, states=len(self._tracks), last_pts_ns=self._last_pts,
                    last_frame_seq=self._last_seq)

    def inspect(self, track_id):
        """Small copied diagnostic view; no mutable state references escape."""
        t = self._tracks.get(track_id)
        if t is None:
            return None
        return dict(state=t.state, latched=t.latched, episode=t.episode,
                    last_present_pts=t.last_present, transition_pts=t.transition_at,
                    down_hold_ns=t.down.total, recovery_hold_ns=t.recovery.total,
                    arm_hold_ns=t.arm.total, scale=t.scale, references=dict(t.references),
                    history_sizes=tuple(len(h.samples) for h in (t.angles, t.hips, t.spans)),
                    reference_samples=len(t.reference_samples))

    def evaluate(self, frame, poses=(), track_ids=(), *, gap_before=False):
        if not isinstance(frame, FrameEnvelope) or type(gap_before) is not bool:
            raise ValueError('Invalid FALL Frame or gap marker.')
        poses, track_ids = tuple(poses), tuple(track_ids)
        if len(poses) != len(track_ids) or len(poses) > 4096:
            raise ValueError('FALL observation/track association is incomplete or oversized.')
        identity = (frame.server_id, frame.source_id, frame.stream_id, frame.run_id,
                    frame.run_generation, frame.frame_width, frame.frame_height, frame.config_version)
        if self._identity is not None and identity != self._identity:
            raise ValueError('FALL run/config/geometry changed; owner must clear or create a new engine.')
        seen = set()
        for pose, track_id in zip(poses, track_ids):
            if not isinstance(pose, PoseObservation) or pose.frame != frame or pose.model_key != POSE_MODEL:
                raise ValueError('FALL Pose/Frame/provider association mismatch.')
            if track_id is not None:
                if not ((type(track_id) is int and track_id > 0)
                        or (type(track_id) is str and 0 < len(track_id) <= 100)):
                    raise ValueError('Invalid existing track ID.')
                if track_id in seen:
                    raise ValueError('Duplicate track ID in a FALL Frame.')
                seen.add(track_id)
        pts = frame.pts_ns
        if type(pts) is not int or not 0 <= pts < 2**63:
            self._metrics['invalid_pts'] += 1
            self._gap_pending = True
            return FallEvaluation(frame, False, reason='TIME_UNAVAILABLE')
        if (type(frame.frame_seq) is not int or frame.frame_seq < 0
                or (self._last_pts is not None and (pts <= self._last_pts or frame.frame_seq <= self._last_seq))):
            self._metrics['stale_frame'] += 1
            return FallEvaluation(frame, False, reason='STALE_FRAME')
        if not self.config.enabled:
            return FallEvaluation(frame, True, reason='DISABLED')
        # Validate/extract the whole batch before mutating per-track state.
        features = tuple(extract_features(p, self.config) for p in poses)
        self._identity = identity
        gap = gap_before or self._gap_pending
        self._gap_pending = False
        self._last_pts, self._last_seq = pts, frame.frame_seq
        self._metrics['frames'] += 1
        for track_id, track in tuple(self._tracks.items()):
            ttl = (self.config.confirmed_retention_seconds if track.latched
                   else self.config.unconfirmed_retention_seconds)
            if pts - track.last_present >= seconds_ns(ttl):
                del self._tracks[track_id]
                self._metrics['state_expired'] += 1
            elif track_id not in seen:
                self._advance(track, PoseFeatures(), pts, gap=gap)
        decisions, candidates, direct_ready = [], [], []
        for index, (pose, track_id, geometry) in enumerate(zip(poses, track_ids, features)):
            if track_id is None:
                decisions.append(FallDecision(None, str(index), 'PENDING_ID', False, FallEvidence(geometry)))
                continue
            if track_id not in self._tracks:
                if len(self._tracks) >= self.config.max_states:
                    self._metrics['state_capacity_rejected'] += 1
                    decisions.append(FallDecision(track_id, str(index), 'CAPACITY', False, FallEvidence(geometry)))
                    continue
                self._tracks[track_id] = _Track(last_present=pts)
            track = self._tracks[track_id]
            track.last_present = pts  # Present person != usable posture; prevents false disappearance.
            evidence, created = self._advance(track, geometry, pts, gap=gap)
            # Current usable torso only: no bbox shortcut, arming or ID history.
            direct = (self._direct_down and geometry.torso_angle is not None
                      and geometry.torso_angle + 1e-12 >= self.config.down_angle_deg)
            if direct and not track.latched:
                track.state, track.latched, track.direct_pending = 'FALL_CONFIRMED', True, True
                track.episode += 1
                track.recovery.reset()
                track.recovery.last_known = pts
            if direct and track.direct_pending:
                direct_ready.append((index, pose, track_id, track))
            decisions.append(FallDecision(track_id, str(index), track.state, track.latched,
                                          evidence, track.down.total, track.recovery.total))
            from app.event_cooldown import allow_event
            if created:
                track.temporal_pending = True
            current_down = (geometry.torso_angle is not None
                            and geometry.torso_angle + 1e-12 >= self.config.down_angle_deg)
            if (track.temporal_pending and track.latched and (created or current_down)
                    and allow_event(self, 'FALL')):
                track.temporal_pending = False
                track.direct_pending = False
                candidates.append(FallEventCandidate(frame, track_id, str(index),
                                  pose.bbox_xyxy, pose.score, track.episode))
                self._metrics['events_created'] += 1
        # Normal temporal candidates take priority regardless of input order.
        # They seed only the direct limiter; normal FALL Events are never limited.
        if candidates and self._direct_down:
            self._last_direct_event_pts = pts
        elif direct_ready:
            if ((self._last_direct_event_pts is None
                    or pts - self._last_direct_event_pts >= self._direct_cooldown_ns)
                    and allow_event(self, 'FALL')):
                index, pose, track_id, track = direct_ready[0]
                track.direct_pending = False
                candidates.append(FallEventCandidate(frame, track_id, str(index),
                                  pose.bbox_xyxy, pose.score, track.episode))
                self._last_direct_event_pts = pts
                self._metrics['events_created'] += 1
                self._metrics['direct_events_created'] += 1
                direct_ready = direct_ready[1:]
            self._metrics['direct_cooldown_suppressed'] += len(direct_ready)
        return FallEvaluation(frame, True, tuple(decisions), tuple(candidates))

    def _features(self, track, g, pts, gap):
        c = self.config
        angle_prior = track.angles.observe(pts, g.torso_angle, g.torso_source, c, gap=gap)
        hip_prior = track.hips.observe(pts, g.hip_y, g.hip_source, c, scale=track.scale, gap=gap)
        span_prior = track.spans.observe(pts, g.span, g.span_source, c, gap=gap)
        delta, a = _compare(g.torso_angle, angle_prior, c.torso_delta_deg)
        recent_ratio, cue_c = _compare(g.span, span_prior, c.span_collapse_ratio, ratio=True)
        hip_ratio = b = scale_source = scale_value = None
        # The old endpoint carries a frozen scale snapshot. Recomputing the
        # denominator from current visibility would turn ankle loss into motion.
        baseline = next((sample for sample in hip_prior if sample[2] is not None), None)
        if g.hip_y is not None and baseline is not None:
            scale_source, scale_value = baseline[2]
            if scale_value > EPS:
                hip_ratio = (g.hip_y - baseline[1]) / scale_value
                b = hip_ratio + 1e-12 >= c.hip_drop_ratio
        reference = track.references.get(g.span_source)
        upright_ratio = g.span / reference if g.span is not None and reference is not None and reference > EPS else None
        down = down_source = None
        if g.torso_angle is not None:
            down = g.torso_angle + 1e-12 >= c.down_angle_deg
            down_source = 'torso:' + g.torso_source
        elif upright_ratio is not None:
            down = upright_ratio <= c.span_collapse_ratio + 1e-12 and g.knee_guard is not True
            down_source = 'span:' + g.span_source
        return FallEvidence(g, delta, hip_ratio, scale_source, scale_value,
                            recent_ratio, upright_ratio, (a, b, cue_c), down, down_source)

    def _references(self, track, geometry, pts, *, publish=True):
        c = self.config
        samples = track.reference_samples
        samples.append((pts, geometry.spans))
        while samples and pts - samples[0][0] > seconds_ns(c.reference_window_seconds):
            samples.popleft()
        while len(samples) > c.history_capacity:
            samples.popleft()
        if not publish:
            return
        for signature, _, _ in SPAN_SOURCES:
            values = [value for _, spans in samples for source, value in spans
                      if source == signature and value > EPS]
            if values:
                track.references[signature] = median(values)
        if track.scale is None:
            track.scale = next(((signature, track.references[signature])
                                for signature, _, _ in SPAN_SOURCES if signature in track.references), None)
        elif track.scale[0] in dict(geometry.spans):
            # Sticky source: never replace ankle denominator with knee fallback.
            track.scale = (track.scale[0], track.references[track.scale[0]])

    def _advance(self, track, g, pts, *, gap):
        c, grace = self.config, seconds_ns(self.config.missing_grace_seconds)
        evidence = self._features(track, g, pts, gap)
        self._metrics['feature_unavailable'] += 3 - evidence.available_count
        vertical = None if g.torso_angle is None else g.torso_angle <= c.upright_angle_deg + 1e-12
        arm_condition = None if vertical is None else vertical and g.knee_guard is not True
        track.arm.step(arm_condition, pts, grace, source=g.torso_source, gap=gap)

        if track.latched:
            expired = track.recovery.step(vertical, pts, grace, source=g.torso_source, gap=gap)
            if expired:
                self._metrics['long_unavailable_reset'] += 1
                track.state = 'FALL_CONFIRMED'
            if vertical is True:
                track.state = 'RECOVERING'
                if track.recovery.total >= seconds_ns(c.recovery_hold_seconds):
                    track.latched = False
                    track.recovery.reset()
                    track.reset_unconfirmed()
                    track.state = 'UPRIGHT'
                    track.arm.total = seconds_ns(c.upright_hold_seconds)
                    track.arm.last_known = track.arm.last_true = pts
                    track.arm.source = g.torso_source
                    if g.knee_guard is not True:
                        self._references(track, g, pts)
                    self._metrics['rearmed'] += 1
            elif vertical is False:
                track.state = 'FALL_CONFIRMED'
            return evidence, False

        if track.state == 'UNKNOWN':
            if arm_condition is True:
                # Bootstrap only from currently observed vertical, guard-compatible samples.
                self._references(track, g, pts, publish=track.arm.total >= seconds_ns(c.upright_hold_seconds))
                if track.arm.total >= seconds_ns(c.upright_hold_seconds):
                    track.state = 'UPRIGHT'
                    self._metrics['armed'] += 1
            else:
                track.reference_samples.clear()
                track.references.clear()
                track.scale = None
            return evidence, False

        if track.state == 'UPRIGHT':
            if evidence.transition:
                track.state, track.transition_at = 'TRANSITION', pts
                track.down.reset()
                track.down.last_known = pts
                self._metrics['transitions'] += 1
            elif arm_condition is True and track.arm.total >= seconds_ns(c.upright_hold_seconds):
                self._references(track, g, pts)
                return evidence, False
            else:
                return evidence, False

        if track.state == 'TRANSITION':
            if pts - track.transition_at > seconds_ns(c.transition_to_down_seconds):
                armed = arm_condition is True and track.arm.total >= seconds_ns(c.upright_hold_seconds)
                track.reset_unconfirmed()
                if armed:
                    track.state = 'UPRIGHT'
                    self._references(track, g, pts)
                self._metrics['transition_expired'] += 1
                return evidence, False
            expired = track.down.step(evidence.down, pts, grace, source=evidence.down_source, gap=gap)
            if expired:
                track.reset_unconfirmed()
                self._metrics['long_unavailable_reset'] += 1
            elif evidence.down is True:
                track.state = 'DOWN_CANDIDATE'
                if c.test_skip_down_hold:
                    track.state, track.latched = 'FALL_CONFIRMED', True
                    track.episode += 1
                    track.recovery.reset()
                    track.recovery.last_known = pts
                    return evidence, True
            return evidence, False

        if track.state == 'DOWN_CANDIDATE':
            expired = track.down.step(evidence.down, pts, grace, source=evidence.down_source, gap=gap)
            if expired:
                track.reset_unconfirmed()
                self._metrics['long_unavailable_reset'] += 1
            elif evidence.down is False:
                # Do not restart an already-expired transition after a posture flicker.
                if pts - track.transition_at <= seconds_ns(c.transition_to_down_seconds):
                    track.state = 'TRANSITION'
                else:
                    track.reset_unconfirmed()
            elif evidence.down is True and track.down.total >= seconds_ns(c.down_hold_seconds):
                track.state, track.latched = 'FALL_CONFIRMED', True
                track.episode += 1
                track.recovery.reset()
                track.recovery.last_known = pts
                return evidence, True
        return evidence, False
