"""Targeted release checks: no Kodi, SSH, uploads or real network calls."""
import importlib.util
import io
import json
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from unittest.mock import patch
import xml.etree.ElementTree as ET
import zipfile

ROOT = Path(__file__).resolve().parents[1]


def load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


release = load('release', ROOT / 'scripts/release.py')


class Response:
    def __enter__(self):
        return self

    def __exit__(self, *args):
        pass

    def read(self):
        return b'<addons><addon id="plugin.video.milionar" version="2.1.41"/></addons>'


class ReleaseTests(unittest.TestCase):
    def setUp(self):
        output = patch('sys.stdout', new_callable=io.StringIO)
        output.start()
        self.addCleanup(output.stop)
        self.directory = tempfile.TemporaryDirectory(prefix='milionar-release-test-')
        self.root = Path(self.directory.name)
        for name, text in {
            'addon.xml': '<addon id="plugin.video.milionar" version="2.1.41"/>',
            'resources/skin.milionar/addon.xml': '<addon id="skin.milionar" version="1.3.29"><requires>'
                '<import addon="plugin.video.milionar" version="2.1.41"/></requires></addon>',
            'resources/skin.milionar/changelog.txt': '[B]1.3.29[/B]\nPrevious release\n',
            'resources/skin.milionar/xml/Home.xml': '<window/>',
            'resources/skin.milionar.modern/addon.xml': '<addon id="skin.milionar.modern" version="1.3.29"><requires>'
                '<import addon="plugin.video.milionar" version="2.1.41"/></requires></addon>',
            'resources/skin.milionar.modern/xml/Includes_Modern.xml': '<includes/>',
        }.items():
            path = self.root / name
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(text)
        helper = self.root / 'scripts/skin_harness/deploy_check.py'
        helper.parent.mkdir(parents=True)
        shutil.copy2(ROOT / 'scripts/skin_harness/deploy_check.py', helper)
        self.guard = load('test_deploy_guard', helper)
        self.state = Path(self.guard.STATE)
        self.state.parent.mkdir(parents=True)
        self.state.write_text(json.dumps({'version': '1.3.29', 'hash': self.guard.tree_hash()}))
        self.addCleanup(self.directory.cleanup)
        for name, value in (('ROOT', str(self.root)), ('REPO_DIR', str(self.root / 'repository/repository.milionar'))):
            handle = patch.object(release, name, value)
            handle.start()
            self.addCleanup(handle.stop)
        handle = patch.object(release.urllib.request, 'urlopen', return_value=Response())
        self.http = handle.start()
        self.addCleanup(handle.stop)

    def versions(self, root=None):
        root = root or self.root
        plugin = ET.parse(root / 'addon.xml').getroot()
        skin = ET.parse(root / 'resources/skin.milionar/addon.xml').getroot()
        modern = ET.parse(root / 'resources/skin.milionar.modern/addon.xml').getroot()
        # skin.milionar.modern shares the version and the plugin dependency of skin.milionar
        self.assertEqual((modern.get('version'), modern.find('requires/import').get('version')),
                         (skin.get('version'), skin.find('requires/import').get('version')))
        return plugin.get('version'), skin.get('version'), skin.find('requires/import').get('version')

    def fake_build(self, stage):
        with zipfile.ZipFile(Path(stage) / 'plugin.video.milionar-latest.zip', 'w') as archive:
            archive.write(Path(release.ROOT) / 'addon.xml', 'plugin.video.milionar/addon.xml')
        return self.versions(Path(release.ROOT))[0]

    def test_preparation_synchronizes_versions_and_changelog(self):
        release.prepare()
        self.assertEqual(self.versions(), ('2.1.42', '1.3.30', '2.1.42'))
        self.assertTrue((self.root / 'resources/skin.milionar/changelog.txt').read_text().startswith('[B]1.3.30[/B]'))

    def test_preparation_is_idempotent_before_publish(self):
        release.prepare()
        first = {path: path.read_bytes() for path in self.root.rglob('*') if path.is_file()}
        release.prepare()
        self.assertEqual(first, {path: path.read_bytes() for path in first})

    def test_ahead_plugin_version_is_preserved(self):
        (self.root / 'addon.xml').write_text('<addon id="plugin.video.milionar" version="2.2.0"/>')
        release.prepare()
        self.assertEqual(self.versions(), ('2.2.0', '1.3.30', '2.2.0'))

    def test_no_bump_refuses_without_editing_versions(self):
        with self.assertRaises(SystemExit):
            release.prepare(bump=False)
        self.assertEqual(self.versions(), ('2.1.41', '1.3.29', '2.1.41'))

    def test_changed_skin_bumps_even_if_dependency_matches(self):
        (self.root / 'resources/skin.milionar/xml/Home.xml').write_text('<window><onload/></window>')
        self.guard.prepare('2.1.41')
        self.assertEqual(self.versions(), ('2.1.41', '1.3.30', '2.1.41'))

    def test_changed_modern_overlay_bumps_both_skins(self):
        (self.root / 'resources/skin.milionar.modern/xml/Includes_Modern.xml').write_text('<includes><include name="x"/></includes>')
        self.guard.prepare('2.1.41')
        self.assertEqual(self.versions(), ('2.1.41', '1.3.30', '2.1.41'))

    def test_news_is_the_newest_own_changelog_entry(self):
        manifest = self.root / 'addon.xml'
        manifest.write_text('<addon id="plugin.video.milionar" version="2.1.41">\n'
                            '    <extension point="xbmc.addon.metadata">\n    </extension>\n</addon>')
        changelog = self.root / 'resources/skin.milionar/changelog.txt'
        changelog.write_text('[B]1.3.30[/B]\n• Updates & news\n• Release with Milionář 2.1.42; skin files updated\n\n'
                             '[B]1.3.29[/B]\nPrevious release\n')
        release.write_news()
        release.write_news()                              # idempotent
        self.assertEqual(manifest.read_text().count('<news>Updates &amp; news</news>'), 1)
        changelog.write_text('[B]1.3.31[/B]\n• Release with Milionář 2.1.43; skin files updated\n\n')
        release.write_news()
        self.assertNotIn('<news>', manifest.read_text())

    def test_manual_skin_version_is_preserved(self):
        path = self.root / 'resources/skin.milionar/addon.xml'
        path.write_text(path.read_text().replace('1.3.29', '1.4.0'))
        release.prepare()
        self.assertEqual(self.versions(), ('2.1.42', '1.4.0', '2.1.42'))

    def test_defaults_snapshot_is_explicit(self):
        with patch.object(release, 'run') as run:
            release.prepare()
            run.assert_not_called()
            release.prepare(snapshot=True)
            self.assertEqual(run.call_args.args[1], str(self.root / 'scripts/snapshot_defaults.py'))

    def test_dry_run_cannot_upload_or_edit_workspace(self):
        before = {path: path.read_bytes() for path in self.root.rglob('*') if path.is_file()}
        with patch('sys.argv', ['release.py', 'publish', '--dry-run']), \
             patch.object(release, 'build', side_effect=self.fake_build), \
             patch.object(release, 'remote', side_effect=AssertionError('SSH in dry-run')), \
             patch.object(release, 'publish', side_effect=AssertionError('upload in dry-run')), \
             patch.object(release.tempfile, 'mkdtemp', return_value=str(self.root / 'stage')):
            release.main()
        self.assertEqual(before, {path: path.read_bytes() for path in before})
        receipt = json.loads((self.root / 'stage/release.json').read_text())
        self.assertEqual((receipt['mode'], receipt['version'], receipt['skin_version']), ('dry-run', '2.1.42', '1.3.30'))
        self.assertFalse((self.root / '.agents/state/release.json').exists())
        self.assertEqual(release.ROOT, str(self.root))

    def test_publish_order_and_success_receipt(self):
        events = []
        with patch('sys.argv', ['release.py', 'publish']), \
             patch.object(release, 'build', side_effect=lambda stage: (events.append('build'), self.fake_build(stage))[1]), \
             patch.object(release, 'remote', side_effect=lambda cmd: events.append('ssh')), \
             patch.object(release, 'publish', side_effect=lambda *args: events.append('repo')), \
             patch.object(release, 'run', side_effect=lambda *args: events.append('record')), \
             patch.object(release, 'publish_dev', side_effect=lambda *args: events.append('dev')), \
             patch.object(release, 'prune', side_effect=lambda: (events.append('archive'), ['old/2.1.41.zip'])[1]):
            release.main()
        self.assertEqual(events, ['ssh', 'build', 'repo', 'record', 'dev', 'archive'])
        receipt = json.loads((self.root / '.agents/state/release.json').read_text())
        self.assertEqual(receipt['mode'], 'published')
        self.assertEqual(receipt['archived'], ['old/2.1.41.zip'])
        self.assertEqual(receipt['zip_files'], 1)
        self.assertEqual(len(receipt['zip_sha256']), 64)

    def test_failed_dev_upload_does_not_write_success_receipt(self):
        with patch('sys.argv', ['release.py', 'publish']), \
             patch.object(release, 'build', side_effect=self.fake_build), \
             patch.object(release, 'remote'), patch.object(release, 'publish'), patch.object(release, 'run'), \
             patch.object(release, 'publish_dev', side_effect=RuntimeError('upload failed')), \
             patch.object(release, 'prune') as archive:
            with self.assertRaises(RuntimeError):
                release.main()
        self.assertFalse((self.root / '.agents/state/release.json').exists())
        archive.assert_not_called()

    def test_packaged_playback_failure_blocks_all_uploads(self):
        repository = self.root / 'repository/repository.milionar/addon.xml'
        repository.parent.mkdir(parents=True)
        repository.write_text('<addon id="repository.milionar" version="1.0.0"/>')

        def execute(*command, **kwargs):
            if command[0] == 'sh':
                target = Path(command[2]) / 'plugin.video.milionar-2.1.41.zip'
                with zipfile.ZipFile(target, 'w') as package:
                    package.write(self.root / 'addon.xml', 'plugin.video.milionar/addon.xml')
                    package.write(ROOT / 'wcs/playback/language.py', 'plugin.video.milionar/wcs/playback/language.py')
                    # The shipped service starts playback handling but never dispatches its callbacks.
                    package.writestr('plugin.video.milionar/service.py',
                        "if __name__ == '__main__':\n"
                        "    from wcs.playback import language\n"
                        "    language.start()\n"
                        "    main()\n")
                return subprocess.CompletedProcess(command, 0, stdout=str(target))
            if '-m' in command:
                # Run the real package checks; only packaging/SSH are simulated.
                kwargs['cwd'] = str(ROOT)
                return subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE,
                                      stderr=subprocess.PIPE, **kwargs)
            return subprocess.CompletedProcess(command, 0)

        with patch('sys.argv', ['release.py', 'publish']), \
             patch.object(release, 'prepare'), patch.object(release, 'remote'), \
             patch.object(release, 'run', side_effect=execute), \
             patch.object(release, 'publish') as upload, patch.object(release, 'publish_dev') as dev_upload:
            with self.assertRaises(subprocess.CalledProcessError) as failed:
                release.main()
        self.assertIn('test_packaged_service_dispatches_callbacks_after_startup', failed.exception.stderr)
        self.assertIn("Expected 'mock' to have been called", failed.exception.stderr)
        upload.assert_not_called()
        dev_upload.assert_not_called()
        self.assertFalse((self.root / '.agents/state/release.json').exists())

    def test_unknown_option_refuses_before_network_or_edits(self):
        with patch('sys.argv', ['release.py', 'publish', '--typo']), patch('sys.stderr', new_callable=io.StringIO):
            with self.assertRaises(SystemExit):
                release.main()
        self.http.assert_not_called()
        self.assertEqual(self.versions(), ('2.1.41', '1.3.29', '2.1.41'))


if __name__ == '__main__':
    unittest.main()
