# 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, Optional, Tuple

import pyarrow as pa
import pyarrow.compute as pc
from pyarrow import RecordBatch

from pypaimon.common.file_io import FileIO
from pypaimon.read.partition_info import PartitionInfo
from pypaimon.read.reader.format_blob_reader import FormatBlobReader
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.schema.data_types import (ArrayType, AtomicType, DataField,
                                        MapType, PyarrowFieldParser, RowType)
from pypaimon.table.special_fields import SpecialFields


def _is_character_string_type(data_type) -> bool:
    if not isinstance(data_type, AtomicType):
        return False
    t = data_type.type.upper()
    return t == 'STRING' or t.startswith('VARCHAR') or t.startswith('CHAR')


def _unslice(array):
    """Re-materialize a sliced array so offsets/buffers start at zero.

    The list/map rebuilds below read ``offsets``/raw buffers directly; on a
    sliced array those still point into the parent storage, which either
    errors (list rebuild with a null mask) or silently misaligns rows (map
    rebuild from raw buffers)."""
    if array.offset == 0:
        return array
    return pa.concat_arrays([array])


def _to_string_values(array, data_type) -> list:
    """Render *array* as a list of per-row strings (None for NULL rows)."""
    if isinstance(data_type, (RowType, ArrayType, MapType)):
        return _constructed_to_string_array(array, data_type).to_pylist()
    return array.cast(pa.string(), safe=False).to_pylist()


def _constructed_to_string_array(array, file_type):
    """Render a struct/list/map array in the engine's string form:
    ROW -> ``{v1, v2}``, ARRAY -> ``[e1, e2]``, MAP -> ``{k1 -> v1, k2 -> v2}``.
    Sub-values are rendered recursively; a NULL sub-value renders as the
    literal ``null`` while a NULL container row stays NULL."""
    array = _unslice(array)
    valid = pc.is_valid(array).to_pylist()
    out = []
    if isinstance(file_type, RowType):
        children = [
            _to_string_values(array.field(i), sub.type)
            for i, sub in enumerate(file_type.fields)
        ]
        for i in range(len(array)):
            if not valid[i]:
                out.append(None)
                continue
            vals = [c[i] if c[i] is not None else 'null' for c in children]
            out.append('{' + ', '.join(vals) + '}')
    elif isinstance(file_type, ArrayType):
        values = _to_string_values(array.values, file_type.element)
        offsets = array.offsets.to_pylist()
        for i in range(len(array)):
            if not valid[i]:
                out.append(None)
                continue
            elems = [v if v is not None else 'null'
                     for v in values[offsets[i]:offsets[i + 1]]]
            out.append('[' + ', '.join(elems) + ']')
    elif isinstance(file_type, MapType):
        keys = _to_string_values(array.keys, file_type.key)
        items = _to_string_values(array.items, file_type.value)
        offsets = array.offsets.to_pylist()
        for i in range(len(array)):
            if not valid[i]:
                out.append(None)
                continue
            entries = [
                '{} -> {}'.format(
                    keys[j] if keys[j] is not None else 'null',
                    items[j] if items[j] is not None else 'null')
                for j in range(offsets[i], offsets[i + 1])
            ]
            out.append('{' + ', '.join(entries) + '}')
    else:
        raise ValueError(
            'Unsupported constructed type for string rendering: {}'.format(file_type))
    return pa.array(out, type=pa.string())


class _RowIdRangeCursor:
    """Generate selected row offsets batch by batch from compact ranges."""

    def __init__(self, ranges: List[Tuple[int, int]]):
        self._ranges = ranges
        self._range_index = 0
        self._next_offset = ranges[0][0] if ranges else None

    def take(self, count: int) -> List[int]:
        offsets = []
        while len(offsets) < count:
            if self._range_index >= len(self._ranges):
                raise ValueError(
                    "Row range offsets were exhausted before the reader")
            lower, upper = self._ranges[self._range_index]
            current = max(lower, self._next_offset)
            take_count = min(count - len(offsets), upper - current + 1)
            offsets.extend(range(current, current + take_count))
            current += take_count
            if current > upper:
                self._range_index += 1
                self._next_offset = (
                    self._ranges[self._range_index][0]
                    if self._range_index < len(self._ranges) else None)
            else:
                self._next_offset = current
        return offsets


