"""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


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)]
    if any(p is None for p in joints):
        return None
    shoulder, hip = middle(*joints[:2]), middle(*joints[2:])
    scale = dist(shoulder, hip)
    if scale <= 1e-6:
        return None
    return _Body(track_id, object_ref, pose.bbox_xyxy, middle(shoulder, hip),
                 scale, (point(9), point(10)))


@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
    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()):
        if not isinstance(config, FightConfig):
            raise ValueError("Expected FightConfig.")
        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._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 pts < 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 pts < 0:
                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:
                        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

        quiet = all(observable) and not any_qualifying
        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 = False
            pair.bursts.clear()
            self.metrics["rearmed"] += 1

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

        if active and new_bursts and not pair.latched and len(pair.bursts) >= cfg.required_bursts:
            pair.latched = True
            self.metrics["event_candidates"] += 1
            return True
        return False
