import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import struct

from model_tooling.deploy import deployment_plan, snapshot
from model_tooling.manifests import ContractError, fingerprints
from model_tooling.medium import digest
from model_tooling.medium_acceptance import POLICY, RUN_HASHES


class DeployPlanTest(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.root = Path(self.temp.name)
        self.run = self.root / 'run'
        self.run.mkdir()
        self.target = self.root / 'RFDETRMedium'
        (self.target / '1').mkdir(parents=True)
        (self.target / '1/model.plan').write_bytes(b'old plan')
        (self.target / 'config.pbtxt').write_text('old config')

    def accept_source(self):
        for stage, name in [('export', 'model.onnx'), ('build', 'model.plan')]:
            directory = self.run / stage
            directory.mkdir()
            artifact = directory / name
            artifact.write_bytes(stage.encode())
            result = {'status': 'PASS', 'input_identity': 'same',
                      'artifact': {'fingerprint': fingerprints(artifact)}}
            (directory / 'result.json').write_text(json.dumps(result))
        onnx_sha = fingerprints(self.run / 'export/model.onnx')['sha256']
        plan_sha = fingerprints(self.run / 'build/model.plan')['sha256']
        (self.run / 'export/reference_onnx.json').write_text(json.dumps({'status': 'PASS', 'onnx_sha256': onnx_sha}))
        (self.run / 'build/numerical.json').write_text(json.dumps({'status': 'PASS', 'onnx_sha256': onnx_sha, 'plan_sha256': plan_sha}))

    def test_missing_acceptance_never_ready(self):
        before = snapshot(self.target)
        result = deployment_plan(self.run, self.target, 2)
        self.assertFalse(result['ready'])
        self.assertFalse(result['mutation'])
        self.assertEqual(before, snapshot(self.target))
        self.assertFalse((self.target / '2').exists())

    def test_numerical_pass_still_needs_target_acceptance(self):
        self.accept_source()
        result = deployment_plan(self.run, self.target, 2)
        self.assertEqual(result['numerical_gate'], 'PASS')
        self.assertFalse(result['ready'])
        self.assertTrue(result['rollback']['preserve_existing_versions'])

    def test_target_cas_drift(self):
        cas = digest(snapshot(self.target))
        (self.target / 'config.pbtxt').write_text('changed')
        with self.assertRaises(ContractError):
            deployment_plan(self.run, self.target, 2, cas)

    def test_existing_version_rejected(self):
        with self.assertRaises(ContractError):
            deployment_plan(self.run, self.target, 1)

    def test_source_hash_drift(self):
        self.accept_source()
        (self.run / 'build/model.plan').write_bytes(b'changed')
        result = deployment_plan(self.run, self.target, 2)
        self.assertIn('SOURCE_NUMERICAL_ACCEPTANCE_MISSING_OR_INVALID', result['blockers'])

    def test_symlink_target_rejected(self):
        alias = self.root / 'alias'
        alias.symlink_to(self.target, target_is_directory=True)
        with self.assertRaises(ContractError):
            deployment_plan(self.run, alias, 2)

    def limited_fixture(self):
        self.accept_source()
        report_path = self.run / 'build/numerical.json'
        report = json.loads(report_path.read_text())
        report.update(status='FAIL', cpp_onnx={'pass': True}, cpp_plan={'pass': True})
        report_path.write_text(json.dumps(report))
        export_path = self.run / 'export/result.json'
        export = json.loads(export_path.read_text())
        export['onnx_schema'] = {name: {'dtype': 1, 'shape': shape} for name, shape in (
            ('input', ['B', 3, 576, 576]), ('dets', ['B', 300, 4]), ('labels', ['B', 300, 91]))}
        export_path.write_text(json.dumps(export))
        (self.run / 'numerical').mkdir()
        for name, size in [('dets', 1200), ('labels', 27300)]:
            (self.run / 'numerical' / (name+'.bin')).write_bytes(struct.pack('<f', .1)*size)
        # 합성 fixture만 pin한다. Production CLI는 pin 교체 옵션을 제공하지 않는다.
        pins = {name: fingerprints(self.run / name)['sha256'] for name in RUN_HASHES}
        return pins

    def test_exact_opt_in_keeps_fail_and_target_blockers(self):
        pins = self.limited_fixture()
        with patch.dict(RUN_HASHES, pins, clear=True):
            result = deployment_plan(self.run, self.target, 2, source_acceptance=POLICY)
        self.assertEqual('FAIL', result['numerical_gate'])
        self.assertEqual('LIMITED_USER_VISUAL_ACCEPTANCE', result['source_acceptance']['status'])
        self.assertFalse(result['ready'])
        self.assertFalse(result['mutation'])
        self.assertEqual(['TARGET_ENVIRONMENT_AND_PIPELINE_ACCEPTANCE_REQUIRED'], result['blockers'])

    def test_fail_without_opt_in_is_rejected(self):
        self.limited_fixture()
        result = deployment_plan(self.run, self.target, 2)
        self.assertNotIn('source_acceptance', result)

    def test_arbitrary_model_cannot_use_real_approval(self):
        self.limited_fixture()
        result = deployment_plan(self.run, self.target, 2, source_acceptance=POLICY)
        self.assertNotIn('source_acceptance', result)

    def test_report_drift_and_cpp_failure_are_rejected(self):
        pins = self.limited_fixture()
        path = self.run / 'build/numerical.json'
        value = json.loads(path.read_text())
        value['cpp_plan']['pass'] = False
        path.write_text(json.dumps(value))
        with patch.dict(RUN_HASHES, pins, clear=True):
            self.assertNotIn('source_acceptance', deployment_plan(self.run, self.target, 2, source_acceptance=POLICY))
        pins['build/numerical.json'] = fingerprints(path)['sha256']
        with patch.dict(RUN_HASHES, pins, clear=True):
            self.assertNotIn('source_acceptance', deployment_plan(self.run, self.target, 2, source_acceptance=POLICY))

    def test_nonfinite_and_shape_errors_not_waived(self):
        pins = self.limited_fixture()
        path = self.run / 'numerical/dets.bin'
        for raw in (struct.pack('<f', float('nan'))*1200, b'bad size'):
            path.write_bytes(raw)
            pins['numerical/dets.bin'] = fingerprints(path)['sha256']
            with patch.dict(RUN_HASHES, pins, clear=True):
                self.assertNotIn('source_acceptance', deployment_plan(self.run, self.target, 2, source_acceptance=POLICY))

    def test_schema_and_stage_error_not_waived(self):
        pins = self.limited_fixture()
        path = self.run / 'export/result.json'
        value = json.loads(path.read_text())
        for field, replacement in [('onnx_schema', {}), ('status', 'FAIL')]:
            invalid = {**value, field: replacement}
            path.write_text(json.dumps(invalid))
            with patch.dict(RUN_HASHES, pins, clear=True):
                self.assertNotIn('source_acceptance', deployment_plan(self.run, self.target, 2, source_acceptance=POLICY))

    def test_no_generic_force_policy(self):
        with self.assertRaises(ContractError):
            deployment_plan(self.run, self.target, 2, source_acceptance='force')
