"""INTRUSION-01: Frame-time decision, independent of storage and rendering."""
from dataclasses import dataclass, asdict
import math
import time
import uuid


def _cross(a, b, c):
    return (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0])


def _on(a, b, p):
    return (abs(_cross(a, b, p)) <= 1e-12
            and min(a[0], b[0])-1e-12 <= p[0] <= max(a[0], b[0])+1e-12
            and min(a[1], b[1])-1e-12 <= p[1] <= max(a[1], b[1])+1e-12)


def _intersects(a, b, c, d):
    return ((_cross(a, b, c)*_cross(a, b, d) < 0
             and _cross(c, d, a)*_cross(c, d, b) < 0)
            or _on(a, b, c) or _on(a, b, d) or _on(c, d, a) or _on(c, d, b))


def inside(p, polygon):
    """Polygon boundary is inside; coordinates are normalized SOURCE_FRAME."""
    return _inside_edges(p, zip(polygon, polygon[1:] + polygon[:1]))


def _inside_edges(p, edges):
    hit = False
    for a, b in edges:
        if _on(a, b, p):
            return True
        if (a[1] > p[1]) != (b[1] > p[1]):
            if p[0] < (b[0]-a[0])*(p[1]-a[1])/(b[1]-a[1])+a[0]:
                hit = not hit
    return hit


@dataclass(frozen=True)
class IntrusionConfig:
    enabled: bool = False
    roi_id: str = 'primary'
    roi_name: str = 'Intrusion ROI'
    debug_roi: bool = True
    roi: tuple = ((0., 0.), (1., 0.), (1., 1.), (0., 1.))
    point_x: float = .5
    point_y: float = 1.
    confidence: float = .5
    dwell_seconds: float = .5
    rearm_seconds: float = 1.
    grace_seconds: float = .5
    retention_seconds: float = 30.
    max_states: int = 1024

    def __post_init__(self):
        if (type(self.roi_name) is not str or not self.roi_name.strip() or len(self.roi_name) > 64
                or any(ord(c) < 32 for c in self.roi_name) or type(self.debug_roi) is not bool):
            raise ValueError('Invalid ROI display settings.')
        if type(self.enabled) is not bool or type(self.roi_id) is not str or not 1 <= len(self.roi_id) <= 64:
            raise ValueError('Invalid INTRUSION configuration.')
        for key in ('point_x', 'point_y', 'confidence', 'dwell_seconds', 'rearm_seconds',
                    'grace_seconds', 'retention_seconds'):
            v = getattr(self, key)
            if type(v) not in (int, float) or not math.isfinite(v):
                raise ValueError('Invalid scenario number.')
            if key in ('point_x', 'point_y', 'confidence'):
                if not 0 <= v <= 1:
                    raise ValueError('Invalid scenario ratio.')
            elif not 0 < v <= 3600:
                raise ValueError('Invalid scenario duration.')
        if self.retention_seconds < max(self.grace_seconds, self.rearm_seconds, self.dwell_seconds):
            raise ValueError('Retention must cover scenario timers.')
        if type(self.max_states) is not int or not 1 <= self.max_states <= 4096:
            raise ValueError('Invalid scenario state bound.')
        try:
            poly = tuple(tuple(p) for p in self.roi)
        except TypeError:
            raise ValueError('Invalid polygon.') from None
        if not 3 <= len(poly) <= 32 or any(len(p) != 2 for p in poly):
            raise ValueError('Invalid polygon.')
        if any(type(x) not in (int, float) or not math.isfinite(x) or not 0 <= x <= 1
               for p in poly for x in p) or len(set(poly)) != len(poly):
            raise ValueError('Invalid polygon coordinates.')
        edges = tuple(zip(poly, poly[1:] + poly[:1]))
        if abs(sum(a[0]*b[1]-b[0]*a[1] for a, b in edges)) <= 1e-12:
            raise ValueError('Degenerate polygon.')
        for i, (a, b) in enumerate(edges):
            for j in range(i+1, len(edges)):
                if j == i+1 or (i == 0 and j == len(edges)-1):
                    continue
                if _intersects(a, b, *edges[j]):
                    raise ValueError('Self-intersecting polygon.')
        object.__setattr__(self, 'roi', poly)

    @classmethod
    def parse(cls, value):
        if not isinstance(value, dict):
            raise ValueError('INTRUSION settings must be an object.')
        try:
            return cls(**value)
        except TypeError:
            raise ValueError('Unknown INTRUSION settings.') from None

    def payload(self):
        value = asdict(self)
        value['roi'] = [list(p) for p in self.roi]
        return value

    @property
    def scenario_name(self):
        return 'INTRUSION'


@dataclass(frozen=True)
class ScenarioDecision:
    point: tuple[float, float]
    inside: bool
    state: str
    confidence: float
    event_id: str | None = None

    def payload(self):
        return dict(scenario='INTRUSION', point=list(self.point), inside=self.inside,
                    state=self.state, confidence=self.confidence, eventId=self.event_id)


@dataclass(frozen=True)
class IntrusionEvent:
    event_id: str
    frame: object
    track_id: str | int
    roi_id: str
    object_ref: str
    confidence: float
    bbox: tuple
    model_key: str = 'RFDETRMedium'
    ai_recorded_at_wall_ms: int = 0

    def payload(self):
        from app.contracts.visualization import frame_snapshot
        data = frame_snapshot(self.frame, self.frame.source_id)
        data.pop('objects')
        return dict(data, eventId=self.event_id, scenario='INTRUSION',
                    categoryKey='person', modelKey=self.model_key, classId=0,
                    trackId=self.track_id, roiId=self.roi_id, objectRef=self.object_ref,
                    confidence=self.confidence, bbox=list(self.bbox),
                    aiRecordedAtWallMs=self.ai_recorded_at_wall_ms,
                    clipUri=None, thumbnailUri=None, deliveryStatus='NOT_SUBMITTED')


