import unittest
from dataclasses import replace

from app.contracts.frames import FrameEnvelope, DetectionObservation
from app.intrusion import Intrusion, IntrusionConfig, inside
from app.stream_settings import patch_settings, settings_view, revision


def frame(seq, seconds, generation=1):
    return FrameEnvelope('server', 'source', 'stream', 'run', generation, seq,
                         None if seconds is None else round(seconds*1e9), 1000, 640, 360)


class IntrusionTest(unittest.TestCase):
    def setUp(self):
        self.now = 0.
        self.engine = Intrusion(IntrusionConfig(enabled=True,
            roi=((0, 0), (.5, 0), (.5, 1), (0, 1))), clock=lambda: self.now)
        self.seq = 0

    def step(self, seconds, score=.9, tid=1, outside=False, empty=False, generation=1):
        f = frame(self.seq, seconds, generation)
        self.seq += 1
        box = (.6, .2, .8, .8) if outside else (.1, .2, .3, .8)
        obs = () if empty else (DetectionObservation(f, 'RFDETRMedium', 0, score, box),)
        return self.engine.evaluate(f, obs, () if empty else (tid,))

    def trigger(self, start=0):
        events = []
        for i in range(6):
            d, e = self.step(start+i*.1)
            events.extend(e)
        self.assertEqual(len(events), 1)
        self.assertEqual(d[0].state, 'INTRUSION')
        return events[0]

    def test_starts_inside_confirmation_frame_event_and_no_duplicate(self):
        event = self.trigger()
        self.assertEqual(event.frame.frame_seq, 5)
        self.assertEqual(event.frame.pts_ns, 500000000)
        self.assertEqual(event.payload()['frameTime']['detectedAtWallMs'], None)
        self.assertEqual(event.payload()['deliveryStatus'], 'NOT_SUBMITTED')
        self.assertIsNone(event.payload()['clipUri'])
        self.assertEqual(self.step(.6)[1], ())

    def test_missing_short_freezes_dwell_and_long_restarts(self):
        self.step(0)
        self.step(.2)
        self.step(.3, empty=True)
        self.step(.4)
        self.assertAlmostEqual(self.engine.states[1].dwell, .2)
        self.assertEqual(self.step(.7)[0][0].state, 'INTRUSION')
        self.engine.clear()
        self.step(1)
        self.step(1.2)
        self.step(2)
        self.assertEqual(self.engine.states[1].dwell, 0)

    def test_low_confidence_not_exit_and_short_warning_grace(self):
        event = self.trigger()
        d, _ = self.step(.7, score=.4, outside=True)
        self.assertEqual(d[0].state, 'INTRUSION')
        self.assertEqual(d[0].event_id, event.event_id)
        self.assertEqual(self.step(1.1, score=.4)[0][0].state, 'LOW_CONFIDENCE')
        d, e = self.step(1.2)
        self.assertEqual(d[0].state, 'CONFIRMING')
        self.assertFalse(e)
        for i in range(1, 6):
            d, e = self.step(1.2+i*.1)
            self.assertFalse(e)
        self.assertEqual(d[0].state, 'INTRUSION')

    def test_outside_rearms_only_after_observed_second(self):
        first = self.trigger()
        self.step(.6, outside=True)
        self.step(.8, outside=True)
        for i in range(6):
            _, events = self.step(.9+i*.1)
            self.assertFalse(events)
        for i in range(11):
            self.step(1.5+i*.1, outside=True)
        second = self.trigger(2.6)
        self.assertNotEqual(first.event_id, second.event_id)

    def test_no_id_score_equality_and_new_id(self):
        self.assertEqual(self.step(0, tid=None)[0][0].state, 'PENDING_ID')
        self.assertEqual(self.step(.1, score=.5)[0][0].state, 'LOW_CONFIDENCE')
        self.assertEqual(len(self.engine.states), 0)
        self.trigger(.2)
        self.assertEqual(self.step(.8, tid=2)[0][0].state, 'CONFIRMING')

    def test_expiry_without_frames_and_capacity_fail_closed(self):
        self.engine = Intrusion(IntrusionConfig(enabled=True, max_states=1), clock=lambda: self.now)
        self.step(0)
        with self.assertRaises(RuntimeError):
            self.step(.1, tid=2)
        self.now = 30
        self.engine.expire()
        self.assertFalse(self.engine.states)
        self.step(.2, tid=2)
        self.engine.clear()
        self.assertFalse(self.engine.states)

    def test_time_null_regression_same_pts_and_generation(self):
        self.assertEqual(self.step(None)[0][0].state, 'TIME_UNAVAILABLE')
        self.step(.3)
        self.step(.3)
        self.assertEqual(self.engine.states[1].dwell, 0)
        self.assertEqual(self.step(.2)[0][0].state, 'TIME_UNAVAILABLE')
        self.step(.4)
        self.assertEqual(self.engine.states[1].dwell, 0)
        with self.assertRaises(ValueError):
            self.step(.5, generation=2)
        self.engine.clear()
        self.step(.5, generation=2)

    def test_boundary_point_and_invalid_polygons(self):
        self.assertTrue(inside((.5, 1), self.engine.config.roi))
        self.assertFalse(inside((.6, .5), self.engine.config.roi))
        for roi in (None, [], [[0, 0], [1, 1], [0, 1], [1, 0]],
                    [[0, 0], [1, 0], [2, 0]], [[0, 0]]*3):
            with self.subTest(roi=roi), self.assertRaises(ValueError):
                IntrusionConfig(roi=roi)

    def test_settings_patch_noop_and_invalid_preserves_spec(self):
        spec = dict(server_id='s', source_id='c', stream_id='x')
        before = revision(spec)
        self.assertEqual(patch_settings(spec, {'intrusion': {'confidence': .5}}), spec)
        updated = patch_settings(spec, {'intrusion': {'enabled': True, 'point_y': .95}})
        self.assertEqual(settings_view(updated)['intrusion']['point_y'], .95)
        self.assertEqual(revision(spec), before)
        for value in ({'roi': []}, {'confidence': True}, {'unrecognized': 1}):
            with self.assertRaises(ValueError):
                patch_settings(spec, {'intrusion': value})
            self.assertEqual(revision(spec), before)


if __name__ == '__main__':
    unittest.main()
