"""Disposable Pose interaction heuristic. Single owner; no I/O or Tracker.

TEMPORARY_DEMO_HEURISTIC / NOT ACTION-RECOGNITION / NOT REAL-VIDEO VALIDATED.
Only accepted FrameEnvelope inputs may reach this stateful core.
"""
from collections import Counter, deque
from dataclasses import dataclass, field
from itertools import combinations
from math import dist, isfinite

from app.contracts.frames import FrameEnvelope, PoseObservation
from app.pose_config import POSE_MODEL
from app.fight_config import FightConfig


def _ns(seconds):
    return round(seconds * 1_000_000_000)


@dataclass(frozen=True)
class _Body:
    track_id: int
    object_ref: str
    bbox: tuple
    center: tuple
    scale: float
    wrists: tuple
    torso_source: str


def _body(pose, track_id, object_ref, config):
    if pose.score < config.person_score:
        return None

    def point(index):
        x, y, score = pose.keypoints[index]
        if (score < config.joint_score or not all(map(isfinite, (x, y, score)))
                or not 0 <= x <= 1 or not 0 <= y <= 1):
            return None
        return x * pose.frame.frame_width, y * pose.frame.frame_height

    def middle(a, b):
        return (a[0] + b[0]) / 2, (a[1] + b[1]) / 2

    joints = [point(i) for i in (5, 6, 11, 12)]
    sources = []
    if all(p is not None for p in joints):
        sources.append(('mid', middle(*joints[:2]), middle(*joints[2:])))
    sources.extend((('left', joints[0], joints[2]), ('right', joints[1], joints[3])))
    for source, shoulder, hip in sources:
        if shoulder is None or hip is None:
            continue
        scale = dist(shoulder, hip)
        if scale > 1e-6:
            return _Body(track_id, object_ref, pose.bbox_xyxy, middle(shoulder, hip),
                         scale, (point(9), point(10)), source)
    return None


def _current_interaction(bodies):
    # Already-close pair. Wrist proximity uses the opponent's torso length.
    # No baseline, hold or ID continuity; IDs only identify the emitted pair.
    return any(xy is not None and dist(xy, bodies[1-actor].center) <= bodies[1-actor].scale
               for actor, body in enumerate(bodies) for xy in body.wrists)


@dataclass(frozen=True)
class FightEventCandidate:
    frame: FrameEnvelope
    track_ids: tuple[int, int]
    object_refs: tuple[str, str]
    bboxes: tuple
    scenario: str = "FIGHT"


@dataclass(frozen=True)
class FightPairDecision:
    track_ids: tuple[int, int]
    close: bool
    alert: bool
    burst_count: int


@dataclass(frozen=True)
class FightEvaluation:
    accepted: bool
    pairs: tuple[FightPairDecision, ...] = ()
    event_candidates: tuple[FightEventCandidate, ...] = ()
    reason: str | None = None


@dataclass
class _Pair:
    last_seen: int
    last_pts: int | None = None
    close_ns: int = 0
    quiet_ns: int = 0
    quiet_valid: bool = False
    latched: bool = False
    event_pending: bool = False  # Distinct from the display/episode latch.
    history: deque = field(default_factory=deque)
    bursts: deque = field(default_factory=lambda: deque(maxlen=16))
    high: dict = field(default_factory=dict)
    last_arm_burst: dict = field(default_factory=dict)

    def interrupt(self):
        self.last_pts = None
        self.close_ns = self.quiet_ns = 0
        self.quiet_valid = False
        self.history.clear()
        self.bursts.clear()
        self.high.clear()
        self.last_arm_burst.clear()


