import hashlib
import io
import json
import os
import tarfile
import tempfile
import unittest

from occystrap import constants
from occystrap.filters import ExcludeFilter, TimestampNormalizer, SearchFilter
from occystrap.outputs import tarfile as output_tarfile


class FilterChainingTestCase(unittest.TestCase):
    """Test chaining multiple filters together."""

    def _create_layer_with_files(self, files):
        """Create a layer tarball containing the specified files.

        Args:
            files: Dict of {path: content} pairs. Use None for directories.

        Returns:
            Path to the temporary layer file.
        """
        tf = tempfile.NamedTemporaryFile(delete=False)
        with tarfile.open(fileobj=tf, mode='w') as layer_tar:
            for path, content in files.items():
                ti = tarfile.TarInfo(path)
                if content is None:
                    ti.type = tarfile.DIRTYPE
                    ti.size = 0
                    layer_tar.addfile(ti)
                else:
                    data = content.encode('utf-8') if isinstance(
                        content, str) else content
                    ti.size = len(data)
                    ti.mtime = 1700000000  # Non-zero timestamp
                    layer_tar.addfile(ti, io.BytesIO(data))
        tf.flush()
        tf.close()
        return tf.name

    def _get_layer_files_and_mtimes(self, layer_path):
        """Get list of file paths and mtimes in a layer tarball."""
        with open(layer_path, 'rb') as f:
            with tarfile.open(fileobj=f, mode='r') as tar:
                return [(m.name, m.mtime) for m in tar]

    def test_exclude_then_normalize(self):
        """Test chaining ExcludeFilter -> TimestampNormalizer."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
            'app/utils.py': 'def util(): pass',
        })

        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    # Build pipeline: exclude -> normalize -> tarwriter
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    normalizer = TimestampNormalizer(tw, timestamp=0)
                    exclude_filter = ExcludeFilter(
                        normalizer, patterns=['*__pycache__*'])

                    # Process layer
                    with open(layer_path, 'rb') as f:
                        filtered_data, _ = exclude_filter._filter_layer(f)

                    # The filtered data should have __pycache__ removed
                    filtered_data.seek(0)
                    with tarfile.open(fileobj=filtered_data, mode='r') as tar:
                        files = [m.name for m in tar]

                    self.assertIn('app/main.py', files)
                    self.assertIn('app/utils.py', files)
                    pycache_file = 'app/__pycache__/main.cpython-311.pyc'
                    self.assertNotIn(pycache_file, files)

                    filtered_data.close()
                    os.unlink(filtered_data.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_normalize_then_exclude(self):
        """Test chaining TimestampNormalizer -> ExcludeFilter."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
        })

        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    # Build pipeline: normalize -> exclude -> tarwriter
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    exclude_filter = ExcludeFilter(
                        tw, patterns=['*__pycache__*'])
                    normalizer = TimestampNormalizer(
                        exclude_filter, timestamp=0)

                    # Process layer through normalizer first
                    with open(layer_path, 'rb') as f:
                        normalized_data, _ = normalizer._normalize_layer(f)

                    # Verify timestamps are normalized
                    normalized_data.seek(0)
                    with tarfile.open(
                            fileobj=normalized_data, mode='r') as tar:
                        for m in tar:
                            self.assertEqual(0, m.mtime)

                    normalized_data.close()
                    os.unlink(normalized_data.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_search_as_passthrough_with_exclude(self):
        """Test SearchFilter as passthrough with ExcludeFilter."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
            'config/settings.conf': 'setting=value',
        })

        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    # Build pipeline: search -> exclude -> tarwriter
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    exclude_filter = ExcludeFilter(
                        tw, patterns=['*__pycache__*'])
                    search_filter = SearchFilter(
                        exclude_filter,
                        pattern='*.py',
                        image='test/image',
                        tag='latest',
                        script_friendly=True)

                    # Process layer
                    with open(layer_path, 'rb') as f:
                        search_filter.process_image_element(
                            constants.ImageElement(
                                constants.IMAGE_LAYER,
                                'originalhash', f))

                    # Search should find .py files (not filtered by exclude)
                    self.assertEqual(1, len(search_filter.results))
                    self.assertEqual('app/main.py',
                                     search_filter.results[0][1])

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_full_pipeline_integration(self):
        """Test a full pipeline with multiple filters and TarWriter."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
            'config.json': '{"setting": "value"}',
        })

        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    # Build pipeline:
                    # search -> exclude -> normalize -> tarwriter
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    normalizer = TimestampNormalizer(tw, timestamp=0)
                    exclude_filter = ExcludeFilter(
                        normalizer, patterns=['*__pycache__*'])
                    search_filter = SearchFilter(
                        exclude_filter,
                        pattern='*.json',
                        image='test/image',
                        tag='latest',
                        script_friendly=True)

                    # Process config (passthrough)
                    config_data = json.dumps({
                        'architecture': 'amd64',
                        'os': 'linux'
                    }).encode('utf-8')
                    search_filter.process_image_element(
                        constants.ImageElement(
                            constants.CONFIG_FILE,
                            'config.json',
                            io.BytesIO(config_data)))

                    # Process layer
                    with open(layer_path, 'rb') as f:
                        search_filter.process_image_element(
                            constants.ImageElement(
                                constants.IMAGE_LAYER,
                                'originalhash', f))

                    search_filter.finalize()

                    # Verify output tarball structure
                    with tarfile.open(output_tf.name, 'r') as output_tar:
                        names = [m.name for m in output_tar]
                        # Should have manifest.json and config
                        self.assertIn('manifest.json', names)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_multiple_exclude_patterns(self):
        """Test ExcludeFilter with multiple patterns."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
            '.git/config': '[core]',
            '.git/objects/abc': b'\x00',
            'README.md': '# Project',
            'test.pyc': b'\x00\x00',
        })

        try:
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    exclude_filter = ExcludeFilter(
                        tw,
                        patterns=['*__pycache__*', '.git/*', '*.pyc'])

                    with open(layer_path, 'rb') as f:
                        result = exclude_filter._filter_layer(f)
                        filtered_data, _ = result

                    filtered_data.seek(0)
                    with tarfile.open(fileobj=filtered_data, mode='r') as tar:
                        files = [m.name for m in tar]

                    # Should keep only main.py and README.md
                    self.assertIn('app/main.py', files)
                    self.assertIn('README.md', files)
                    # Should exclude __pycache__, .git, and .pyc
                    self.assertNotIn('app/__pycache__/main.cpython-311.pyc',
                                     files)
                    self.assertNotIn('.git/config', files)
                    self.assertNotIn('.git/objects/abc', files)
                    self.assertNotIn('test.pyc', files)

                    filtered_data.close()
                    os.unlink(filtered_data.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_sha_changes_with_each_filter(self):
        """Test that SHA changes when filters modify content."""
        layer_path = self._create_layer_with_files({
            'file1.txt': 'content1',
            'file2.txt': 'content2',
        })

        try:
            # Get original SHA
            with open(layer_path, 'rb') as f:
                original_sha = hashlib.sha256(f.read()).hexdigest()

            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)

                    # Normalize timestamps
                    normalizer = TimestampNormalizer(tw, timestamp=0)
                    with open(layer_path, 'rb') as f:
                        normalized_data, normalized_sha = \
                            normalizer._normalize_layer(f)

                    # SHA should change after normalization
                    self.assertNotEqual(original_sha, normalized_sha)

                    normalized_data.close()
                    os.unlink(normalized_data.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_filter_order_affects_output(self):
        """Test that filter order can affect the final output."""
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc': b'\x00\x00',
        })

        try:
            # Test 1: exclude first, then normalize
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    tw1 = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    normalizer1 = TimestampNormalizer(tw1, timestamp=0)
                    exclude1 = ExcludeFilter(
                        normalizer1, patterns=['*__pycache__*'])

                    with open(layer_path, 'rb') as f:
                        filtered_data1, sha1 = exclude1._filter_layer(f)
                    filtered_data1.close()
                    os.unlink(filtered_data1.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

            # Test 2: normalize first, then exclude
            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    tw2 = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    exclude2 = ExcludeFilter(
                        tw2, patterns=['*__pycache__*'])
                    normalizer2 = TimestampNormalizer(exclude2, timestamp=0)

                    with open(layer_path, 'rb') as f:
                        result2 = normalizer2._normalize_layer(f)
                        normalized_data2, sha2 = result2
                    normalized_data2.close()
                    os.unlink(normalized_data2.name)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

            # The SHA from exclude->normalize should differ from original
            # (both filters affect output, but at different stages)
            self.assertIsNotNone(sha1)
            self.assertIsNotNone(sha2)

        finally:
            os.unlink(layer_path)

    def test_combined_filters_update_config_diff_ids(
            self):
        """Test that chained content-modifying filters
        correctly update config diff_ids.

        Pipeline: normalize-timestamps -> exclude -> tw
        Both filters modify layer content and must update
        the config diff_ids chain correctly.
        """
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc':
                b'\x00\x00',
            'app/utils.py': 'def util(): pass',
        })

        try:
            # Compute original diff_id
            with open(layer_path, 'rb') as f:
                original_sha = hashlib.sha256(
                    f.read()).hexdigest()

            # Build config with original diff_id
            config = {
                'architecture': 'amd64',
                'os': 'linux',
                'rootfs': {
                    'type': 'layers',
                    'diff_ids': [
                        'sha256:%s' % original_sha
                    ],
                },
            }
            config_bytes = json.dumps(
                config).encode('utf-8')
            config_sha = hashlib.sha256(
                config_bytes).hexdigest()
            config_name = '%s.json' % config_sha

            with tempfile.NamedTemporaryFile(
                    delete=False,
                    suffix='.tar') as output_tf:
                try:
                    # Pipeline: normalize -> exclude -> tw
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest',
                        output_tf.name)
                    exclude_filter = ExcludeFilter(
                        tw,
                        patterns=['*__pycache__*'])
                    normalizer = TimestampNormalizer(
                        exclude_filter, timestamp=0)

                    # Process config then layer
                    normalizer.process_image_element(
                        constants.ImageElement(
                            constants.CONFIG_FILE,
                            config_name,
                            io.BytesIO(config_bytes)))
                    normalizer.process_image_element(
                        constants.ImageElement(
                            constants.IMAGE_LAYER,
                            original_sha,
                            open(layer_path, 'rb'),
                            layer_index=0))

                    normalizer.finalize()

                    # Read back the tarball
                    with tarfile.open(
                            output_tf.name,
                            'r') as out_tar:
                        manifest = json.loads(
                            out_tar.extractfile(
                                'manifest.json'
                            ).read())
                        config_path = (
                            manifest[0]['Config'])
                        updated_config = json.loads(
                            out_tar.extractfile(
                                config_path
                            ).read())

                        # Get the actual layer data
                        layer_path_in_tar = (
                            manifest[0]['Layers'][0])
                        layer_data = (
                            out_tar.extractfile(
                                layer_path_in_tar
                            ).read())

                    # Config should have updated
                    # diff_ids
                    updated_ids = (
                        updated_config['rootfs'][
                            'diff_ids'])
                    self.assertEqual(
                        1, len(updated_ids))

                    # Should NOT be the original
                    self.assertNotEqual(
                        'sha256:%s' % original_sha,
                        updated_ids[0])

                    # Verify the diff_id matches
                    # actual layer content
                    actual_sha = hashlib.sha256(
                        layer_data).hexdigest()
                    self.assertEqual(
                        'sha256:%s' % actual_sha,
                        updated_ids[0],
                        'Config diff_id does not '
                        'match actual layer hash')

                    # Verify config filename changed
                    self.assertNotEqual(
                        config_name, config_path)

                    # Verify layer has normalized
                    # timestamps and no __pycache__
                    with tarfile.open(
                            fileobj=io.BytesIO(
                                layer_data),
                            mode='r') as layer_tar:
                        names = []
                        for member in layer_tar:
                            names.append(member.name)
                            self.assertEqual(
                                0, member.mtime)
                        self.assertIn(
                            'app/main.py', names)
                        self.assertNotIn(
                            'app/__pycache__/'
                            'main.cpython-311.pyc',
                            names)

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)

    def test_combined_filters_check_passes(self):
        """Test that check verifies a tarball produced
        by chained filters without errors.

        Exercises the full pipeline: create tarball with
        combined filters, then verify via check logic.
        """
        from occystrap import check as check_mod
        from occystrap.inputs import tarfile as input_tarfile

        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/__pycache__/main.cpython-311.pyc':
                b'\x00\x00',
            'app/utils.py': 'def util(): pass',
        })

        try:
            # Compute original diff_id
            with open(layer_path, 'rb') as f:
                original_sha = hashlib.sha256(
                    f.read()).hexdigest()

            config = {
                'architecture': 'amd64',
                'os': 'linux',
                'rootfs': {
                    'type': 'layers',
                    'diff_ids': [
                        'sha256:%s' % original_sha
                    ],
                },
            }
            config_bytes = json.dumps(
                config).encode('utf-8')
            config_sha = hashlib.sha256(
                config_bytes).hexdigest()
            config_name = '%s.json' % config_sha

            with tempfile.NamedTemporaryFile(
                    delete=False,
                    suffix='.tar') as output_tf:
                tar_path = output_tf.name

            try:
                # Pipeline: normalize -> exclude -> tw
                tw = output_tarfile.TarWriter(
                    'test/image', 'latest', tar_path)
                exclude_filter = ExcludeFilter(
                    tw,
                    patterns=['*__pycache__*'])
                normalizer = TimestampNormalizer(
                    exclude_filter, timestamp=0)

                normalizer.process_image_element(
                    constants.ImageElement(
                        constants.CONFIG_FILE,
                        config_name,
                        io.BytesIO(config_bytes)))
                normalizer.process_image_element(
                    constants.ImageElement(
                        constants.IMAGE_LAYER,
                        original_sha,
                        open(layer_path, 'rb'),
                        layer_index=0))
                normalizer.finalize()

                # Now run check on the tarball
                input_source = input_tarfile.Image(
                    tar_path)
                config_out = input_source.get_config()
                results = check_mod.CheckResults()
                check_mod.check_metadata(
                    None, config_out, results)
                check_mod.check_layers(
                    input_source, None,
                    config_out, results)

                errors = [
                    r for r in results.results
                    if r['severity'] == 'error']
                self.assertEqual(
                    0, len(errors),
                    'Check found errors: %s' % errors)

            finally:
                if os.path.exists(tar_path):
                    os.unlink(tar_path)

        finally:
            os.unlink(layer_path)

    def test_config_forwarded_when_exclude_matches_nothing(
            self):
        """Test config is intact when exclude filter has
        no matching entries.

        This reproduces the bug where a chained filter
        that doesn't change the layer hash forwards the
        config with its BytesIO position at the end,
        causing TarWriter to write a 0-byte config entry.

        Pipeline: normalize -> exclude -> tarwriter
        The layer has no __pycache__, so exclude rewrites
        but doesn't remove anything. If the rewritten
        layer hash equals the input hash, the exclude
        filter's _forward_buffered_config takes the
        'no change' path and must seek(0) on the config
        data before forwarding.
        """
        from occystrap.inputs import (
            tarfile as input_tarfile)

        # Layer with NO __pycache__ — exclude won't
        # remove anything
        layer_path = self._create_layer_with_files({
            'app/main.py': 'print("hello")',
            'app/utils.py': 'def util(): pass',
        })

        try:
            with open(layer_path, 'rb') as f:
                original_sha = hashlib.sha256(
                    f.read()).hexdigest()

            config = {
                'architecture': 'amd64',
                'os': 'linux',
                'rootfs': {
                    'type': 'layers',
                    'diff_ids': [
                        'sha256:%s' % original_sha
                    ],
                },
            }
            config_bytes = json.dumps(
                config).encode('utf-8')
            config_sha = hashlib.sha256(
                config_bytes).hexdigest()
            config_name = '%s.json' % config_sha

            with tempfile.NamedTemporaryFile(
                    delete=False,
                    suffix='.tar') as output_tf:
                tar_path = output_tf.name

            try:
                tw = output_tarfile.TarWriter(
                    'test/image', 'latest', tar_path)
                exclude_filter = ExcludeFilter(
                    tw,
                    patterns=['*__pycache__*'])
                normalizer = TimestampNormalizer(
                    exclude_filter, timestamp=0)

                normalizer.process_image_element(
                    constants.ImageElement(
                        constants.CONFIG_FILE,
                        config_name,
                        io.BytesIO(config_bytes)))
                normalizer.process_image_element(
                    constants.ImageElement(
                        constants.IMAGE_LAYER,
                        original_sha,
                        open(layer_path, 'rb'),
                        layer_index=0))
                normalizer.finalize()

                # Read back and verify config is not
                # empty (this was the bug)
                input_source = input_tarfile.Image(
                    tar_path)
                config_out = input_source.get_config()
                self.assertIsNotNone(config_out)
                self.assertIn('rootfs', config_out)
                self.assertIn(
                    'diff_ids',
                    config_out['rootfs'])

            finally:
                if os.path.exists(tar_path):
                    os.unlink(tar_path)

        finally:
            os.unlink(layer_path)

    def test_search_does_not_modify_data(self):
        """Test that SearchFilter does not modify layer data."""
        layer_path = self._create_layer_with_files({
            'file1.txt': 'content1',
            'file2.txt': 'content2',
        })

        try:
            with open(layer_path, 'rb') as f:
                original_content = f.read()
            original_sha = hashlib.sha256(original_content).hexdigest()

            with tempfile.NamedTemporaryFile(delete=False, suffix='.tar') as \
                    output_tf:
                try:
                    tw = output_tarfile.TarWriter(
                        'test/image', 'latest', output_tf.name)
                    search_filter = SearchFilter(
                        tw,
                        pattern='*.txt',
                        image='test/image',
                        tag='latest')

                    with open(layer_path, 'rb') as f:
                        # Read original for comparison
                        search_filter.process_image_element(
                            constants.ImageElement(
                                constants.IMAGE_LAYER,
                                original_sha, f))

                    # Search should find both files
                    self.assertEqual(2, len(search_filter.results))

                finally:
                    if os.path.exists(output_tf.name):
                        os.unlink(output_tf.name)

        finally:
            os.unlink(layer_path)
