import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from types import SimpleNamespace

from model_tooling import manifests, medium


class ModelToolingTest(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.root = Path(self.tmp.name)
        self.weight = self.root / 'rf-detr-medium.pth'
        self.weight.write_bytes(b'synthetic checkpoint: never load')
        asset = dict(manifests.ASSETS['medium'], **manifests.fingerprints(self.weight))
        self.asset_patch = patch.dict(manifests.ASSETS, medium=asset)
        self.asset_patch.start()
        self.addCleanup(self.asset_patch.stop)
        self.manifest = self.root / 'manifest.yaml'
        manifests.write_new_yaml(self.manifest, manifests.make_input('medium', self.weight))
        self.builds = self.root / 'builds'
        self.builds.mkdir()
        p = patch.object(medium, 'BUILD_ROOT', self.builds)
        p.start()
        self.addCleanup(p.stop)

    def test_input_roundtrip(self):
        data = manifests.validate_input(self.manifest)
        self.assertEqual(data['verification']['weight_load'], 'NOT_RUN')

    def test_weight_drift(self):
        self.weight.write_bytes(b'changed')
        with self.assertRaises(manifests.ContractError):
            manifests.validate_input(self.manifest)

    def test_manifest_no_clobber(self):
        old = self.manifest.read_bytes()
        with self.assertRaises(manifests.ContractError):
            manifests.write_new_yaml(self.manifest, {})
        self.assertEqual(old, self.manifest.read_bytes())

    def test_manifest_traversal(self):
        data = manifests.make_input('medium', self.weight)
        data['artifact']['filename'] = '../rf-detr-medium.pth'
        import yaml
        self.manifest.write_text(yaml.safe_dump(data))
        with self.assertRaises(manifests.ContractError):
            manifests.validate_input(self.manifest)

    def test_symlink(self):
        alias = self.root / 'alias.yaml'
        alias.symlink_to(self.manifest)
        with self.assertRaises(manifests.ContractError):
            manifests.validate_input(alias)

    def test_invalid_run_ids(self):
        for value in ('../escape', '/absolute', '.', 'a/b', 'a;cmd', ''):
            with self.assertRaises(manifests.ContractError):
                medium.run_path(value)

    def test_dry_run_has_no_side_effect(self):
        with patch.object(subprocess, 'run', side_effect=AssertionError('no subprocess')):
            result = medium.plan(self.manifest, 'run-1')
        self.assertFalse(result['execution_verified'])
        self.assertFalse((self.builds / 'run-1').exists())

    def test_collision_report(self):
        (self.builds / 'run-1').mkdir()
        self.assertTrue(medium.plan(self.manifest, 'run-1')['output_collision'])

    def test_missing_previous_stage(self):
        with self.assertRaises(manifests.ContractError):
            medium.execute('build', self.manifest, 'missing')

    def test_export_failure_stops_and_records(self):
        with patch.object(medium, 'exporter_gate'), patch.object(subprocess, 'run',
                side_effect=subprocess.CalledProcessError(1, ['synthetic'])):
            with self.assertRaises(subprocess.CalledProcessError):
                medium.execute('export', self.manifest, 'failed')
        result = json.loads((self.builds / 'failed/export/result.json').read_text())
        self.assertEqual(result['status'], 'FAILED')
        self.assertFalse((self.builds / 'failed/.stage-lock').exists())
        with self.assertRaises(manifests.ContractError):
            medium.execute('build', self.manifest, 'failed')

    def test_stage_lock(self):
        run = self.builds / 'locked'
        run.mkdir()
        (run / '.stage-lock').touch()
        with self.assertRaises(manifests.ContractError):
            medium.execute('build', self.manifest, 'locked')

    def test_previous_artifact_hash_guard(self):
        stage = self.builds / 'run/export'
        stage.mkdir(parents=True)
        artifact = stage / 'model.onnx'
        artifact.write_bytes(b'original')
        result = {'status': 'PASS', 'input_identity': 'id',
                  'artifact': {'filename': artifact.name, 'fingerprint': manifests.fingerprints(artifact)}}
        (stage / 'result.json').write_text(json.dumps(result))
        artifact.write_bytes(b'changed')
        with self.assertRaises(manifests.ContractError):
            medium.checked_result(stage.parent, 'export', 'id')

    def test_unpinned_exporter_rejected(self):
        dist = SimpleNamespace(version='1.9.3', read_text=lambda _: '{}')
        with patch.object(medium.metadata, 'distribution', return_value=dist):
            with self.assertRaises(manifests.ContractError):
                medium.exporter_gate()

    def test_existing_export_run_preserved(self):
        run = self.builds / 'existing'
        run.mkdir()
        marker = run / 'keep'
        marker.write_text('preserve')
        with patch.object(medium, 'exporter_gate'):
            with self.assertRaises(FileExistsError):
                medium.execute('export', self.manifest, 'existing')
        self.assertEqual(marker.read_text(), 'preserve')

    def test_reuse_verified_export_without_model_execution(self):
        source = self.builds / 'source/export'
        source.mkdir(parents=True)
        artifact = source / 'model.onnx'
        artifact.write_bytes(b'fake graph for copy test only')
        identity = medium.digest(manifests.validate_input(self.manifest))
        result = {'status': 'PASS', 'input_identity': identity,
                  'artifact': {'filename': 'model.onnx', 'fingerprint': manifests.fingerprints(artifact)}}
        (source / 'result.json').write_text(json.dumps(result))
        (source / 'reference_onnx.json').write_text(json.dumps(
            {'status': 'PASS', 'onnx_sha256': manifests.fingerprints(artifact)['sha256']}))
        for name in ('input', 'dets_reference', 'labels_reference', 'dets_onnx', 'labels_onnx'):
            (source / (name+'.npy')).write_bytes(b'copy fixture')
        with patch.object(subprocess, 'run', side_effect=AssertionError('no execution')):
            medium.reuse_export(self.manifest, 'source', 'new')
        self.assertEqual(artifact.read_bytes(), (self.builds/'new/export/model.onnx').read_bytes())
        self.assertTrue((self.builds/'new/reused-export.json').exists())
        artifact.write_bytes(b'corrupted')
        with self.assertRaises(manifests.ContractError):
            medium.reuse_export(self.manifest, 'source', 'bad')
        self.assertFalse((self.builds/'bad').exists())
