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

from typing import List, Union

import pyarrow as pa
import pyarrow.compute as pc

from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.read.reader.deduplicate_merge_function import \
    DeduplicateMergeFunction
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.table.row.key_value import KeyValue
from pypaimon.write.writer.data_writer import DataWriter


class KeyValueDataWriter(DataWriter):
    """Data writer for primary key tables with system fields and sorting.

    Accumulates incoming batches in ``pending_data`` without sorting or
    folding on the write path. Sort and ``MergeFunction``-based fold
    are deferred to flush time (``_flush_all``), where the result is
    roll-written into one or more data files. This enforces the LSM
    "PK unique within a file" invariant the read-side
    ``raw_convertible`` fast path relies on, while keeping per-write
    cost bounded.
    """

    def __init__(self, table, partition, bucket, max_seq_number,
                 options=None, write_cols=None, merge_function=None,
                 changelog_producer=ChangelogProducer.NONE):
        super().__init__(table, partition, bucket, max_seq_number,
                         options, write_cols, changelog_producer)
        # Defaults to deduplicate so direct callers (tests / future code
        # paths that don't go through FileStoreWrite) don't accidentally
        # skip the merge step entirely.
        self._merge_function = merge_function or DeduplicateMergeFunction()

    def _process_data(self, data: pa.RecordBatch) -> pa.Table:
        # No sort here: sorting once at flush is strictly cheaper than
        # per-batch sort + a final global sort. ``pending_data`` ends
        # up as a concat of unsorted batches; ``_flush_all`` sorts it
        # exactly once before folding.
        enhanced_data = self._add_system_fields(data)
        return pa.Table.from_batches([enhanced_data])

    def _merge_data(self, existing_data: pa.Table, new_data: pa.Table) -> pa.Table:
        # Plain concat. Sort + fold both run inside ``_flush_all`` so
        # N writes incur 1 sort instead of N sorts.
        return pa.concat_tables([existing_data, new_data])

    def prepare_commit(self) -> List[DataFileMeta]:
        if self.pending_data is not None and self.pending_data.num_rows > 0:
            self._flush_all()
        # ``_flush_all`` leaves ``pending_data = None``, so super's
        # prepare_commit just returns ``committed_files``.
        return super().prepare_commit()

    def _check_and_roll_if_needed(self):
        # Buffer overflowed target_file_size: sort + fold + roll-write
        # the whole buffer as multiple files in one pass. Unlike the
        # base class's slice loop, we never keep a slice remainder in
        # ``pending_data`` -- flush empties the buffer outright.
        if (self.pending_data is not None
                and self.pending_data.num_rows > 0
                and self.pending_data.nbytes > self.target_file_size):
            self._flush_all()

    def close(self):
        # Override the base ``close`` because its straight
        # ``_write_data_to_file(pending_data)`` would land an unsorted,
        # un-folded buffer on disk -- violating the file-internal
        # PK-unique invariant. Route the final flush through
        # ``_flush_all`` so the contract holds even on the
        # close-without-prepare_commit path.
        try:
            if self.pending_data is not None and self.pending_data.num_rows > 0:
                self._flush_all()
        except Exception as e:
            import logging
            logger = logging.getLogger(__name__)
            logger.warning(
                "Exception occurs when closing writer. Cleaning up.",
                exc_info=e)
            self.abort()
            raise e
        finally:
            self.pending_data = None

    def _flush_all(self) -> None:
        """Sort + fold the entire buffer, then roll-write as files.

        On return, ``pending_data is None`` and every flushed chunk
        has been recorded in ``committed_files``. The buffer is
        always fully drained per flush: no slice remainder is
        carried back into ``pending_data``.
        """
        if self.pending_data is None or self.pending_data.num_rows == 0:
            self.pending_data = None
            return
        sorted_data = self._sort_by_primary_key(self.pending_data)
        folded = self._merge_pending_by_pk(sorted_data)
        self.pending_data = None
        if folded.num_rows == 0:
            return
        self._roll_write(folded)

    def _roll_write(self, data: pa.Table) -> None:
        """Write ``data`` as one or more files, each <= target_file_size.

        ``data`` is required to be PK-unique (the fold guarantees
        that), so any slice of it is also PK-unique -- splitting for
        size does not violate the LSM file-internal invariant.
        Reuses ``_find_optimal_split_point`` / ``_write_data_to_file``
        from the base class.
        """
        while data.num_rows > 0:
            if data.nbytes <= self.target_file_size:
                self._write_data_to_file(data)
                return
            split_row = self._find_optimal_split_point(
                data, self.target_file_size)
            if split_row <= 0:
                # Single row already exceeds target_file_size; nothing
                # to gain from further slicing, write it as-is.
                self._write_data_to_file(data)
                return
            self._write_data_to_file(data.slice(0, split_row))
            data = data.slice(split_row)

    def _merge_pending_by_pk(self, data: pa.Table) -> pa.Table:
        """Fold same-PK runs in ``data`` using ``self._merge_function``.

        ``data`` is required to already be sorted by
        ``(primary_key, _SEQUENCE_NUMBER)``. ``_flush_all`` is the
        only caller and runs ``_sort_by_primary_key`` immediately
        before this method, so the precondition holds.

        NOTE(follow-up): the merge runs row-by-row over
        ``data.to_pydict()`` / ``pa.Table.from_pydict``. Arrow types
        with non-trivial Python representations (Decimal128 with
        specific precision/scale, timestamps with timezone or
        sub-millisecond units, durations, deeply nested structs) can
        drift across this round-trip. A columnar merge implementation
        would close the gap and is tracked as a follow-up; until
        then, partial-update on those types should be avoided in
        pypaimon.
        """
        n = data.num_rows
        if n < 2:
            # Single-row buffer cannot have duplicates; sidestep the
            # row-by-row pyarrow round-trip in the common streaming case.
            return data

        col_names = data.schema.names
        # ``to_pydict`` works on pyarrow >= 6 (Python 3.6 CI ships 6.0.1),
        # unlike ``to_pylist`` which only landed in pyarrow 7.
        col_dict = data.to_pydict()
        rows = [{name: col_dict[name][i] for name in col_names}
                for i in range(n)]
        key_arity = len(self.trimmed_primary_keys)
        # System fields sit at indices [key_arity, key_arity + 1] (the
        # _SEQUENCE_NUMBER and _VALUE_KIND columns added by
        # _add_system_fields). Everything to the right is the value side.
        value_arity = len(col_names) - key_arity - 2

        # Pool one ``KeyValue`` for the whole fold. Safe because:
        # - ``DeduplicateMergeFunction.add`` stores the kv reference; the
        #   reused instance always carries the most recent ``replace``,
        #   which is exactly the "latest wins" the engine wants.
        # - ``PartialUpdateMergeFunction.add`` also stores a reference,
        #   but ``get_result`` snapshots key + sequence into a fresh
        #   tuple before returning, so the consumed result is decoupled
        #   from any later ``replace`` on the pooled kv.
        # - ``FirstRowMergeFunction.add`` ``copy()``s the first kv, so it
        #   keeps the first row rather than tracking later ``replace``s on
        #   the pooled kv (which would otherwise yield the last row).
        # This drops per-row ``KeyValue``/``OffsetRow`` allocations and
        # the resulting GC churn on large buffers.
        pooled_kv = KeyValue(key_arity, value_arity)

        merged_rows: List[dict] = []
        i = 0
        while i < n:
            j = i
            first_key = self._key_tuple(rows[i], col_names, key_arity)
            while j < n and \
                    self._key_tuple(rows[j], col_names, key_arity) == first_key:
                j += 1
            run = rows[i:j]
            self._merge_function.reset()
            for r in run:
                pooled_kv.replace(self._row_to_tuple(r, col_names))
                self._merge_function.add(pooled_kv)
            result_kv = self._merge_function.get_result()
            if result_kv is not None:
                merged_rows.append(
                    self._kv_to_row(result_kv, col_names,
                                    key_arity, value_arity))
            i = j

        if not merged_rows:
            return data.slice(0, 0)
        result_dict = {name: [r[name] for r in merged_rows]
                       for name in data.schema.names}
        return pa.Table.from_pydict(result_dict, schema=data.schema)

    @staticmethod
    def _key_tuple(row: dict, col_names: List[str], key_arity: int) -> tuple:
        return tuple(row[col_names[i]] for i in range(key_arity))

    @staticmethod
    def _row_to_tuple(row: dict, col_names: List[str]) -> tuple:
        return tuple(row[name] for name in col_names)

    @staticmethod
    def _kv_to_row(kv: KeyValue, col_names: List[str],
                   key_arity: int, value_arity: int) -> dict:
        out = {}
        for i in range(key_arity):
            out[col_names[i]] = kv.key.get_field(i)
        out[col_names[key_arity]] = kv.sequence_number
        out[col_names[key_arity + 1]] = kv.value_row_kind_byte
        for i in range(value_arity):
            out[col_names[key_arity + 2 + i]] = kv.value.get_field(i)
        return out

    def _add_system_fields(self, data: pa.RecordBatch) -> pa.RecordBatch:
        """Add system fields: _KEY_{pk_key}, _SEQUENCE_NUMBER, _VALUE_KIND."""
        num_rows = data.num_rows

        new_arrays = []
        new_fields = []

        for pk_key in self.trimmed_primary_keys:
            if pk_key in data.schema.names:
                key_column = data.column(pk_key)
                new_arrays.append(key_column)
                src_field = data.schema.field(pk_key)
                new_fields.append(pa.field(f'_KEY_{pk_key}', src_field.type, nullable=src_field.nullable))

        sequence_column = pa.array([self.sequence_generator.next() for _ in range(num_rows)], type=pa.int64())
        new_arrays.append(sequence_column)
        new_fields.append(pa.field('_SEQUENCE_NUMBER', pa.int64(), nullable=False))

        # TODO: support real row kind here
        value_kind_column = pa.array([0] * num_rows, type=pa.int8())
        new_arrays.append(value_kind_column)
        new_fields.append(pa.field('_VALUE_KIND', pa.int8(), nullable=False))

        for i in range(data.num_columns):
            new_arrays.append(data.column(i))
            new_fields.append(data.schema.field(i))

        return pa.RecordBatch.from_arrays(new_arrays, schema=pa.schema(new_fields))

    def _sort_by_primary_key(
        self, data: Union[pa.RecordBatch, pa.Table]
    ) -> Union[pa.RecordBatch, pa.Table]:
        # pc.sort_indices + .take work uniformly over RecordBatch and
        # Table, so this serves both the per-batch entry path (legacy)
        # and the buffer-wide sort path (used by ``_flush_all``).
        sort_keys = [(key, 'ascending') for key in self.trimmed_primary_keys]
        if '_SEQUENCE_NUMBER' in data.schema.names:
            sort_keys.append(('_SEQUENCE_NUMBER', 'ascending'))

        sorted_indices = pc.sort_indices(data, sort_keys=sort_keys)
        return data.take(sorted_indices)
