import copy
import unittest
from datetime import datetime
from fleet_capacity_gate import evaluate_snapshot, evaluate_workload, validate_snapshot

NOW = datetime.fromisoformat("2026-11-03T09:31:00+00:00")
BASE = {
  "captured_at_utc":"2026-11-03T09:30:54Z",
  "nodes":[
    {"name":"primary","roles":["primary"],"online":True,"disk_free_gib":27,"encryption_at_rest":"disabled","clock_sync_state":"synchronized","collected_at_utc":"2026-11-03T09:29:21Z","source_clock_utc":"2026-11-03T09:29:21Z"},
    {"name":"archive","roles":["archive"],"online":True,"disk_free_gib":177,"encryption_at_rest":"disabled","clock_sync_state":"unsynchronized","collected_at_utc":"2026-11-03T09:30:50Z","source_clock_utc":"2026-11-03T09:37:01Z"},
    {"name":"lab","roles":["lab"],"online":True,"disk_free_gib":88,"encryption_at_rest":"disabled","clock_sync_state":"unsynchronized","collected_at_utc":"2026-11-03T09:30:52Z","source_clock_utc":"2026-11-03T09:30:57Z"},
    {"name":"mac","roles":["management"],"online":True,"disk_free_gib":28,"encryption_at_rest":"enabled","clock_sync_state":"unknown","collected_at_utc":"2026-11-03T09:29:34Z","source_clock_utc":"2026-11-03T09:29:34Z"},
    {"name":"relay","roles":["relay"],"online":True,"disk_free_gib":12,"encryption_at_rest":"disabled","clock_sync_state":"synchronized","collected_at_utc":"2026-11-03T09:30:53Z","source_clock_utc":"2026-11-03T09:30:53Z"}
  ],
  "workloads":[]
}

class FleetGateTests(unittest.TestCase):
    def test_public_research_can_use_lab_with_central_collector_time(self):
        w={"name":"research","allowed_roles":["lab"],"min_free_gib":40,"data_class":"public","requires_synchronized_clock":False}
        r=evaluate_workload(w,BASE["nodes"],NOW); self.assertEqual(r["selected_candidate"]["node"],"lab")
    def test_public_archive_can_use_capacity_node(self):
        w={"name":"archive","allowed_roles":["archive"],"min_free_gib":100,"data_class":"public","requires_synchronized_clock":False}
        r=evaluate_workload(w,BASE["nodes"],NOW); self.assertEqual(r["selected_candidate"]["node"],"archive")
    def test_confidential_archive_fails_on_unencrypted_and_unsynced_server(self):
        w={"name":"private","allowed_roles":["archive"],"min_free_gib":80,"data_class":"confidential"}
        r=evaluate_workload(w,BASE["nodes"],NOW); self.assertEqual(r["status"],"blocked")
        reasons=r["rejected_allowed_role_nodes"][0]["reasons"]
        self.assertIn("encryption_at_rest_not_verified",reasons); self.assertIn("node_clock_not_synchronized",reasons)
    def test_primary_capacity_and_encryption_block_production_profile(self):
        w={"name":"api","allowed_roles":["primary"],"min_free_gib":60,"data_class":"internal"}
        r=evaluate_workload(w,BASE["nodes"],NOW); self.assertEqual(r["status"],"blocked")
        reasons=r["rejected_allowed_role_nodes"][0]["reasons"]
        self.assertIn("insufficient_free_disk",reasons); self.assertIn("encryption_at_rest_not_verified",reasons)
    def test_mac_encryption_does_not_override_unknown_clock_for_sensitive_jobs(self):
        w={"name":"cache","allowed_roles":["management"],"min_free_gib":10,"data_class":"confidential"}
        r=evaluate_workload(w,BASE["nodes"],NOW); self.assertEqual(r["status"],"blocked")
        self.assertIn("node_clock_not_synchronized",r["rejected_allowed_role_nodes"][0]["reasons"])
    def test_public_non_time_sensitive_task_can_use_unsynced_lab(self):
        w={"name":"public","allowed_roles":["lab"],"min_free_gib":10,"data_class":"public","requires_synchronized_clock":False}
        self.assertEqual(evaluate_workload(w,BASE["nodes"],NOW)["status"],"eligible")
    def test_private_task_requires_clock_even_on_encrypted_node(self):
        node=copy.deepcopy(BASE["nodes"][3]); node["clock_sync_state"]="unsynchronized"
        w={"name":"private","allowed_roles":["management"],"min_free_gib":10,"data_class":"confidential"}
        self.assertIn("node_clock_not_synchronized",evaluate_workload(w,[node],NOW)["rejected_allowed_role_nodes"][0]["reasons"])
    def test_stale_collector_observation_is_rejected(self):
        node=copy.deepcopy(BASE["nodes"][2]); node["collected_at_utc"]="2026-11-03T08:00:00Z"
        w={"name":"research","allowed_roles":["lab"],"min_free_gib":10,"data_class":"public","requires_synchronized_clock":False}
        self.assertIn("collector_observation_stale",evaluate_workload(w,[node],NOW)["rejected_allowed_role_nodes"][0]["reasons"])
    def test_offline_node_is_rejected(self):
        node=copy.deepcopy(BASE["nodes"][2]); node["online"]=False
        w={"name":"research","allowed_roles":["lab"],"min_free_gib":10,"data_class":"public","requires_synchronized_clock":False}
        self.assertEqual(evaluate_workload(w,[node],NOW)["status"],"blocked")
    def test_insufficient_relay_capacity_is_rejected(self):
        w={"name":"edge","allowed_roles":["relay"],"min_free_gib":20,"data_class":"public","requires_synchronized_clock":False}
        self.assertEqual(evaluate_workload(w,BASE["nodes"],NOW)["status"],"blocked")
    def test_unknown_encryption_fails_closed(self):
        node=copy.deepcopy(BASE["nodes"][1]); node["encryption_at_rest"]="unknown"; node["clock_sync_state"]="synchronized"
        w={"name":"private","allowed_roles":["archive"],"min_free_gib":10,"data_class":"internal"}
        self.assertEqual(evaluate_workload(w,[node],NOW)["status"],"blocked")
    def test_invalid_duplicate_node_names_fail(self):
        s=copy.deepcopy(BASE); s["nodes"].append(copy.deepcopy(s["nodes"][0]))
        with self.assertRaises(ValueError): validate_snapshot(s)
    def test_decisions_never_execute(self):
        s=copy.deepcopy(BASE); s["workloads"]=[{"name":"research","allowed_roles":["lab"],"min_free_gib":10,"data_class":"public","requires_synchronized_clock":False}]
        r=evaluate_snapshot(s,NOW); self.assertFalse(r["policy"]["performs_execution"]); self.assertFalse(r["decisions"][0]["execution_performed"])

if __name__ == "__main__": unittest.main(verbosity=2)
