import itertools
import unittest
from dataclasses import asdict
from controller import Controller, Inputs


def ready(**kwargs):
    c=Controller(**kwargs); c.step(0,Inputs(reset=True)); return c

def wet(**kwargs):
    d={'wet_low':True,'start':True};d.update(kwargs);return Inputs(**d)

class ControllerTests(unittest.TestCase):
    def test_boot_requires_quiet_reset(self):
        c=Controller();self.assertFalse(c.step(0,wet())['command_a'])
        self.assertTrue(c.step(1,wet(reset=True))['restart_inhibited'])
        self.assertFalse(c.step(2,Inputs(reset=True))['restart_inhibited'])
        self.assertTrue(c.step(3,wet())['command_a'])

    def test_independent_expected_normal_trace(self):
        c=ready()
        table=[(1,wet(),(True,False,'A')),(2,wet(feedback_a=True),(True,False,'A')),
               (3,wet(assist=True,feedback_a=True),(True,True,'A')),
               (4,wet(assist=True,feedback_a=True,feedback_b=True),(True,True,'A')),
               (15,wet(feedback_a=True,feedback_b=True),(True,False,'A')),
               (16,Inputs(),(False,False,'B')),(17,wet(),(False,True,'B'))]
        for t,i,expected in table:
            o=c.step(t,i);self.assertEqual((o['command_a'],o['command_b'],o['next_lead']),expected)

    def test_failed_lead_is_replaced_at_timeout(self):
        c=ready();c.step(1,wet());o=c.step(4,wet(assist=True))
        self.assertEqual(o['fault_a'],'START_TIMEOUT');self.assertFalse(o['command_a']);self.assertTrue(o['command_b'])

    def test_simultaneous_events_are_order_independent(self):
        first=ready();first.step(1,wet());second=first.clone()
        a=first.apply_events(4,[{'assist':True},{'feedback_a':False}])
        b=second.apply_events(4,[{'feedback_a':False},{'assist':True}])
        self.assertEqual(a,b);self.assertTrue(a['command_b']);self.assertFalse(a['command_a'])

    def test_conflicting_atomic_updates_rejected(self):
        with self.assertRaises(ValueError): ready().apply_events(1,[{'start':True},{'start':False}])

    def test_feedback_loss_debounce_then_failover(self):
        c=ready();c.step(1,wet());c.step(2,wet(feedback_a=True))
        self.assertTrue(c.step(3,wet())['command_a'])
        self.assertTrue(c.step(3.5,wet())['command_a'])
        o=c.step(4,wet());self.assertEqual(o['fault_a'],'RUN_FEEDBACK_LOST');self.assertTrue(o['command_b'])

    def test_recovered_feedback_cancels_loss_timer(self):
        c=ready();c.step(1,wet());c.step(2,wet(feedback_a=True));c.step(3,wet());c.step(3.5,wet(feedback_a=True))
        self.assertFalse(c.step(4.2,wet())['fault_a'])

    def test_dry_level_overrides_minimum_run_and_manual(self):
        c=ready();c.step(1,wet());c.step(2,wet(feedback_a=True))
        o=c.step(2.1,Inputs(manual_a=True,manual_b=True))
        self.assertFalse(o['command_a'] or o['command_b'])

    def test_assist_minimum_run_then_stop(self):
        c=ready();c.step(1,wet(assist=True));c.step(2,wet(assist=True,feedback_a=True,feedback_b=True))
        self.assertTrue(c.step(5,wet(feedback_a=True,feedback_b=True))['command_b'])
        self.assertFalse(c.step(11,wet(feedback_a=True,feedback_b=True))['command_b'])

    def test_manual_wet_permissive(self):
        c=ready();o=c.step(1,Inputs(wet_low=True,manual_b=True));self.assertEqual((o['command_a'],o['command_b']),(False,True))
        self.assertFalse(c.step(2,Inputs())['command_b'])

    def test_unavailable_never_commanded(self):
        c=ready();o=c.step(1,wet(available_a=False,manual_a=True));self.assertFalse(o['command_a']);self.assertTrue(o['command_b'])

    def test_stuck_feedback_latches_fault(self):
        c=ready();o=c.step(1,wet(feedback_a=True));self.assertEqual(o['fault_a'],'UNCOMMANDED_FEEDBACK');self.assertFalse(o['command_a'])

    def test_reset_requires_safe_inputs_and_available_pump(self):
        c=ready();c.step(1,wet());c.step(4,wet())
        self.assertTrue(c.step(5,wet(reset=True))['fault_a'])
        self.assertTrue(c.step(6,Inputs(reset=True,available_a=False))['fault_a'])
        self.assertFalse(c.step(7,Inputs(reset=True))['fault_a'])

    def test_invalid_sensor_order_latches_until_reset(self):
        c=ready();o=c.step(1,Inputs(wet_low=True,assist=True));self.assertTrue(o['sensor_fault'])
        self.assertFalse(c.step(2,wet())['command_a']);self.assertFalse(c.step(3,Inputs(reset=True))['sensor_fault'])

    def test_power_loss_and_restore_from_representative_states(self):
        states=[]
        c=ready();states.append(c.clone());c.step(1,wet());states.append(c.clone());c.step(2,wet(feedback_a=True));states.append(c.clone())
        c.step(3,wet(assist=True,feedback_a=True));states.append(c.clone());c.step(4,wet(assist=True,feedback_a=True,feedback_b=True));states.append(c.clone())
        c.step(5,Inputs(wet_low=True,assist=True));states.append(c.clone())
        f=ready();f.step(1,wet());f.step(4,wet());states.append(f)
        for c in states:
            t=c.last_time+1;o=c.step(t,wet(power=False));self.assertFalse(o['command_a'] or o['command_b'])
            o=c.step(t+1,wet());self.assertTrue(o['restart_inhibited']);self.assertFalse(o['command_a'] or o['command_b'])
            o=c.step(t+2,Inputs(reset=True));self.assertFalse(o['restart_inhibited'])

    def test_timer_boundary_and_monotonic_time(self):
        for timer in ['start_timeout','feedback_timeout','minimum_run']:
            for bad in [0,-1,float('nan'),float('inf')]:
                with self.assertRaises(ValueError): Controller(**{timer:bad})
        c=ready();c.step(1,wet());self.assertFalse(c.step(3.999,wet())['fault_a']);self.assertTrue(c.step(4,wet())['fault_a'])
        with self.assertRaises(ValueError): c.step(3,Inputs())
        with self.assertRaises(ValueError): c.step(float('nan'),Inputs())
        with self.assertRaises(TypeError): c.step(5,Inputs(wet_low=1))

    def test_all_boolean_snapshots_interlocks(self):
        # 2^11 inputs at seven representative states: exhaustive one-scan input sweep,
        # not a claim of exhaustive coverage of continuous-time histories.
        seeds=[];c=ready();seeds.append(c.clone());c.step(1,wet());seeds.append(c.clone());c.step(2,wet(feedback_a=True));seeds.append(c.clone())
        c.step(3,wet(assist=True,feedback_a=True));seeds.append(c.clone());c.step(4,wet(assist=True,feedback_a=True,feedback_b=True));seeds.append(c.clone())
        f=ready();f.step(1,wet());f.step(4,wet());seeds.append(f);seeds.append(Controller())
        keys=list(asdict(Inputs()))
        for base in seeds:
            t=(base.last_time or 0)+20
            for bits in itertools.product([False,True],repeat=len(keys)):
                c=base.clone();i=Inputs(**dict(zip(keys,bits)));o=c.step(t,i)
                if not i.power or not i.wet_low or o['sensor_fault'] or o['restart_inhibited']:
                    self.assertFalse(o['command_a'] or o['command_b'])
                for n in ['a','b']:
                    if not getattr(i,'available_'+n) or o['fault_'+n]: self.assertFalse(o['command_'+n])

    def test_declared_transition_set_reached(self):
        transitions=set()
        c=ready();c.step(1,wet());c.step(2,wet(feedback_a=True));c.step(3,Inputs());transitions |= c.transitions
        c=ready();c.step(1,wet());c.step(4,wet());c.step(5,Inputs(reset=True));transitions |= c.transitions
        c=ready();c.step(1,wet());c.step(2,Inputs());transitions |= c.transitions
        c=ready();c.step(1,wet());c.step(2,wet(feedback_a=True));c.step(3,wet());c.step(4,wet());transitions |= c.transitions
        declared={('OFF','STARTING'),('STARTING','RUNNING'),('STARTING','FAULT'),('RUNNING','FAULT'),('RUNNING','OFF'),('STARTING','OFF'),('FAULT','OFF')}
        self.assertTrue(declared <= transitions, declared-transitions)

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