class FightEngine:
    """One engine per run/config/geometry, called serially by its future owner."""

    def __init__(self, config=FightConfig(), *, current_frame_fallback=False,
                 event_cooldown_seconds=0.0):
        if not isinstance(config, FightConfig):
            raise ValueError("Expected FightConfig.")
        if type(current_frame_fallback) is not bool:
            raise ValueError("Invalid FIGHT current-frame toggle.")
        if (type(event_cooldown_seconds) not in (int, float)
                or not isfinite(event_cooldown_seconds) or not 0 <= event_cooldown_seconds <= 30):
            raise ValueError("Invalid FIGHT cooldown.")
        # Per engine/run/source, not a global or cross-scenario cooldown.
        self._current_frame_fallback = current_frame_fallback
        self._event_cooldown_ns = _ns(event_cooldown_seconds)
        self._last_event_pts = None
        self.config = config
        self._pairs = {}
        self._identity = None
        self._last_pts = self._last_seq = None
        self.metrics = Counter()
        self._gap_pending = False

    def clear(self):
        self._pairs.clear()
        self._last_event_pts = None
        self._identity = self._last_pts = self._last_seq = None
        self.metrics.clear()
        self._gap_pending = False

    def snapshot(self):
        return dict(self.metrics, pair_states=len(self._pairs))

    def latched_pairs(self):
        return frozenset(k for k,p in self._pairs.items() if p.latched)

    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 FIGHT Frame/gap input.")
        poses, track_ids = tuple(poses), tuple(track_ids)
        if len(poses) != len(track_ids):
            raise ValueError("Pose/Tracking association differs.")
        seen = set()
        for pose, tid in zip(poses, track_ids):
            if not isinstance(pose, PoseObservation) or pose.frame != frame or pose.model_key != POSE_MODEL:
                raise ValueError("FIGHT requires same-Frame canonical Pose.")
            if tid is not None:
                if type(tid) is not int or tid < 1 or tid in seen:
                    raise ValueError("Invalid or duplicate FIGHT track ID.")
                seen.add(tid)
        if not self.config.enabled:
            return FightEvaluation(True, reason="DISABLED")

        pts = frame.pts_ns
        if (type(pts) is not int or not 0 <= pts < 2**63
                or 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["invalid_or_stale_frame"] += 1
            if type(pts) is not int or not 0 <= pts < 2**63:
                self._gap_pending = True
            return FightEvaluation(False, reason="INVALID_OR_STALE_FRAME")

        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("FIGHT run/source/config/geometry changed; clear or create a new engine.")
        self._identity = identity
        self._last_pts, self._last_seq = pts, frame.frame_seq
        cfg = self.config

        if gap_before or self._gap_pending:
            for pair in self._pairs.values():
                pair.interrupt()
        self._gap_pending = False

        for key, pair in tuple(self._pairs.items()):
            if pts - pair.last_seen >= _ns(cfg.pair_retention_seconds):
                del self._pairs[key]
                self.metrics["pair_expired"] += 1

        tracked = sorted((tid, i) for i, tid in enumerate(track_ids) if tid is not None)
        self.metrics["people_over_cap"] += max(0, len(tracked) - cfg.max_people)
        bodies = []
        for tid, i in tracked[:cfg.max_people]:
            body = _body(poses[i], tid, str(i), cfg)
            if body is not None:
                bodies.append(body)

        current, decisions, candidates = set(), [], []
        for a, b in combinations(bodies, 2):
            key = (a.track_id, b.track_id)
            close = dist(a.center, b.center) / ((a.scale + b.scale) / 2) <= cfg.pair_distance_ratio
            if not close:
                pair = self._pairs.get(key)
                if pair is not None:
                    pair.interrupt()
                    pair.last_seen = pts
                continue
            if key not in self._pairs:
                if len(self._pairs) >= cfg.max_pairs:
                    self.metrics["pair_capacity_rejected"] += 1
                    continue
                self._pairs[key] = _Pair(pts, history=deque(maxlen=cfg.history_capacity))
            pair = self._pairs[key]
            current.add(key)
            event = self._step(pair, (a, b), pts)
            decisions.append(FightPairDecision(key, True, pair.latched, len(pair.bursts)))
            if event:
                candidates.append(FightEventCandidate(frame, key, (a.object_ref, b.object_ref),
                                                      (a.bbox, b.bbox)))

        for key, pair in self._pairs.items():
            if key not in current:
                pair.interrupt()
        return FightEvaluation(True, tuple(decisions), tuple(candidates))

    def _step(self, pair, bodies, pts):
        cfg = self.config
        dt = 0 if pair.last_pts is None else pts - pair.last_pts
        if dt > _ns(cfg.arm_motion_window_seconds):
            pair.interrupt()
            dt = 0

        pair.last_seen = pair.last_pts = pts
        pair.close_ns += dt
        while pair.history and pts - pair.history[0][0] > _ns(cfg.arm_motion_window_seconds):
            pair.history.popleft()
        while pair.bursts and pts - pair.bursts[0] > _ns(cfg.fight_window_seconds):
            pair.bursts.popleft()

        active = pair.close_ns >= _ns(cfg.pair_close_hold_seconds)
        new_bursts, any_qualifying = 0, False
        observable = [False, False]

        for actor, body in enumerate(bodies):
            opponent = 1 - actor
            for wrist, xy in enumerate(body.wrists):
                arm = (actor, wrist)
                if xy is None:
                    continue

                baseline = None
                for _, previous in reversed(pair.history):
                    if (previous[actor].wrists[wrist] is None
                            or previous[actor].torso_source != body.torso_source
                            or previous[opponent].torso_source != bodies[opponent].torso_source):
                        break
                    baseline = previous
                if baseline is None:
                    continue

                observable[actor] = True
                old = baseline[actor]
                old_xy = old.wrists[wrist]
                motion = dist(old_xy, xy) / old.scale
                approach = (dist(old_xy, baseline[opponent].center)
                            - dist(xy, bodies[opponent].center)) / old.scale
                qualifying = (active and motion >= cfg.arm_motion_ratio
                              and approach >= cfg.toward_opponent_ratio)
                any_qualifying |= qualifying
                last = pair.last_arm_burst.get(arm)
                if (qualifying and not pair.high.get(arm, False)
                        and (last is None or pts - last >= _ns(cfg.burst_min_interval_seconds))):
                    pair.bursts.append(pts)
                    pair.last_arm_burst[arm] = pts
                    new_bursts += 1
                pair.high[arm] = qualifying

        current_hit = self._current_frame_fallback and _current_interaction(bodies)
        quiet = all(observable) and not any_qualifying and not current_hit
        pair.quiet_ns = pair.quiet_ns + dt if quiet and pair.quiet_valid else 0
        pair.quiet_valid = quiet

        if pair.latched and pair.quiet_ns >= _ns(cfg.rearm_quiet_seconds):
            pair.latched = pair.event_pending = False
            pair.bursts.clear()
            self.metrics["rearmed"] += 1

        pair.history.append((pts, bodies))
        self.metrics["arm_bursts"] += new_bursts

        temporal_hit = active and new_bursts and len(pair.bursts) >= cfg.required_bursts
        if not pair.latched and (temporal_hit or current_hit):
            pair.latched = pair.event_pending = True
        # Cooldown never removes the current alert or fabricates an Event ID.
        # A suppressed episode may retry, but only with fresh usable evidence.
        if pair.event_pending and (any_qualifying or current_hit):
            from app.event_cooldown import allow_event
            if ((self._last_event_pts is None or pts - self._last_event_pts >= self._event_cooldown_ns)
                    and allow_event(self, 'FIGHT')):
                pair.event_pending = False
                self._last_event_pts = pts
                self.metrics["event_candidates"] += 1
                return True
            self.metrics["event_cooldown_suppressed"] += 1
        return False
