################################################################################
#  Licensed to the Apache Software Foundation (ASF) under one
#  or more contributor license agreements.  See the NOTICE file
#  distributed with this work for additional information
#  regarding copyright ownership.  The ASF licenses this file
#  to you under the Apache License, Version 2.0 (the
#  "License"); you may not use this file except in compliance
#  with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
# limitations under the License.
################################################################################

import datetime
import tempfile
import unittest
from decimal import Decimal
from unittest.mock import Mock, patch

import pyarrow as pa

from pypaimon import CatalogFactory, Schema
from pypaimon.index.dynamic_bucket import (
    HashBucketAssigner,
    _PartitionIndex,
    _iter_hashes,
    compute_assigner,
    to_signed_int32,
)
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor


class DynamicBucketTest(unittest.TestCase):

    @staticmethod
    def _create_table(
        root, name, target_row_num=100, max_buckets=None
    ):
        catalog = CatalogFactory.create({'warehouse': root})
        catalog.create_database('default', True)
        options = {
            'bucket': '-1',
            'dynamic-bucket.target-row-num': str(target_row_num),
            'file.format': 'parquet',
        }
        if max_buckets is not None:
            options['dynamic-bucket.max-buckets'] = str(max_buckets)
        schema = Schema.from_pyarrow_schema(
            pa.schema([
                pa.field('id', pa.int64()),
                pa.field('value', pa.string()),
            ]),
            primary_keys=['id'],
            options=options,
        )
        catalog.create_table(f'default.{name}', schema, False)
        return catalog.get_table(f'default.{name}')

    @staticmethod
    def _prepare_indexed_write(table, ids):
        builder = table.new_batch_write_builder()
        writer = builder.new_write().with_dynamic_bucket_index()
        writer.write_arrow_batch(pa.RecordBatch.from_pydict({
            'id': ids,
            'value': [f'v-{value}' for value in ids],
        }))
        return writer, builder.new_commit(), writer.prepare_commit()

    @staticmethod
    def _hash_indexes(table):
        snapshot = table.snapshot_manager().get_latest_snapshot()
        return [
            entry for entry in IndexFileHandler(table).scan(snapshot)
            if entry.index_file.index_type == 'HASH'
        ]

    @staticmethod
    def _commit_arrow(table, ids, values):
        builder = table.new_batch_write_builder()
        writer = builder.new_write()
        writer.write_arrow(pa.table({'id': ids, 'value': values}))
        messages = writer.prepare_commit()
        commit = builder.new_commit()
        commit.commit(messages)
        writer.close()
        commit.close()
        return messages

    @staticmethod
    def _read_arrow(table):
        builder = table.new_read_builder()
        return builder.new_read().to_arrow(builder.new_scan().plan().splits())

    def test_compute_assigner_matches_java(self):
        max_int = 2 ** 31 - 1
        self.assertEqual(compute_assigner(max_int, 0, 5, 5), 2)
        self.assertEqual(compute_assigner(max_int, 1, 5, 5), 3)
        self.assertEqual(compute_assigner(max_int, 2, 5, 5), 4)
        self.assertEqual(compute_assigner(max_int, 3, 5, 5), 0)
        self.assertEqual(compute_assigner(2, 0, 5, 3), 2)
        self.assertEqual(compute_assigner(2, 1, 5, 3), 3)
        self.assertEqual(compute_assigner(2, 2, 5, 3), 4)
        self.assertEqual(compute_assigner(2, 3, 5, 3), 2)
        self.assertEqual(compute_assigner(3, 1, 5, 1), 3)
        self.assertEqual(compute_assigner(3, 2, 5, 1), 3)
        min_int = -(2 ** 31)
        self.assertEqual(compute_assigner(min_int, 0, 5, 5), 3)
        self.assertEqual(compute_assigner(2, min_int, 5, 5), 0)

    def test_binary_row_hash_matches_java_for_bucket_key_types(self):
        # Generated with Java InternalRowSerializer.toBinaryRow(...).hashCode().
        cases = [
            (
                'inline string',
                ('hello',),
                [DataField(0, 'key', AtomicType('STRING'))],
                243722546,
            ),
            (
                'variable string',
                ('hello-java',),
                [DataField(0, 'key', AtomicType('STRING'))],
                -201703277,
            ),
            (
                'composite bucket key',
                ('hello-java', 42),
                [
                    DataField(0, 'key1', AtomicType('STRING')),
                    DataField(1, 'key2', AtomicType('BIGINT')),
                ],
                -2066620165,
            ),
            (
                'compact decimal',
                (Decimal('12345.67'),),
                [DataField(0, 'key', AtomicType('DECIMAL(10, 2)'))],
                754928256,
            ),
            (
                'variable decimal',
                (Decimal('12345678901234567890123.45'),),
                [DataField(0, 'key', AtomicType('DECIMAL(25, 2)'))],
                1388205002,
            ),
            (
                'compact timestamp',
                (datetime.datetime(2026, 1, 2, 3, 4, 5, 123000),),
                [DataField(0, 'key', AtomicType('TIMESTAMP(3)'))],
                -1766746798,
            ),
            (
                'variable timestamp',
                (datetime.datetime(2026, 1, 2, 3, 4, 5, 123456),),
                [DataField(0, 'key', AtomicType('TIMESTAMP(6)'))],
                1245041971,
            ),
            (
                'inline binary',
                (bytes([0, 1, 255, 127]),),
                [DataField(0, 'key', AtomicType('BYTES'))],
                586821318,
            ),
            (
                'variable binary',
                (bytes(range(10)),),
                [DataField(0, 'key', AtomicType('BYTES'))],
                1822312655,
            ),
        ]

        for name, values, fields, java_hash in cases:
            with self.subTest(name=name):
                actual = DynamicBucketRowKeyExtractor._binary_row_hash_code(
                    values, fields
                )
                self.assertEqual(java_hash, to_signed_int32(actual))

    def test_unbounded_bucket_id_matches_java_short_limit(self):
        index = _PartitionIndex({}, {}, 1)

        bucket = index.assign(
            key_hash=1,
            bucket_filter=lambda candidate: candidate == 32766,
            max_buckets_num=-1,
            max_bucket_id=32765,
        )

        self.assertEqual(bucket, 32766)

    def test_assigner_rejects_record_owned_by_another_writer(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'wrong_assigner')
            assigner = HashBucketAssigner(
                table=table,
                num_channels=2,
                num_assigners=2,
                assign_id=0,
                target_bucket_row_number=100,
                max_buckets_num=-1,
            )

            with self.assertRaisesRegex(
                ValueError, 'Record assigner 1 does not match writer assigner 0'
            ):
                assigner.assign((), partition_hash=0, key_hash=1)

    def test_max_buckets_rejects_assigner_without_bucket(self):
        index = _PartitionIndex({}, {}, 1)

        with self.assertRaisesRegex(
            RuntimeError, 'No dynamic bucket is available for this assigner'
        ):
            index.assign(
                key_hash=1,
                bucket_filter=lambda _: False,
                max_buckets_num=1,
                max_bucket_id=0,
            )

    def test_unbounded_buckets_rejects_java_short_id_exhaustion(self):
        index = _PartitionIndex({}, {}, 1)

        with self.assertRaisesRegex(
            RuntimeError, 'No dynamic bucket id remains below Java Short.MAX_VALUE'
        ):
            index.assign(
                key_hash=1,
                bucket_filter=lambda _: False,
                max_buckets_num=-1,
                max_bucket_id=32766,
            )

    def test_corrupt_hash_index_rejects_trailing_bytes(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'corrupt_index')
            path = f'{root}/corrupt-hash-index'
            with table.file_io.new_output_stream(path) as stream:
                stream.write(b'\x00\x00\x00')
            entry = IndexManifestEntry(
                kind=0,
                partition=GenericRow([], []),
                bucket=0,
                index_file=IndexFileMeta(
                    index_type='HASH',
                    file_name='corrupt-hash-index',
                    file_size=3,
                    row_count=1,
                    external_path=path,
                ),
            )

            with self.assertRaisesRegex(
                RuntimeError, 'expected a multiple of 4 bytes'
            ):
                list(_iter_hashes(table, entry))

    def test_regular_dynamic_writer_uses_persistent_index(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'regular_dynamic')
            writer = table.new_batch_write_builder().new_write()

            self.assertIs(table, writer.row_key_extractor._table)
            writer.write_arrow_batch(pa.RecordBatch.from_pydict({
                'id': [1],
                'value': ['v-1'],
            }))
            messages = writer.prepare_commit()

            self.assertTrue(any(message.index_adds for message in messages))
            self.assertFalse(any(message.index_deletes for message in messages))

    def test_regular_dynamic_writer_restores_mapping_across_commits(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'regular_upsert', target_row_num=1)
            self._commit_arrow(table, [1], ['old'])
            self._commit_arrow(table, [2, 1], ['other', 'new'])

            result = self._read_arrow(table).sort_by('id').to_pydict()
            self.assertEqual({'id': [1, 2], 'value': ['new', 'other']}, result)

    def test_regular_dynamic_writer_retains_only_requested_index_hashes(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'bounded_restore')
            self._commit_arrow(
                table, list(range(10)), [f'old-{value}' for value in range(10)]
            )

            writer = table.new_batch_write_builder().new_write()
            writer.write_arrow(pa.table({'id': [3], 'value': ['new-3']}))

            partition_index = writer.row_key_extractor._assigner._partition_indexes[()]
            self.assertEqual(1, len(partition_index.hash_to_bucket))
            self.assertEqual({}, writer.row_key_extractor._index_maintainer._states)

    def test_legacy_dynamic_data_without_hash_index_fails_fast(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'legacy_no_index')
            builder = table.new_batch_write_builder()
            writer = builder.new_write()
            writer.row_key_extractor = DynamicBucketRowKeyExtractor(
                table.table_schema
            )
            writer.write_arrow(pa.table({'id': [1], 'value': ['old']}))
            messages = writer.prepare_commit()
            builder.new_commit().commit(messages)
            writer.close()

            new_writer = table.new_batch_write_builder().new_write()
            with self.assertRaisesRegex(
                RuntimeError, 'has data files but no complete HASH index'
            ):
                new_writer.write_arrow(pa.table({'id': [1], 'value': ['new']}))

    def test_cross_partition_write_requires_global_index(self):
        with tempfile.TemporaryDirectory() as root:
            catalog = CatalogFactory.create({'warehouse': root})
            catalog.create_database('default', True)
            schema = Schema.from_pyarrow_schema(
                pa.schema([
                    pa.field('id', pa.int64()),
                    pa.field('value', pa.string()),
                    pa.field('dt', pa.string()),
                ]),
                partition_keys=['dt'],
                primary_keys=['id'],
                options={'bucket': '-1'},
            )
            catalog.create_table('default.cross_partition', schema, False)
            table = catalog.get_table('default.cross_partition')

            with self.assertRaisesRegex(
                ValueError, 'CROSS_PARTITION.*global primary-key index'
            ):
                table.new_batch_write_builder().new_write()

    def test_batch_writer_abort_after_prepare_deletes_hash_index(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'abort_prepared')
            writer = table.new_batch_write_builder().new_write()
            writer.write_arrow(pa.table({'id': [1], 'value': ['v-1']}))
            messages = writer.prepare_commit()
            index_path = messages[0].index_adds[0].index_file.external_path
            if index_path is None:
                index_path = (
                    table.path_factory().global_index_path_factory()
                    .to_path(messages[0].index_adds[0].index_file.file_name)
                )
            self.assertTrue(table.file_io.exists(index_path))

            writer.abort()

            self.assertFalse(table.file_io.exists(index_path))

    def test_stream_writer_releases_prepared_hash_index_ownership(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'stream_prepared')
            builder = table.new_stream_write_builder()
            writer = builder.new_write()
            commit = builder.new_commit()
            writer.write_arrow(pa.table({'id': [1], 'value': ['v-1']}))
            messages = writer.prepare_commit(1)
            index_path = messages[0].index_adds[0].index_file.external_path
            if index_path is None:
                index_path = (
                    table.path_factory().global_index_path_factory()
                    .to_path(messages[0].index_adds[0].index_file.file_name)
                )
            self.assertEqual(
                [], writer.row_key_extractor._index_maintainer._new_paths
            )

            commit.commit(messages, 1)
            writer.close()

            self.assertTrue(table.file_io.exists(index_path))
            self.assertEqual(
                {'id': [1], 'value': ['v-1']},
                self._read_arrow(table).to_pydict(),
            )
            commit.close()

    def test_regular_dynamic_extractor_skips_partition_hash(self):
        with tempfile.TemporaryDirectory() as root:
            catalog = CatalogFactory.create({'warehouse': root})
            catalog.create_database('default', True)
            schema = Schema.from_pyarrow_schema(
                pa.schema([
                    pa.field('id', pa.int64()),
                    pa.field('value', pa.string()),
                    pa.field('dt', pa.string()),
                ]),
                partition_keys=['dt'],
                primary_keys=['id', 'dt'],
                options={
                    'bucket': '-1',
                    'dynamic-bucket.target-row-num': '100',
                },
            )
            catalog.create_table('default.partitioned', schema, False)
            table = catalog.get_table('default.partitioned')
            extractor = DynamicBucketRowKeyExtractor(table.table_schema)
            hash_code = Mock(wraps=extractor._binary_row_hash_code)
            extractor._binary_row_hash_code = hash_code

            extractor.extract_partition_bucket_batch(
                pa.RecordBatch.from_pydict({
                    'id': [1, 2],
                    'value': ['a', 'b'],
                    'dt': ['p', 'p'],
                })
            )

            self.assertEqual(2, hash_code.call_count)
            self.assertNotIn(
                ('p',),
                [call.args[0] for call in hash_code.call_args_list],
            )

    def test_concurrent_initial_hash_index_add_conflicts(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'concurrent_initial')
            writer1, commit1, messages1 = self._prepare_indexed_write(
                table, [1]
            )
            writer2, commit2, messages2 = self._prepare_indexed_write(
                table, [2]
            )

            commit1.commit(messages1)
            stale_data_paths = [
                file.file_path for message in messages2 for file in message.new_files
            ]
            stale_index_paths = [
                entry.index_file.external_path
                or table.path_factory().global_index_path_factory().to_path(
                    entry.index_file.file_name
                )
                for message in messages2 for entry in message.index_adds
            ]
            with self.assertRaisesRegex(
                RuntimeError, 'HASH index assignment conflict'
            ):
                commit2.commit(messages2)

            self.assertTrue(all(
                not table.file_io.exists(path)
                for path in stale_data_paths + stale_index_paths
            ))

            indexes = self._hash_indexes(table)
            self.assertEqual(1, len(indexes))
            self.assertEqual(1, indexes[0].index_file.row_count)
            writer1.close()
            writer2.close()
            commit1.close()
            commit2.close()

    def test_concurrent_hash_index_replacement_conflicts(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'concurrent_replace')
            seed_writer, seed_commit, seed_messages = (
                self._prepare_indexed_write(table, [1])
            )
            seed_commit.commit(seed_messages)
            seed_writer.close()
            seed_commit.close()

            writer1, commit1, messages1 = self._prepare_indexed_write(
                table, [2]
            )
            writer2, commit2, messages2 = self._prepare_indexed_write(
                table, [3]
            )
            old_index = messages1[0].index_deletes[0].index_file.file_name
            self.assertEqual(
                old_index,
                messages2[0].index_deletes[0].index_file.file_name,
            )

            commit1.commit(messages1)
            with self.assertRaisesRegex(
                RuntimeError, 'HASH index assignment conflict'
            ):
                commit2.commit(messages2)

            indexes = self._hash_indexes(table)
            self.assertEqual(1, len(indexes))
            self.assertEqual(2, indexes[0].index_file.row_count)
            writer1.close()
            writer2.close()
            commit1.close()
            commit2.close()

    def test_concurrent_disjoint_bucket_replacements_conflict(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(
                root,
                'concurrent_disjoint',
                target_row_num=1,
                max_buckets=2,
            )
            seed_writer, seed_commit, seed_messages = (
                self._prepare_indexed_write(table, [1, 2])
            )
            seed_commit.commit(seed_messages)
            seed_writer.close()
            seed_commit.close()

            with patch(
                'pypaimon.index.dynamic_bucket.random.choice',
                return_value=0,
            ):
                writer1, commit1, messages1 = self._prepare_indexed_write(
                    table, [3]
                )
            with patch(
                'pypaimon.index.dynamic_bucket.random.choice',
                return_value=1,
            ):
                writer2, commit2, messages2 = self._prepare_indexed_write(
                    table, [3]
                )

            self.assertNotEqual(messages1[0].bucket, messages2[0].bucket)
            commit1.commit(messages1)
            stale_paths = [
                file.file_path
                for message in messages2
                for file in message.new_files
            ] + [
                entry.index_file.external_path
                or table.path_factory().global_index_path_factory().to_path(
                    entry.index_file.file_name
                )
                for message in messages2
                for entry in message.index_adds
            ]

            with self.assertRaisesRegex(
                RuntimeError, 'assigned from snapshot.*latest snapshot'
            ):
                commit2.commit(messages2)

            self.assertTrue(all(
                not table.file_io.exists(path) for path in stale_paths
            ))
            writer1.close()
            writer2.close()
            commit1.close()
            commit2.close()

    def test_data_only_upsert_conflicts_after_overwrite_remaps_key(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(
                root, 'data_only_overwrite_conflict', target_row_num=1
            )
            self._commit_arrow(table, [1, 2], ['one', 'two'])

            stale_builder = table.new_batch_write_builder()
            stale_writer = stale_builder.new_write()
            stale_writer.write_arrow(
                pa.table({'id': [2], 'value': ['stale-upsert']})
            )
            stale_messages = stale_writer.prepare_commit()
            stale_commit = stale_builder.new_commit()
            self.assertFalse(any(
                message.index_adds or message.index_deletes
                for message in stale_messages
            ))

            overwrite_builder = table.new_batch_write_builder().overwrite({})
            overwrite_writer = overwrite_builder.new_write()
            overwrite_writer.write_arrow(
                pa.table({'id': [2], 'value': ['overwrite']})
            )
            overwrite_messages = overwrite_writer.prepare_commit()
            overwrite_commit = overwrite_builder.new_commit()
            overwrite_commit.commit(overwrite_messages)
            overwrite_writer.close()
            overwrite_commit.close()

            with self.assertRaisesRegex(
                RuntimeError, 'HASH index assignment conflict'
            ):
                stale_commit.commit(stale_messages)

            self.assertEqual(
                {'id': [2], 'value': ['overwrite']},
                self._read_arrow(table).to_pydict(),
            )
            stale_writer.close()
            stale_commit.close()

    def test_retry_then_hash_index_conflict_preserves_prepared_files(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'retry_hash_conflict')
            writer, commit, messages = self._prepare_indexed_write(table, [1])
            prepared_paths = [
                file.file_path
                for message in messages
                for file in message.new_files
            ] + [
                entry.index_file.external_path
                or table.path_factory().global_index_path_factory().to_path(
                    entry.index_file.file_name
                )
                for message in messages
                for entry in message.index_adds
            ]
            original_snapshot_commit = commit.file_store_commit.snapshot_commit
            calls = 0

            def lose_first_compare_and_set(
                    base_snapshot_uuid, snapshot, statistics):
                nonlocal calls
                calls += 1
                concurrent_writer, concurrent_commit, concurrent_messages = (
                    self._prepare_indexed_write(table, [2])
                )
                concurrent_commit.commit(concurrent_messages)
                concurrent_writer.close()
                concurrent_commit.close()
                return False

            with patch.object(
                original_snapshot_commit,
                'commit',
                side_effect=lose_first_compare_and_set,
            ), patch.object(
                commit.file_store_commit,
                '_commit_retry_wait',
            ):
                with self.assertRaisesRegex(
                    RuntimeError, 'HASH index assignment conflict'
                ):
                    commit.commit(messages)

            self.assertEqual(1, calls)
            self.assertTrue(all(
                table.file_io.exists(path) for path in prepared_paths
            ))
            writer.close()
            commit.close()

    def test_invalid_dynamic_bucket_key_reports_schema_error(self):
        with tempfile.TemporaryDirectory() as root:
            table = self._create_table(root, 'invalid_bucket_key')
            options = dict(table.table_schema.options)
            options['bucket-key'] = 'missing_column'
            invalid_schema = table.table_schema.copy(options)

            with self.assertRaisesRegex(
                ValueError, 'bucket-key references unknown columns'
            ):
                DynamicBucketRowKeyExtractor(invalid_schema)


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