"""Behavioral safeguards for tooling that writes files or certifies checks."""
import argparse
import contextlib
import io
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch

sys.dont_write_bytecode = True
import checks
import common
import dev


class ValidationTests(unittest.TestCase):
    def test_script_error_overrides_success_and_zero_exit(self):
        status, errors = checks.outcome('SCRIPT ERROR: Nil access\nPONT 3D OK:', 0, False, 'PONT 3D OK:')
        self.assertEqual(status, 'FAIL')
        self.assertTrue(errors)

    def test_missing_completion_marker_fails(self):
        self.assertEqual(checks.outcome('Godot started', 0, False, 'SMOKE TEST PASSED:')[0], 'FAIL')

    def test_timeout_after_success_still_fails(self):
        self.assertEqual(checks.outcome('OK', 0, True, 'OK')[0], 'FAIL')

    def test_failure_without_error_text_fails(self):
        self.assertEqual(checks.outcome('', 1, False)[0], 'FAIL')

    def test_completed_check_passes(self):
        self.assertEqual(checks.outcome('SMOKE TEST PASSED:', 0, False, 'SMOKE TEST PASSED:')[0], 'PASS')

    def test_failure_identity_matters_not_just_count(self):
        old = {'conditions': {'seed': 1}, 'checks': [{'id': 'smoke', 'errors': ['ERROR: old']}]}
        new = {'conditions': {'seed': 1}, 'checks': [{'id': 'smoke', 'errors': ['ERROR: new']}]}
        comparison = checks.compare_reports(old, new)
        self.assertEqual(len(comparison['new']), 1)
        self.assertEqual(len(comparison['resolved']), 1)
        new['conditions']['seed'] = 2
        self.assertFalse(checks.compare_reports(old, new)['comparable'])

    def test_empty_selection_is_not_a_pass(self):
        with self.assertRaises(ValueError):
            checks.select_checks(['manager'], match='this-test-does-not-exist')

    def test_selection_deduplicates_area(self):
        once = [item['id'] for item in checks.select_checks(['gameplay'])]
        twice = [item['id'] for item in checks.select_checks(['gameplay', 'gameplay'])]
        # Nommer un nombre rendait ce test faux le jour ou une zone gagne un
        # second controle. Ce qu'il garde, c'est qu'une zone repetee ne double
        # rien — et qu'aucun controle n'est selectionne deux fois.
        self.assertEqual(once, twice)
        self.assertEqual(len(set(twice)), len(twice))

    def test_full_selection_does_not_run_author_data_writers(self):
        selected = checks.select_checks([], full=True)
        manager = [item for item in selected if item.get('path', '').startswith('tools/game-data-manager/tests/')]
        self.assertTrue(manager)
        self.assertTrue(all(Path(item['path']).stem.endswith('_test') for item in manager))
        self.assertNotIn('seed_ui_texts', [item['id'] for item in selected])
        self.assertNotIn('migrate_schema_27', [item['id'] for item in selected])

    def test_subprocess_timeout_and_false_green(self):
        with tempfile.TemporaryDirectory() as directory:
            log = Path(directory) / 'output.log'
            with patch.object(checks, 'PROJECT', Path(directory)):
                result = checks.execute([sys.executable, '-c', 'print("SCRIPT ERROR: broken");print("OK")'],
                                        os.environ.copy(), log, 10, 'OK')
                self.assertEqual(result['status'], 'FAIL')
                result = checks.execute([sys.executable, '-c', 'import time;time.sleep(5)'],
                                        os.environ.copy(), log, .15)
                self.assertTrue(result['timed_out'])