@dataclass
class _Visit:
    last_valid: float
    last_seen: float
    previous_pts: float
    previous_index: int
    inside: bool
    dwell: float = 0.
    outside: float = 0.
    event_id: str | None = None
    warning: bool = False


class Intrusion:
    """Single result-worker owner. State contains small values, never image buffers."""
    def __init__(self, config, *, clock=time.monotonic):
        self.config, self.clock = config, clock
        self._roi_edges = tuple(zip(config.roi, config.roi[1:] + config.roi[:1]))
        self.states = {}
        self.identity = None
        self.last_pts = None
        self.last_seq = -1
        self.index = 0

    def clear(self):
        self.states.clear()
        self.identity = None
        self.last_pts = None
        self.last_seq = -1
        self.index = 0

    def expire(self):
        now = self.clock()
        expired = [key for key, value in self.states.items()
                   if now-value.last_seen >= self.config.retention_seconds]
        for key in expired:
            del self.states[key]

    def evaluate(self, frame, observations, ids, *, gap_before=False):
        cfg = self.config
        self.expire()
        identity = (frame.server_id, frame.source_id, frame.stream_id, frame.run_id, frame.run_generation)
        if self.identity is None:
            self.identity = identity
        if identity != self.identity or frame.frame_seq <= self.last_seq:
            raise ValueError('Stale scenario Frame.')
        if len(observations) != len(ids) or any(o.frame != frame for o in observations):
            raise ValueError('Scenario Frame association differs.')
        self.last_seq = frame.frame_seq
        self.index += 1
        # Missing or regressing media time never becomes processing-time dwell.
        pts = frame.pts_ns / 1e9 if type(frame.pts_ns) is int and 0 <= frame.pts_ns < 2**63 else None
        valid_time = pts is not None and (self.last_pts is None or pts >= self.last_pts)
        if valid_time:
            self.last_pts = pts
        seen = set()
        decisions, events = [], []
        now = self.clock()
        for index, (obs, tid) in enumerate(zip(observations, ids)):
            from app.pose_config import PERSON_MODELS
            if obs.model_key not in PERSON_MODELS or obs.class_id != 0:
                decisions.append(None)
                continue
            x1, y1, x2, y2 = obs.bbox_xyxy
            point = (x1+(x2-x1)*cfg.point_x, y1+(y2-y1)*cfg.point_y)
            within = _inside_edges(point, self._roi_edges)
            state, event_id = 'PENDING_ID', None
            qualifies = obs.score > cfg.confidence
            if tid is not None:
                if type(tid) not in (str, int) or tid in seen:
                    raise ValueError('Invalid or duplicate scenario track.')
                seen.add(tid)
            visit = self.states.get(tid) if tid is not None else None
            if not qualifies or not valid_time:
                state = 'LOW_CONFIDENCE' if not qualifies else 'TIME_UNAVAILABLE'
                if visit and valid_time and pts-visit.last_valid <= cfg.grace_seconds and visit.warning:
                    state, event_id = 'INTRUSION', visit.event_id
            elif tid is not None:
                if visit is None:
                    if len(self.states) >= cfg.max_states:
                        raise RuntimeError('INTRUSION state capacity exceeded.')
                    visit = _Visit(pts, now, pts, self.index, within)
                    self.states[tid] = visit
                gap = pts-visit.last_valid
                continuous = not gap_before and visit.previous_index == self.index-1 and gap <= cfg.grace_seconds
                delta = max(0., pts-visit.previous_pts) if continuous else 0.
                if gap > cfg.grace_seconds:
                    visit.dwell = 0.
                    visit.outside = 0.
                    visit.warning = False
                if within:
                    visit.outside = 0.
                    if not visit.inside:
                        visit.dwell = 0.
                        visit.warning = False
                    if visit.inside:
                        visit.dwell += delta
                    if visit.dwell+1e-9 >= cfg.dwell_seconds:
                        visit.warning = True
                        from app.event_cooldown import allow_event
                        if visit.event_id is None and allow_event(self, 'INTRUSION'):
                            visit.event_id = str(uuid.uuid4())
                            # EVART-01: local reference time, once after confirmation and before enqueue; no I/O here.
                            events.append(IntrusionEvent(visit.event_id, frame, tid, cfg.roi_id,
                                                         str(index), obs.score, obs.bbox_xyxy, obs.model_key,
                                                         time.time_ns() // 1_000_000))
                    state = 'INTRUSION' if visit.warning else 'CONFIRMING'
                else:
                    visit.dwell = 0.
                    visit.warning = False
                    visit.outside = visit.outside+delta if not visit.inside else 0.
                    if visit.outside+1e-9 >= cfg.rearm_seconds:
                        visit.event_id = None
                    state = 'OUTSIDE'
                visit.last_valid, visit.last_seen = pts, now
                visit.previous_pts, visit.previous_index, visit.inside = pts, self.index, within
                event_id = visit.event_id if visit.warning else None
            decisions.append(ScenarioDecision(point, within, state, cfg.confidence, event_id))
        return tuple(decisions), tuple(events)
