################################################################################
#  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.
################################################################################

"""End-to-end coverage for ``with_limit`` after row-level pushdown.

Locks the contract: ``with_limit(N)`` returns at most ``N`` rows, and
the reader actually stops at that boundary instead of reading every
split / merge output to completion and trimming at the consumer.
"""

import os
import shutil
import tempfile
import unittest

import pyarrow as pa

from pypaimon import CatalogFactory, Schema


class LimitPushdownTest(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls.tempdir = tempfile.mkdtemp()
        cls.warehouse = os.path.join(cls.tempdir, 'warehouse')
        cls.catalog_options = {'warehouse': cls.warehouse}
        cls.catalog = CatalogFactory.create(cls.catalog_options)
        cls.catalog.create_database('default', False)

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(cls.tempdir, ignore_errors=True)

    @staticmethod
    def _ao_schema() -> pa.Schema:
        return pa.schema([
            pa.field('id', pa.int64(), nullable=False),
            ('val', pa.int64()),
            pa.field('dt', pa.string(), nullable=False),
        ])

    @staticmethod
    def _pk_schema() -> pa.Schema:
        return pa.schema([
            pa.field('id', pa.int64(), nullable=False),
            ('val', pa.int64()),
        ])

    def _create_ao_table(self, name: str):
        identifier = 'default.' + name
        schema = Schema.from_pyarrow_schema(
            self._ao_schema(),
            partition_keys=['dt'],
            options={'file.format': 'parquet'},
        )
        self.catalog.create_table(identifier, schema, False)
        return self.catalog.get_table(identifier)

    def _create_pk_table(self, name: str, *, num_buckets: int = 1):
        identifier = 'default.' + name
        schema = Schema.from_pyarrow_schema(
            self._pk_schema(),
            primary_keys=['id'],
            options={'bucket': str(num_buckets), 'file.format': 'parquet'},
        )
        self.catalog.create_table(identifier, schema, False)
        return self.catalog.get_table(identifier)

    def _write_ao_partitions(self, table, partitions):
        for dt, rows in partitions:
            wb = table.new_batch_write_builder()
            w = wb.new_write()
            data = pa.Table.from_pylist(
                [{'id': r, 'val': r * 10, 'dt': dt} for r in rows],
                schema=self._ao_schema())
            w.write_arrow(data)
            wb.new_commit().commit(w.prepare_commit())
            w.close()

    def _write_pk_snapshots(self, table, snapshots):
        for rows in snapshots:
            wb = table.new_batch_write_builder()
            w = wb.new_write()
            data = pa.Table.from_pylist(
                [{'id': i, 'val': v} for i, v in rows], schema=self._pk_schema())
            w.write_arrow(data)
            wb.new_commit().commit(w.prepare_commit())
            w.close()

    # ---- append-only -----------------------------------------------------

    def test_append_only_limit_stops_within_first_split(self):
        """With limit=3 on a partitioned append-only table, the result is
        exactly 3 rows — even though each partition split has 5 rows."""
        table = self._create_ao_table('limit_ao_within_split')
        self._write_ao_partitions(table, [
            ('p1', list(range(5))),  # 5 rows
            ('p2', list(range(5, 10))),  # 5 rows
        ])
        rb = table.new_read_builder().with_limit(3)
        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
        self.assertEqual(result.num_rows, 3)

    def test_append_only_limit_spans_multiple_splits(self):
        """Limit larger than first split: read carries over to the next
        split until the budget is met."""
        table = self._create_ao_table('limit_ao_span_splits')
        self._write_ao_partitions(table, [
            ('p1', [1, 2]),
            ('p2', [3, 4]),
            ('p3', [5, 6]),
        ])
        rb = table.new_read_builder().with_limit(5)
        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
        self.assertEqual(result.num_rows, 5)

    def test_append_only_limit_zero_returns_empty(self):
        table = self._create_ao_table('limit_ao_zero')
        self._write_ao_partitions(table, [('p1', [1, 2, 3])])
        rb = table.new_read_builder().with_limit(0)
        splits = rb.new_scan().plan().splits()
        result = rb.new_read().to_arrow(splits)
        self.assertEqual(result.num_rows, 0)

    def test_append_only_limit_larger_than_total(self):
        """Limit greater than the total returns the total, not the limit."""
        table = self._create_ao_table('limit_ao_oversize')
        self._write_ao_partitions(table, [('p1', [1, 2, 3])])
        rb = table.new_read_builder().with_limit(100)
        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
        self.assertEqual(result.num_rows, 3)

    # ---- PK merge-on-read ------------------------------------------------

    def test_pk_merge_limit_stops_within_first_split(self):
        """PK + multiple snapshots forces the merge-read path. The reader
        must stop at limit rows instead of running every section to
        completion and trimming at the consumer."""
        table = self._create_pk_table('limit_pk_within_split')
        # Two snapshots over the same key range → merge path; total
        # post-merge unique rows = 20.
        self._write_pk_snapshots(table, [
            [(i, i) for i in range(20)],
            [(i, i + 1000) for i in range(0, 20, 2)],
        ])
        for limit in (1, 5, 10, 19):
            rb = table.new_read_builder().with_limit(limit)
            result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
            self.assertEqual(
                result.num_rows, limit,
                "with_limit(%d) must short-circuit at the row level" % limit)

    def test_pk_merge_limit_equals_total(self):
        """Limit equal to total post-merge row count: returns everything."""
        table = self._create_pk_table('limit_pk_equals_total')
        self._write_pk_snapshots(table, [
            [(i, i) for i in range(10)],
            [(i, i + 100) for i in range(5)],
        ])
        rb = table.new_read_builder().with_limit(10)
        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
        self.assertEqual(result.num_rows, 10)

    def test_pk_merge_limit_with_predicate(self):
        """``with_limit`` plus ``with_filter``: the filter prunes first and
        the limit caps what survives. ``val >= 1000`` matches the latest
        write of the even ``id`` rows; limit then takes the prefix."""
        table = self._create_pk_table('limit_pk_with_filter')
        self._write_pk_snapshots(table, [
            [(i, i) for i in range(20)],
            [(i, i + 1000) for i in range(0, 20, 2)],  # update evens
        ])
        rb = table.new_read_builder()
        pred = rb.new_predicate_builder().greater_or_equal('val', 1000)
        rb = rb.with_filter(pred).with_limit(3)
        result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
        self.assertEqual(result.num_rows, 3)
        for v in result.column('val').to_pylist():
            self.assertGreaterEqual(v, 1000)

    # ---- to_iterator path ------------------------------------------------

    def test_to_iterator_limit_short_circuits(self):
        table = self._create_ao_table('limit_iter')
        self._write_ao_partitions(table, [
            ('p1', list(range(50))),
            ('p2', list(range(50, 100))),
        ])
        rb = table.new_read_builder().with_limit(7)
        it = rb.new_read().to_iterator(rb.new_scan().plan().splits())
        rows = list(it)
        self.assertEqual(len(rows), 7)

    # ---- SplitRead-level limit pushdown verification ---------------------

    def test_append_only_split_read_creates_limited_batch_reader(self):
        """Verify that RawFileSplitRead.create_reader() returns a
        LimitedRecordBatchReader (inherits RecordBatchReader) when limit
        is set, so the arrow-batch read path is taken."""
        from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
        from pypaimon.read.reader.limited_record_reader import LimitedRecordBatchReader

        table = self._create_ao_table('limit_ao_split_read')
        self._write_ao_partitions(table, [('p1', list(range(10)))])
        rb = table.new_read_builder().with_limit(3)
        table_read = rb.new_read()
        splits = rb.new_scan().plan().splits()
        self.assertGreater(len(splits), 0)
        for split in splits:
            split_read = table_read._create_split_read(split)
            self.assertEqual(split_read.limit, 3)
            reader = split_read.create_reader()
            self.assertIsInstance(reader, LimitedRecordBatchReader,
                                  "RawFileSplitRead.create_reader() should wrap with LimitedRecordBatchReader")
            self.assertIsInstance(reader, RecordBatchReader,
                                  "LimitedRecordBatchReader should be a RecordBatchReader")
            reader.close()

    def test_append_only_split_read_limit_truncates_within_split(self):
        """Directly read from a single split's reader with limit and verify
        the reader itself stops at the limit boundary, not relying on
        TableRead-level truncation."""
        table = self._create_ao_table('limit_ao_split_truncate')
        self._write_ao_partitions(table, [('p1', list(range(20)))])
        rb = table.new_read_builder().with_limit(5)
        table_read = rb.new_read()
        splits = rb.new_scan().plan().splits()
        self.assertEqual(len(splits), 1)
        split_read = table_read._create_split_read(splits[0])
        reader = split_read.create_reader()
        # Drain the reader directly, bypassing TableRead-level control
        total_rows = 0
        while True:
            batch = reader.read_arrow_batch()
            if batch is None:
                break
            total_rows += batch.num_rows
        reader.close()
        self.assertEqual(total_rows, 5,
                         "SplitRead-level reader should stop at limit=5, got %d" % total_rows)


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