class EditTests(unittest.TestCase):
    def test_mixed_endings_and_bom_untouched_outside_edit(self):
        original = b'\xef\xbb\xbfstart\r\nanchor\r\nvalue\nend\r\n'
        changed, count = dev.replacement_bytes(original, [{'old': 'anchor\nvalue', 'new': 'anchor\nnew'}])
        self.assertEqual(changed, b'\xef\xbb\xbfstart\r\nanchor\r\nnew\nend\r\n')
        self.assertEqual(count, [1])

    def test_duplicate_anchor_is_rejected(self):
        with self.assertRaises(ValueError):
            dev.replacement_bytes(b'a a', [{'old': 'a', 'new': 'b'}])

    def test_second_failed_anchor_does_not_write_first_edit(self):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            target = root / 'sample.txt'
            target.write_bytes(b'first\r\nlast\r\n')
            original = target.read_bytes()
            plan = root / 'plan.json'
            common.write_json(plan, {'path': 'sample.txt', 'sha256': common.digest(original),
                                    'replacements': [{'old': 'first', 'new': 'changed'},
                                                     {'old': 'absent', 'new': 'oops'}]})
            with patch.object(common, 'PROJECT', root):
                with self.assertRaises(ValueError):
                    dev.edit(argparse.Namespace(plan=plan, apply=True))
            self.assertEqual(target.read_bytes(), original)

    def test_stale_hash_preserves_newer_edit(self):
        with tempfile.TemporaryDirectory() as directory:
            target = Path(directory) / 'sample.txt'
            old = common.digest(b'old')
            target.write_bytes(b'user changed this')
            with self.assertRaises(ValueError):
                common.atomic_write(target, b'agent edit', expected=old)
            self.assertEqual(target.read_bytes(), b'user changed this')

    def test_write_failure_preserves_original(self):
        with tempfile.TemporaryDirectory() as directory:
            target = Path(directory) / 'sample.txt'
            target.write_bytes(b'original')
            with patch('common.os.replace', side_effect=OSError('simulated failure')):
                with self.assertRaises(OSError):
                    common.atomic_write(target, b'new', expected=common.digest(b'original'))
            self.assertEqual(target.read_bytes(), b'original')
            self.assertEqual(list(Path(directory).iterdir()), [target])

    def test_paths_cannot_escape_project(self):
        with self.assertRaises(ValueError):
            common.project_path('../big-egg/project.godot')


class IsolationTests(unittest.TestCase):
    def test_environment_is_child_only_and_private(self):
        original = os.environ.get('APPDATA')
        with tempfile.TemporaryDirectory() as directory:
            env = common.isolated_environment(Path(directory))
            self.assertTrue(Path(env['APPDATA']).is_relative_to(directory))
            self.assertNotEqual(env['APPDATA'], original)
            self.assertEqual(os.environ.get('APPDATA'), original)

    def test_changed_input_detects_additions_deletions_and_edits(self):
        self.assertEqual(common.changed_files({'a': '1', 'b': '2'}, {'a': '3', 'c': '4'}), ['a', 'b', 'c'])


class GitTests(unittest.TestCase):
    def test_checkpoint_preserves_unrelated_staging_and_commits_explicit_files(self):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            with patch.object(common, 'PROJECT', root), patch.object(dev, 'PROJECT', root):
                common.git('init', '--quiet')
                common.git('config', 'user.name', 'Tooling test')
                common.git('config', 'user.email', 'test@localhost')
                (root / 'requested.txt').write_text('wanted', encoding='utf8')
                (root / 'other.txt').write_text('keep mine', encoding='utf8')
                common.git('add', '--', 'other.txt')
                args = argparse.Namespace(files=['requested.txt'], message='Selected checkpoint')
                with self.assertRaises(ValueError):
                    dev.checkpoint(args)
                self.assertEqual(common.git('diff', '--cached', '--name-only').stdout.strip(), 'other.txt')
                # Only the disposable test repository is changed here.
                common.git('commit', '--quiet', '-m', 'Existing author work')
                with contextlib.redirect_stdout(io.StringIO()):
                    dev.checkpoint(args)
                self.assertEqual(common.git('diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD').stdout.strip(),
                                 'requested.txt')
                self.assertEqual((root / 'other.txt').read_text(), 'keep mine')


class BackupRetentionTests(unittest.TestCase):
    def test_keeps_newest_four_and_first_of_each_month(self):
        names = ['big-egg-20260803-1000.7z', 'big-egg-20260810-1000.7z', 'big-egg-20260902-1000.7z',
                 'big-egg-20260909-1000.7z', 'big-egg-20260916-1000.7z', 'big-egg-20260923-1000.7z',
                 'big-egg-20260930-1000.7z', 'big-egg-20261007-1000.7z']
        self.assertEqual(dev.backups_to_prune(names), ['big-egg-20260810-1000.7z', 'big-egg-20260909-1000.7z'])

    def test_unstamped_files_are_never_pruned(self):
        names = ['notes.txt', 'big-egg-3d-20260917-224731-520237.bundle']
        self.assertEqual(dev.backups_to_prune(names, keep_recent=0), [])


if __name__ == '__main__':
    if '--regression' in sys.argv:
        sys.argv.remove('--regression')
        # Reintroduce the false-green bug once to prove the new regression test catches it.
        checks.outcome = lambda output, code, timed_out, success=None: ('PASS', [])
    unittest.main(verbosity=1)