class DataFileBatchReader(RecordBatchReader):
    """
    Reads record batch from files of different formats
    """

    def __init__(self, format_reader: RecordBatchReader, index_mapping: List[int], partition_info: PartitionInfo,
                 system_primary_key: Optional[List[str]], fields: List[DataField],
                 max_sequence_number: int,
                 first_row_id: int,
                 row_tracking_enabled: bool,
                 system_fields: dict,
                 file_io: Optional[FileIO] = None,
                 row_id_offsets: Optional[List[int]] = None,
                 file_data_fields: Optional[List[DataField]] = None,
                 target_data_fields: Optional[List[DataField]] = None,
                 row_id_offset_ranges: Optional[List[Tuple[int, int]]] = None):
        if (row_id_offsets is not None
                and row_id_offset_ranges is not None):
            raise ValueError(
                "row_id_offsets and row_id_offset_ranges cannot both "
                "be provided")
        self.format_reader = format_reader
        self.index_mapping = index_mapping
        self.partition_info = partition_info
        self.system_primary_key = system_primary_key
        self.schema_map = {field.name: field for field in PyarrowFieldParser.from_paimon_schema(fields)}
        self.row_tracking_enabled = row_tracking_enabled
        self.first_row_id = first_row_id
        self.row_id_offsets = row_id_offsets
        self._row_id_cursor = 0
        self._row_id_range_cursor = (
            _RowIdRangeCursor(row_id_offset_ranges)
            if row_id_offset_ranges is not None else None)
        self.max_sequence_number = max_sequence_number
        self.system_fields = system_fields
        self.file_io = file_io
        # Per-file field-id normalization: map the physically-read columns
        # (the file's own field order/names) onto the latest read target by
        # field id, padding missing ids with NULL and recursing into nested
        # ROW / ARRAY<ROW> / MAP<.,ROW> sub-fields the same way. ``None`` when
        # there is no evolution to reconcile -- the common path stays zero-copy.
        self._normalize_plan = self._build_normalize_plan(file_data_fields, target_data_fields)

    @staticmethod
    def _build_normalize_plan(file_data_fields, target_data_fields):
        """Build a per-file field-id alignment plan.

        Returns a list of ``(pos, file_field, target_field)`` -- one per target
        field, in target order -- where ``pos`` is the column index in the
        physically-read batch carrying ``target_field`` (matched by field id),
        or -1 if the file does not contain that id (pad NULL). Returns ``None``
        when the file already matches the target exactly (no evolution), so the
        caller stays zero-copy.
        """
        if file_data_fields is None or target_data_fields is None:
            return None
        # Recursive equality covers nested sub-field changes too: any rename /
        # add / drop / type change at any depth makes the file != target.
        if file_data_fields == target_data_fields:
            return None
        file_id_to_pos = {f.id: i for i, f in enumerate(file_data_fields)}
        plan = []
        for target in target_data_fields:
            pos = file_id_to_pos.get(target.id, -1)
            file_field = file_data_fields[pos] if pos >= 0 else None
            plan.append((pos, file_field, target))
        return plan

    def _normalize_batch(self, record_batch: RecordBatch) -> RecordBatch:
        """Reorder/pad the physically-read batch onto the latest read target by
        field id, relabel columns to the latest names, and align nested ROW
        sub-fields by id. Missing ids become typed all-NULL columns."""
        if self._normalize_plan is None:
            return record_batch
        num_rows = record_batch.num_rows
        arrays = []
        names = []
        for pos, file_field, target_field in self._normalize_plan:
            target_pa_type = PyarrowFieldParser.from_paimon_type(target_field.type)
            if pos < 0:
                arrays.append(pa.nulls(num_rows, type=target_pa_type))
            else:
                arrays.append(self._align_array_by_id(
                    record_batch.column(pos), file_field.type, target_field.type))
            names.append(target_field.name)
        return pa.RecordBatch.from_arrays(arrays, names=names)

    def _align_array_by_id(self, array, file_type, target_type):
        """Return *array* converted to *target_type*, matching ROW sub-fields by
        field id (reorder, pad missing with NULL, follow renames, cast changed
        types) recursively, transparently through ARRAY/MAP wrappers."""
        if isinstance(target_type, RowType) and isinstance(file_type, RowType):
            n = len(array)
            file_id_to_pos = {f.id: i for i, f in enumerate(file_type.fields)}
            children = []
            pa_fields = []
            for tsub in target_type.fields:
                p = file_id_to_pos.get(tsub.id, -1)
                if p < 0:
                    child = pa.nulls(n, type=PyarrowFieldParser.from_paimon_type(tsub.type))
                else:
                    child = self._align_array_by_id(
                        array.field(p), file_type.fields[p].type, tsub.type)
                children.append(child)
                pa_fields.append(pa.field(tsub.name, child.type, nullable=tsub.type.nullable))
            # Preserve the struct's own null mask; child values under a null
            # struct are irrelevant.
            return pa.StructArray.from_arrays(
                children, fields=pa_fields, mask=pc.is_null(array))
        if isinstance(target_type, ArrayType) and isinstance(file_type, ArrayType):
            array = _unslice(array)
            aligned_values = self._align_array_by_id(
                array.values, file_type.element, target_type.element)
            return pa.ListArray.from_arrays(
                array.offsets, aligned_values, mask=pc.is_null(array))
        if isinstance(target_type, MapType) and isinstance(file_type, MapType):
            array = _unslice(array)
            aligned_items = self._align_array_by_id(
                array.items, file_type.value, target_type.value)
            # MapArray.from_arrays cannot carry a null mask (a null map would
            # collapse to an empty one), so rebuild from buffers, reusing the
            # original validity/offset buffers and only swapping the value child.
            target_pa = PyarrowFieldParser.from_paimon_type(target_type)
            entries = pa.StructArray.from_arrays(
                [array.keys, aligned_items],
                fields=[target_pa.key_field, target_pa.item_field])
            return pa.Array.from_buffers(
                target_pa, len(array), array.buffers()[:2], children=[entries])
        # A constructed type changed to a character string: pyarrow cannot
        # cast struct/list/map to utf8 directly, so render the engine's
        # string form instead.
        if (isinstance(file_type, (RowType, ArrayType, MapType))
                and _is_character_string_type(target_type)):
            return _constructed_to_string_array(array, file_type)
        # Leaf / non-nested: cast to the target type when it differs.
        target_pa_type = PyarrowFieldParser.from_paimon_type(target_type)
        if array.type != target_pa_type:
            return array.cast(target_pa_type, safe=False)
        return array

    def read_arrow_batch(self, start_idx=None, end_idx=None) -> Optional[RecordBatch]:
        if isinstance(self.format_reader, FormatBlobReader):
            record_batch = self.format_reader.read_arrow_batch(start_idx, end_idx)
        else:
            record_batch = self.format_reader.read_arrow_batch()
        if record_batch is None:
            return None
        record_batch = self._normalize_batch(record_batch)

        if self.partition_info is None and self.index_mapping is None:
            # A file written under an older schema (e.g. before an INT -> BIGINT
            # promotion or a DECIMAL precision change) yields columns in the
            # data file's original types. Without reordering or partition padding
            # to rebuild the batch, those old types would otherwise leak through
            # here -- returning a type that depends on whether this read happens
            # to span newer-schema files, and failing to concatenate when it
            # does. Align them to the current read schema, mirroring the rebuild
            # path below.
            record_batch = self._align_batch_to_read_schema(
                record_batch.schema.names, record_batch.columns)
            if self.row_tracking_enabled and self.system_fields:
                record_batch = self._assign_row_tracking(record_batch)
            return record_batch

        inter_arrays = []
        inter_names = []
        num_rows = record_batch.num_rows

        if self.partition_info is not None:
            for i in range(self.partition_info.size()):
                if self.partition_info.is_partition_row(i):
                    partition_value, partition_field = self.partition_info.get_partition_value(i)
                    const_array = pa.repeat(partition_value, num_rows)
                    inter_arrays.append(const_array)
                    inter_names.append(partition_field.name)
                else:
                    real_index = self.partition_info.get_real_index(i)
                    if real_index < record_batch.num_columns:
                        inter_arrays.append(record_batch.column(real_index))
                        inter_names.append(record_batch.schema.field(real_index).name)
        else:
            inter_arrays = record_batch.columns
            inter_names = record_batch.schema.names

        if self.index_mapping is not None:
            mapped_arrays = []
            mapped_names = []
            for i, real_index in enumerate(self.index_mapping):
                if 0 <= real_index < len(inter_arrays):
                    mapped_arrays.append(inter_arrays[real_index])
                    mapped_names.append(inter_names[real_index])
                else:
                    null_array = pa.nulls(num_rows)
                    mapped_arrays.append(null_array)
                    mapped_names.append(f"null_col_{i}")

            if self.system_primary_key:
                for i in range(len(self.system_primary_key)):
                    if not mapped_names[i].startswith("_KEY_"):
                        mapped_names[i] = f"_KEY_{mapped_names[i]}"

            inter_arrays = mapped_arrays
            inter_names = mapped_names

        # Rebuild the batch typed by the read schema (carries 'not null' and
        # aligns old-schema column types).
        record_batch = self._align_batch_to_read_schema(inter_names, inter_arrays)

        # Handle row tracking fields
        if self.row_tracking_enabled and self.system_fields:
            record_batch = self._assign_row_tracking(record_batch)

        return record_batch

    def _align_batch_to_read_schema(self, names: List[str], arrays: list) -> RecordBatch:
        """Build a record batch for ``names``/``arrays`` typed by the read schema.

        Each known field is cast to the current read schema's type, which also
        carries the 'not null' property; unknown columns keep the array's own
        type. Columns whose type already matches are reused as-is, keeping the
        common (non-evolution) path zero-copy.

        Casts use ``safe=False`` to match Java ``CastExecutors`` semantics for
        the read-time conversions a user-approved schema evolution implies
        (e.g. DECIMAL scale-down or DOUBLE -> INT truncate rather than raise).
        Evolution legality is the writer's concern (``DataTypeCasts``); the read
        path only materializes the result.
        """
        out_arrays = []
        out_fields = []
        for name, array in zip(names, arrays):
            target_field = self.schema_map.get(name)
            if target_field is None:
                target_field = pa.field(name, array.type)
            elif array.type != target_field.type:
                array = array.cast(target_field.type, safe=False)
            out_arrays.append(array)
            out_fields.append(target_field)
        return pa.RecordBatch.from_arrays(out_arrays, schema=pa.schema(out_fields))

    def _assign_row_tracking(self, record_batch: RecordBatch) -> RecordBatch:
        """Assign row tracking meta fields (_ROW_ID and _SEQUENCE_NUMBER)."""
        arrays = list(record_batch.columns)

        # Handle _ROW_ID field
        if SpecialFields.ROW_ID.name in self.system_fields.keys():
            idx = self.system_fields[SpecialFields.ROW_ID.name]
            if self.first_row_id is None:
                arrays[idx] = pa.nulls(record_batch.num_rows, type=pa.int64())
            elif self.row_id_offsets is not None:
                end = self._row_id_cursor + record_batch.num_rows
                row_ids = [self.first_row_id + o for o in self.row_id_offsets[self._row_id_cursor:end]]
                arrays[idx] = pa.array(row_ids, type=pa.int64())
                self._row_id_cursor = end
            elif self._row_id_range_cursor is not None:
                row_ids = [
                    self.first_row_id + offset
                    for offset in self._row_id_range_cursor.take(
                        record_batch.num_rows)
                ]
                arrays[idx] = pa.array(row_ids, type=pa.int64())
            else:
                row_id_range = range(self.first_row_id, self.first_row_id + record_batch.num_rows)
                arrays[idx] = pa.array(row_id_range, type=pa.int64())
                self.first_row_id += record_batch.num_rows

        # Handle _SEQUENCE_NUMBER field
        if SpecialFields.SEQUENCE_NUMBER.name in self.system_fields.keys():
            idx = self.system_fields[SpecialFields.SEQUENCE_NUMBER.name]
            # Create a new array that fills with max_sequence_number
            arrays[idx] = pa.repeat(self.max_sequence_number, record_batch.num_rows)

        names = record_batch.schema.names
        table = None
        for i, name in enumerate(names):
            field = pa.field(
                name, arrays[i].type,
                nullable=record_batch.schema.field(name).nullable
            )
            if table is None:
                table = pa.table({name: arrays[i]}, schema=pa.schema([field]))
            else:
                table = table.append_column(field, arrays[i])
        return table.to_batches()[0]

    def close(self) -> None:
        self.format_reader.close()
