import copy
import json
import unittest
from pathlib import Path
import sys

HERE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(HERE))
from pack_rules import validate_pack

RULES = json.loads((HERE / "CHECK_RULES.json").read_text(encoding="utf-8"))


def baseline_pack(one_trigger=True):
    fixture=json.loads((HERE/'fixtures/baseline_fixture.json').read_text(encoding='utf-8'))
    fid=fixture.pop('fixture_id')
    fixture.pop('rights_status',None)
    fixture['fixture_ref']=fid
    fixture['evidence_ref']='fixture://'+fid
    fixture['decision']={
        'final_state':'READY',
        'rationale':'TEST-SENTINEL: bounded decision from the visible fixture.',
        'triggers': {'review':'target changes'} if one_trigger else {
            'review':'target changes','migration':'consumer changes','escalation':'rights disputed','stop':'decisive evidence unavailable','pivot':'method cannot satisfy target'
        }
    }
    return fixture


class PackRuleTests(unittest.TestCase):
    def codes(self, pack):
        return {e["code"] for e in validate_pack(pack, RULES)}

    def assertNoErrors(self, pack):
        self.assertEqual([], validate_pack(pack, RULES))

    def test_actual_baseline_fixture_can_be_bound_without_answer_key(self):
        self.assertNoErrors(baseline_pack())

    def test_one_applicable_trigger_is_sufficient(self):
        self.assertNotIn('E_DECISION_TRIGGERS', self.codes(baseline_pack()))

    def test_missing_all_triggers_is_reported(self):
        p=baseline_pack(); p['decision']['triggers']={}
        self.assertIn('E_DECISION_TRIGGERS', self.codes(p))

    def test_unknown_fixture_ref_is_rejected(self):
        p=baseline_pack(False); p['fixture_ref']='DOES-NOT-EXIST'; p['evidence_ref']='fixture://DOES-NOT-EXIST'
        self.assertIn('E_FIXTURE_REF', self.codes(p))

    def test_bound_fixture_value_mismatch_is_rejected(self):
        p=baseline_pack(False); p['geometry']['triangle_count']+=1
        self.assertIn('E_FIXTURE_BINDING', self.codes(p))

    def test_positive_rejects_unapplied_non_uniform_scale(self):
        p=baseline_pack(False); p["geometry"]["object_transform_state"][1]["scale_state"] = "UNAPPLIED_NON_UNIFORM"
        self.assertIn("E_NONUNIFORM_SCALE", self.codes(p))

    def test_positive_rejects_flipped_normals(self):
        p=baseline_pack(False); p["geometry"]["normal_state"] = "INCONSISTENT_FLIPPED"
        self.assertIn("E_NORMAL_STATE", self.codes(p))

    def test_positive_rejects_triangle_budget_overrun(self):
        p=baseline_pack(False); p["target"]["geometry_budget_policy"]["max_triangles"] = 6000
        # Either exact binding or the budget rule (or both) must catch the contradiction.
        self.assertTrue({'E_FIXTURE_BINDING','E_TRIANGLE_BUDGET'} & self.codes(p))

    def test_required_manifold_must_pass(self):
        p=baseline_pack(False); p["geometry"]["manifold_state"] = "NOT_CHECKED"
        self.assertIn("E_MANIFOLD", self.codes(p))

    def test_positive_requires_naming_contract(self):
        p=baseline_pack(False); p["naming_contract"]["decisive_object_names"] = []
        self.assertIn("E_NAMING", self.codes(p))

    def test_export_only_is_not_interoperability_proof(self):
        p=baseline_pack(False); p["interoperability_evidence"]["export_only_proof"] = True
        self.assertIn("E_INTEROP_EVIDENCE", self.codes(p))

    def test_non_success_can_preserve_missing_decisive_evidence(self):
        p=baseline_pack(False); p["target"].pop("reference_scale"); p["decision"]["final_state"] = "EVIDENCE_INCOMPLETE"
        self.assertNotIn("E_MISSING_CRITICAL_POSITIVE", self.codes(p))

    def test_checker_does_not_choose_missing_decision(self):
        p=baseline_pack(False); p["decision"].pop("final_state")
        codes = self.codes(p)
        self.assertIn("E_DECISION_REQUIRED", codes)
        self.assertNotIn("EXPECTED_READY", codes)

    def test_positive_requires_manifold_requirement_dimension(self):
        p=baseline_pack(False); p['target'].pop('manifold_requirement')
        self.assertIn('E_MISSING_CRITICAL_POSITIVE', self.codes(p))

    def test_positive_requires_destructive_step_log_dimension(self):
        p=baseline_pack(False); p['modifiers'].pop('destructive_step_log')
        self.assertIn('E_MISSING_CRITICAL_POSITIVE', self.codes(p))

    def test_positive_requires_known_limitations_dimension(self):
        p=baseline_pack(False); p.pop('known_limitations')
        self.assertIn('E_MISSING_CRITICAL_POSITIVE', self.codes(p))

    def test_positive_requires_explicit_evidence_ref_dimension(self):
        p=baseline_pack(False); p.pop('evidence_ref')
        self.assertIn('E_MISSING_CRITICAL_POSITIVE', self.codes(p))

    def test_actual_shipped_transfer_fixture_is_resolvable(self):
        tr=json.loads((HERE/'transfer/transfer_fixture.json').read_text(encoding='utf-8'))
        fid=tr.pop('fixture_id'); tr.pop('rights_status',None); tr['fixture_ref']=fid; tr['evidence_ref']='fixture://'+fid
        tr['decision']={'final_state':'HOLD','rationale':'TEST-SENTINEL; resolver compatibility only.','triggers':{'review':'target changes'}}
        self.assertNotIn('E_FIXTURE_REF', self.codes(tr))
        self.assertNotIn('E_FIXTURE_BINDING', self.codes(tr))

    def test_actual_mutation_files_are_resolvable_and_applied(self):
        # Mobile mutation should bind target changes to baseline evidence without an embedded final answer.
        p=baseline_pack(False)
        p['fixture_ref']='D3D001-MUT-MOBILE'; p['evidence_ref']='fixture://D3D001-MUT-MOBILE'
        p['target']['target_use']='REALTIME_MOBILE_PREVIEW'; p['target']['target_constraint_scope']='CONSTRAINED_NAMED_TARGET_ONLY'; p['target']['geometry_budget_policy']['max_triangles']=6000
        p['decision']['final_state']='REWORK_REQUIRED'
        codes=self.codes(p)
        self.assertNotIn('E_FIXTURE_REF', codes); self.assertNotIn('E_FIXTURE_BINDING', codes)

    def test_visible_rules_are_consumed(self):
        bad=copy.deepcopy(RULES); bad['rules']=[r for r in bad['rules'] if r['id']!='R10']
        self.assertIn('E_VISIBLE_RULESET_INCOMPLETE', {e['code'] for e in validate_pack(baseline_pack(False),bad)})

    def test_positive_rejects_blank_known_limitations_when_fixture_has_value(self):
        p=baseline_pack(False); p['known_limitations']=[]
        self.assertTrue(self.codes(p), 'READY must not accept omitted fixture-known limitations')

    def test_positive_rejects_blank_destructive_log_when_fixture_has_value(self):
        p=baseline_pack(False); p['modifiers']['destructive_step_log']=[]
        self.assertTrue(self.codes(p), 'READY must not accept omitted fixture-known destructive history')

    def test_positive_rejects_blank_geometry_budget_when_fixture_has_value(self):
        p=baseline_pack(False); p['target']['geometry_budget_policy']={}
        self.assertTrue(self.codes(p), 'READY must not accept omitted target budget evidence')

    def test_positive_rejects_reference_scale_without_fixture_evidence_ref(self):
        p=baseline_pack(False); p['target']['reference_scale'].pop('evidence_ref')
        self.assertTrue(self.codes(p), 'READY must not accept partially bound reference scale')

    def test_material_slot_count_is_shipped_as_noncritical_knowledge(self):
        fixture=json.loads((HERE/'fixtures/baseline_fixture.json').read_text(encoding='utf-8'))
        template=json.loads((HERE/'evidence/evidence_pack.template.json').read_text(encoding='utf-8'))
        self.assertIsInstance(fixture['geometry']['material_slot_count'], int)
        self.assertIn('material_slot_count', template['geometry'])
        p=baseline_pack(False); p['geometry']['material_slot_count']=None
        self.assertNotIn('E_MISSING_CRITICAL_POSITIVE', self.codes(p))

    def test_material_slot_count_is_bound_when_learner_supplies_it(self):
        p=baseline_pack(False); p['geometry']['material_slot_count'] += 1
        self.assertIn('E_FIXTURE_BINDING', self.codes(p))

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