# 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 os
import shutil
import tempfile
import unittest
from unittest.mock import patch

import pyarrow as pa
import ray

from pypaimon import CatalogFactory, Schema


class RayIntegrationTest(unittest.TestCase):
    """Tests for the top-level read_paimon() / write_paimon() API."""

    @classmethod
    def setUpClass(cls):
        cls.tempdir = tempfile.mkdtemp()
        cls.warehouse = os.path.join(cls.tempdir, 'warehouse')
        cls.catalog_options = {'warehouse': cls.warehouse}

        catalog = CatalogFactory.create(cls.catalog_options)
        catalog.create_database('default', True)

        if not ray.is_initialized():
            ray.init(ignore_reinit_error=True, num_cpus=2)

    @classmethod
    def tearDownClass(cls):
        try:
            if ray.is_initialized():
                ray.shutdown()
        except Exception:
            pass
        try:
            shutil.rmtree(cls.tempdir)
        except OSError:
            pass

    def _create_and_populate_table(self, table_name, pa_schema, data_dict,
                                   primary_keys=None, partition_keys=None, options=None):
        """Helper to create a table and write a single batch of data."""
        identifier = 'default.{}'.format(table_name)
        schema = Schema.from_pyarrow_schema(
            pa_schema,
            primary_keys=primary_keys,
            partition_keys=partition_keys,
            options=options,
        )
        catalog = CatalogFactory.create(self.catalog_options)
        catalog.create_table(identifier, schema, False)
        table = catalog.get_table(identifier)

        test_data = pa.Table.from_pydict(data_dict, schema=pa_schema)
        write_builder = table.new_batch_write_builder()
        writer = write_builder.new_write()
        writer.write_arrow(test_data)
        commit_messages = writer.prepare_commit()
        commit = write_builder.new_commit()
        commit.commit(commit_messages)
        writer.close()

        return identifier

    def test_read_paimon_basic(self):
        """read_paimon() reads back the data we wrote."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('name', pa.string()),
            ('value', pa.int64()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_basic', pa_schema,
            {'id': [1, 2, 3], 'name': ['a', 'b', 'c'], 'value': [10, 20, 30]},
        )

        ds = read_paimon(identifier, self.catalog_options, override_num_blocks=1)
        self.assertEqual(ds.count(), 3)

        df = ds.to_pandas().sort_values('id').reset_index(drop=True)
        self.assertEqual(list(df['id']), [1, 2, 3])
        self.assertEqual(list(df['name']), ['a', 'b', 'c'])

    def test_read_paimon_with_projection(self):
        """read_paimon() respects column projection."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('name', pa.string()),
            ('value', pa.int64()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_proj', pa_schema,
            {'id': [1, 2], 'name': ['a', 'b'], 'value': [10, 20]},
        )

        ds = read_paimon(identifier, self.catalog_options, projection=['id', 'name'])
        df = ds.to_pandas()
        self.assertEqual(set(df.columns), {'id', 'name'})
        self.assertEqual(len(df), 2)

    def test_read_paimon_with_nested_projection(self):
        """read_paimon() respects a nested-leaf projection.

        Regression for the worker-side TableRead being rebuilt without
        nested_name_paths: a projection like ['payload.a'] used to read every
        nested leaf as NULL because the worker treated the flattened leaf name
        as a missing top-level column.
        """
        from pypaimon.ray import read_paimon

        inner = pa.struct([('a', pa.int64()), ('b', pa.string())])
        pa_schema = pa.schema([('id', pa.int32()), ('payload', inner)])
        identifier = self._create_and_populate_table(
            'test_read_nested_proj', pa_schema,
            {'id': [1, 2],
             'payload': [{'a': 10, 'b': 'x'}, {'a': 20, 'b': 'y'}]},
        )

        ds = read_paimon(identifier, self.catalog_options,
                         projection=['id', 'payload.a'])
        rows = {r['id']: r for r in ds.take_all()}
        self.assertEqual(set(rows.keys()), {1, 2})
        self.assertEqual(rows[1]['payload_a'], 10)
        self.assertEqual(rows[2]['payload_a'], 20)

    def test_read_paimon_with_filter(self):
        """read_paimon() pushes down a predicate filter."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('category', pa.string()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_filter', pa_schema,
            {'id': [1, 2, 3], 'category': ['A', 'B', 'A']},
        )

        catalog = CatalogFactory.create(self.catalog_options)
        table = catalog.get_table(identifier)
        pb = table.new_read_builder().new_predicate_builder()
        predicate = pb.equal('category', 'A')

        ds = read_paimon(identifier, self.catalog_options, filter=predicate)
        self.assertEqual(ds.count(), 2)
        df = ds.to_pandas()
        self.assertEqual(set(df['category'].tolist()), {'A'})

    def test_read_paimon_with_limit(self):
        """``read_paimon(limit=N)`` propagates the limit into the scan plan.

        Writes 10 rows across two partitions (5 + 5) so the scan produces two
        raw-convertible splits. ``limit=3`` causes ``FileScanner`` to drop the
        second split once the first already covers the limit, so the Ray
        Dataset contains strictly fewer than the full 10 rows.

        We assert ``< 10`` (not ``== N``) because Paimon's scan-time limit is
        a per-split cap — whole-split granularity at this layer — not a
        row-exact hard limit. Row-exact short-circuiting in the reader is a
        separate follow-up.
        """
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('part', pa.string()),
            ('value', pa.string()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_limit', pa_schema,
            {
                'id': list(range(10)),
                'part': ['a'] * 5 + ['b'] * 5,
                'value': [str(i) for i in range(10)],
            },
            partition_keys=['part'],
        )

        # Sanity baseline: the full unbounded scan returns all 10 rows.
        ds_full = read_paimon(identifier, self.catalog_options)
        self.assertEqual(ds_full.count(), 10)

        # With limit=3, the scan plan drops the second partition's split
        # once the first split's row count already covers the limit.
        ds = read_paimon(identifier, self.catalog_options, limit=3)
        limited_count = ds.count()
        self.assertGreater(limited_count, 0)
        self.assertLess(limited_count, 10)

    def test_read_paimon_empty_table(self):
        """read_paimon() on an empty table preserves the table schema."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('name', pa.string()),
        ])
        identifier = 'default.test_read_empty'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        ds = read_paimon(identifier, self.catalog_options)
        self.assertEqual(ds.count(), 0)
        self.assertEqual(ds.schema().names, ['id', 'name'])

    def test_read_paimon_empty_table_with_projection(self):
        """read_paimon() applies projection to empty table schemas."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('name', pa.string()),
            ('value', pa.int64()),
        ])
        identifier = 'default.test_read_empty_projection'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        ds = read_paimon(
            identifier, self.catalog_options, projection=['id', 'value']
        )
        self.assertEqual(ds.count(), 0)
        self.assertEqual(ds.schema().names, ['id', 'value'])

    def test_missing_ray_dependency_has_install_hint(self):
        """Ray facade surfaces an actionable install hint when Ray is absent."""
        from pypaimon.ray.ray_paimon import _require_ray_data

        error = ModuleNotFoundError("No module named 'ray'")
        error.name = 'ray'

        with patch('importlib.import_module', side_effect=error):
            with self.assertRaises(ImportError) as ctx:
                _require_ray_data()

        self.assertIn('pip install pypaimon[ray]', str(ctx.exception))

    def test_read_paimon_with_snapshot_id(self):
        """read_paimon(snapshot_id=N) time-travels to that snapshot."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
        identifier = 'default.test_read_snap_id'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)
        table = catalog.get_table(identifier)
        for batch in [{'id': [1], 'name': ['a']}, {'id': [2], 'name': ['b']}]:
            wb = table.new_batch_write_builder()
            writer = wb.new_write()
            writer.write_arrow(pa.Table.from_pydict(batch, schema=pa_schema))
            wb.new_commit().commit(writer.prepare_commit())
            writer.close()

        ds_latest = read_paimon(identifier, self.catalog_options)
        self.assertEqual(ds_latest.count(), 2)

        ds_snap1 = read_paimon(identifier, self.catalog_options, snapshot_id=1)
        self.assertEqual(ds_snap1.count(), 1)
        self.assertEqual(ds_snap1.to_pandas()['id'].tolist(), [1])

    def test_read_paimon_with_tag_name(self):
        """read_paimon(tag_name=...) time-travels to a tagged snapshot."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
        identifier = 'default.test_read_tag_name'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)
        table = catalog.get_table(identifier)
        wb = table.new_batch_write_builder()
        writer = wb.new_write()
        writer.write_arrow(pa.Table.from_pydict({'id': [1], 'name': ['a']}, schema=pa_schema))
        wb.new_commit().commit(writer.prepare_commit())
        writer.close()
        table.create_tag('v1')
        wb = table.new_batch_write_builder()
        writer = wb.new_write()
        writer.write_arrow(pa.Table.from_pydict({'id': [2], 'name': ['b']}, schema=pa_schema))
        wb.new_commit().commit(writer.prepare_commit())
        writer.close()

        ds_tag = read_paimon(identifier, self.catalog_options, tag_name='v1')
        self.assertEqual(ds_tag.count(), 1)
        self.assertEqual(ds_tag.to_pandas()['id'].tolist(), [1])

    def test_read_paimon_rejects_snapshot_id_and_tag_name_together(self):
        from pypaimon.ray import read_paimon

        with self.assertRaises(ValueError):
            read_paimon(
                'default.dummy', self.catalog_options,
                snapshot_id=1, tag_name='v1',
            )

    def test_write_paimon_basic(self):
        """write_paimon() writes data that read_paimon() can round-trip."""
        from pypaimon.ray import read_paimon, write_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('name', pa.string()),
        ])
        identifier = 'default.test_write_basic'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        source = pa.Table.from_pydict(
            {'id': [1, 2, 3], 'name': ['x', 'y', 'z']}, schema=pa_schema,
        )
        ds = ray.data.from_arrow(source)
        write_paimon(ds, identifier, self.catalog_options)

        result = read_paimon(identifier, self.catalog_options)
        self.assertEqual(result.count(), 3)
        df = result.to_pandas().sort_values('id').reset_index(drop=True)
        self.assertEqual(list(df['name']), ['x', 'y', 'z'])

    def test_write_paimon_overwrite(self):
        """write_paimon(overwrite=True) replaces existing data."""
        from pypaimon.ray import read_paimon, write_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('val', pa.int64()),
        ])
        identifier = 'default.test_write_overwrite'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        ds1 = ray.data.from_arrow(
            pa.Table.from_pydict({'id': [1, 2], 'val': [10, 20]}, schema=pa_schema)
        )
        write_paimon(ds1, identifier, self.catalog_options)

        ds2 = ray.data.from_arrow(
            pa.Table.from_pydict({'id': [3], 'val': [30]}, schema=pa_schema)
        )
        write_paimon(ds2, identifier, self.catalog_options, overwrite=True)

        result = read_paimon(identifier, self.catalog_options)
        self.assertEqual(result.count(), 1)
        df = result.to_pandas()
        self.assertEqual(list(df['id']), [3])

    def test_write_paimon_empty_overwrite_unpartitioned(self):
        """write_paimon(overwrite=True) with empty data clears an unpartitioned table."""
        from pypaimon.ray import read_paimon, write_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('val', pa.int64()),
        ])
        identifier = 'default.test_write_empty_overwrite_unpartitioned'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        initial = ray.data.from_arrow(
            pa.Table.from_pydict({'id': [1, 2], 'val': [10, 20]}, schema=pa_schema)
        )
        write_paimon(initial, identifier, self.catalog_options)
        self.assertEqual(read_paimon(identifier, self.catalog_options).count(), 2)

        empty = ray.data.from_arrow(
            pa.Table.from_pydict({'id': [], 'val': []}, schema=pa_schema)
        )
        write_paimon(empty, identifier, self.catalog_options, overwrite=True)

        result = read_paimon(identifier, self.catalog_options)
        self.assertEqual(result.count(), 0)

    def test_write_paimon_zero_block_overwrite_unpartitioned(self):
        """An overwrite with no Ray blocks clears an unpartitioned table."""
        from pypaimon.ray import read_paimon, write_paimon

        pa_schema = pa.schema([('id', pa.int64())])
        identifier = 'default.test_write_zero_block_overwrite_unpartitioned'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(pa_schema)
        catalog.create_table(identifier, schema, False)

        initial = ray.data.from_arrow(
            pa.Table.from_pydict({'id': [1, 2]}, schema=pa_schema)
        )
        write_paimon(initial, identifier, self.catalog_options)

        write_paimon(
            ray.data.range(0),
            identifier,
            self.catalog_options,
            overwrite=True,
        )

        result = read_paimon(identifier, self.catalog_options)
        self.assertEqual(result.count(), 0)

    def test_table_write_ray_builder_partition_overwrite(self):
        """Builder-level partition overwrite is honored by write_ray()."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            ('id', pa.int32()),
            ('val', pa.string()),
            ('dt', pa.string()),
        ])
        identifier = 'default.test_write_ray_partition_overwrite'
        catalog = CatalogFactory.create(self.catalog_options)
        schema = Schema.from_pyarrow_schema(
            pa_schema,
            partition_keys=['dt'],
            options={'dynamic-partition-overwrite': 'false'},
        )
        catalog.create_table(identifier, schema, False)
        table = catalog.get_table(identifier)

        initial = pa.Table.from_pydict(
            {
                'id': [1, 2, 3],
                'val': ['old-p1-a', 'old-p1-b', 'old-p2'],
                'dt': ['p1', 'p1', 'p2'],
            },
            schema=pa_schema,
        )
        write_builder = table.new_batch_write_builder()
        writer = write_builder.new_write()
        writer.write_arrow(initial)
        write_builder.new_commit().commit(writer.prepare_commit())
        writer.close()

        replacement = ray.data.from_arrow(
            pa.Table.from_pydict(
                {'id': [4], 'val': ['new-p1'], 'dt': ['p1']},
                schema=pa_schema,
            )
        )
        writer = table.new_batch_write_builder().overwrite({'dt': 'p1'}).new_write()
        writer.write_ray(replacement, concurrency=1)
        writer.close()

        result = read_paimon(identifier, self.catalog_options)
        df = result.to_pandas().sort_values('id').reset_index(drop=True)
        self.assertEqual(list(df['id']), [3, 4])
        self.assertEqual(list(df['val']), ['old-p2', 'new-p1'])
        self.assertEqual(list(df['dt']), ['p2', 'p1'])

    def test_read_paimon_primary_key(self):
        """read_paimon() merges PK rows correctly after an upsert."""
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            pa.field('id', pa.int32(), nullable=False),
            ('name', pa.string()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_pk', pa_schema,
            {'id': [1, 2, 3], 'name': ['a', 'b', 'c']},
            primary_keys=['id'],
            options={'bucket': '2'},
        )

        catalog = CatalogFactory.create(self.catalog_options)
        table = catalog.get_table(identifier)
        update = pa.Table.from_pydict({'id': [1, 4], 'name': ['a2', 'd']}, schema=pa_schema)
        wb = table.new_batch_write_builder()
        w = wb.new_write()
        w.write_arrow(update)
        msgs = w.prepare_commit()
        wb.new_commit().commit(msgs)
        w.close()

        ds = read_paimon(identifier, self.catalog_options)
        self.assertEqual(ds.count(), 4)
        df = ds.to_pandas().sort_values('id').reset_index(drop=True)
        self.assertEqual(list(df['name']), ['a2', 'b', 'c', 'd'])

    def test_read_paimon_invalid_override_num_blocks(self):
        """override_num_blocks below 1 is rejected with a clear error."""
        from pypaimon.ray import read_paimon

        with self.assertRaises(ValueError):
            read_paimon('default.does_not_matter', self.catalog_options,
                        override_num_blocks=0)

    def test_read_paimon_pk_single_snapshot(self):
        """read_paimon on a PK table with a single snapshot (raw-convertible
        splits) must not raise ArrowInvalid on schema nullability mismatch.

        The Paimon table schema marks PK columns as NOT NULL, but the
        Parquet reader may produce nullable fields. The RayDatasource
        read task must cast the batch to align the schema rather than
        rejecting it via strict from_batches equality.
        """
        from pypaimon.ray import read_paimon

        pa_schema = pa.schema([
            pa.field('id', pa.int32(), nullable=False),
            ('name', pa.string()),
        ])
        identifier = self._create_and_populate_table(
            'test_read_pk_single_snap', pa_schema,
            {'id': [1, 2, 3], 'name': ['a', 'b', 'c']},
            primary_keys=['id'], options={'bucket': '2'},
        )

        ds = read_paimon(identifier, self.catalog_options)
        self.assertEqual(ds.count(), 3)
        df = ds.to_pandas().sort_values('id').reset_index(drop=True)
        self.assertEqual(list(df['id']), [1, 2, 3])


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