# 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 io
import re
from dataclasses import dataclass
from typing import BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence

from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.schema.data_types import is_blob_type
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.table.row.blob import Blob, BlobData, BlobDescriptor
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId


_RANGE_PATTERN = re.compile(r"^bytes=(\d*)-(\d*)$")


class NoSuchKey(KeyError):
    """Raised when a blob key does not exist in a multimodal blob store."""


@dataclass(frozen=True)
class PutObjectResult:
    key: object
    size: Optional[int]


@dataclass(frozen=True)
class ObjectInfo:
    key: object
    size: Optional[int]
    descriptor: Optional[BlobDescriptor]
    columns: Dict[str, object]


@dataclass(frozen=True)
class BlobObject:
    key: object
    descriptor: BlobDescriptor
    columns: Dict[str, object]
    file_io: object
    range_header: Optional[str] = None

    @property
    def size(self) -> int:
        return self.descriptor.length

    @property
    def content_length(self) -> int:
        descriptor = _descriptor_for_range(self.descriptor, self.range_header)
        return descriptor.length

    def open(self) -> BinaryIO:
        descriptor = _descriptor_for_range(self.descriptor, self.range_header)
        uri_reader = self.file_io.uri_reader_factory.create(descriptor.uri)
        return Blob.from_descriptor(uri_reader, descriptor).new_input_stream()

    def read(self) -> bytes:
        with self.open() as stream:
            return stream.read()


class BlobStore:
    """S3-like object facade over a Paimon multimodal table BLOB column."""

    def __init__(
            self,
            table,
            column: Optional[str] = None,
            key_column: Optional[str] = None):
        self._table = table
        self._raw_table = table.raw_table
        self.column = column or self._infer_blob_column()
        self.key_column = key_column or self._infer_key_column()
        self._validate_columns()

    def put_object(
            self,
            key,
            body=None,
            *,
            uri: Optional[str] = None,
            offset: int = 0,
            length: int = -1,
            descriptor: Optional[BlobDescriptor] = None,
            columns: Optional[Mapping[str, object]] = None) -> PutObjectResult:
        return self.put_objects([
            {
                "key": key,
                "body": body,
                "uri": uri,
                "offset": offset,
                "length": length,
                "descriptor": descriptor,
                "columns": dict(columns or {}),
            }
        ])[0]

    def put_objects(self, objects: Iterable[Mapping[str, object]]) -> List[PutObjectResult]:
        rows = []
        for obj in objects:
            key = _require_key(obj)
            row = dict(obj.get("columns") or {})
            row[self.key_column] = key
            row[self.column] = self._object_to_blob(obj)
            rows.append(row)
        if not rows:
            return []
        return self._put_rows(self._deduplicate_rows_last_write_wins(rows))

    def update_object_columns(
            self,
            key,
            columns: Mapping[str, object]) -> ObjectInfo:
        return self.update_objects_columns([
            {"key": key, "columns": columns}
        ])[0]

    def update_objects_columns(
            self,
            objects: Iterable[Mapping[str, object]]) -> List[ObjectInfo]:
        updates = []
        keys = []
        for obj in objects:
            key = _require_key(obj)
            columns = dict(obj.get("columns") or {})
            if not columns:
                raise ValueError("columns must not be empty.")
            self._selected_update_columns(columns.keys())
            updates.append((key, columns))
            keys.append(key)
        if not updates:
            return []
        self._validate_unique_keys(keys)
        self._commit_column_updates(updates)
        return [
            self.head_object(key)
            for key in keys
        ]

    def get_object(
            self,
            key,
            *,
            range: Optional[str] = None,
            columns: Optional[Sequence[str]] = None) -> BlobObject:
        info = self.head_object(key, columns=columns)
        if info.descriptor is None:
            raise NoSuchKey(key)
        _descriptor_for_range(info.descriptor, range)
        return BlobObject(
            key=info.key,
            descriptor=info.descriptor,
            columns=info.columns,
            file_io=self._raw_table.file_io,
            range_header=range,
        )

    def head_object(
            self,
            key,
            *,
            columns: Optional[Sequence[str]] = None) -> ObjectInfo:
        rows = self._read_rows(
            keys=[key],
            include_blob=True,
            columns=columns,
        )
        if not rows:
            raise NoSuchKey(key)
        if len(rows) > 1:
            raise ValueError("Multiple rows found for blob key %r." % key)
        return self._row_to_info(rows[0])

    def list_objects(
            self,
            *,
            prefix: Optional[str] = None,
            limit: Optional[int] = None,
            columns: Optional[Sequence[str]] = None) -> List[ObjectInfo]:
        if limit is not None:
            if limit < 0:
                raise ValueError("limit must be greater than or equal to 0.")
            if limit == 0:
                return []
        rows = self._read_rows(
            include_blob=True,
            columns=columns,
        )
        objects = []
        for row in rows:
            key = row[self.key_column]
            if prefix is not None and not str(key).startswith(prefix):
                continue
            objects.append(self._row_to_info(row))
            if limit is not None and len(objects) >= limit:
                break
        return objects

    def delete_object(self, key) -> None:
        self.delete_objects([key])

    def delete_objects(self, keys: Sequence[object]) -> None:
        if not keys:
            return
        keys = self._deduplicate_keys(keys)
        write_builder = self._raw_table.new_batch_write_builder()
        table_update = write_builder.new_update()
        table_commit = write_builder.new_commit()
        try:
            messages = table_update.delete_by_predicate(
                self._keys_predicate(keys))
            table_commit.commit(messages)
        finally:
            table_commit.close()

    def _put_rows(self, rows: List[Mapping[str, object]]) -> List[PutObjectResult]:
        self._commit_upsert_once(rows)
        return [
            self._put_result_from_row(row)
            for row in rows
        ]

    def _deduplicate_rows_last_write_wins(
            self,
            rows: List[Mapping[str, object]]) -> List[Mapping[str, object]]:
        key_to_last_index = {}
        for index, row in enumerate(rows):
            key_to_last_index[row[self.key_column]] = index
        if len(key_to_last_index) == len(rows):
            return rows
        return [
            rows[index]
            for index in sorted(key_to_last_index.values())
        ]

    def _deduplicate_keys(self, keys: Sequence[object]) -> List[object]:
        unique_keys = []
        seen = set()
        for key in keys:
            if key in seen:
                continue
            seen.add(key)
            unique_keys.append(key)
        return unique_keys

    def _commit_upsert_once(self, rows: List[Mapping[str, object]]) -> None:
        generic_rows = self._rows_to_generic_rows(rows)
        write_builder = self._raw_table.new_batch_write_builder()
        table_update = write_builder.new_update()
        table_commit = write_builder.new_commit()
        try:
            messages = table_update.upsert_by_key(
                generic_rows, [self.key_column])
            table_commit.commit(messages)
        finally:
            table_commit.close()

    def _commit_column_updates(self, updates: Sequence[tuple]) -> None:
        update_columns = self._column_update_names(updates)
        targets = self._read_update_targets(
            [key for key, _ in updates],
            update_columns,
        )
        rows = []
        row_ids_by_row = []
        for key, columns in updates:
            target = targets[key]
            row = dict(target["columns"])
            row.update(columns)
            row[self.key_column] = key
            rows.append(self._row_to_generic_row(row))
            row_ids_by_row.append([target["row_id"]])

        write_builder = self._raw_table.new_batch_write_builder()
        table_commit = write_builder.new_commit()
        try:
            messages = TableUpdateByRowId(
                self._raw_table,
                write_builder.commit_user,
                BATCH_COMMIT_IDENTIFIER,
            ).update_rows_columns(rows, row_ids_by_row, update_columns)
            if messages:
                table_commit.commit(messages)
        finally:
            table_commit.close()

    def _column_update_names(self, updates: Sequence[tuple]) -> List[str]:
        requested = set()
        for _, columns in updates:
            requested.update(columns)
        return [
            name for name in self._raw_table.field_names
            if name in requested
        ]

    def _read_update_targets(
            self,
            keys: Sequence[object],
            columns: Sequence[str]) -> Dict[object, dict]:
        read_builder = self._raw_table.new_read_builder()
        projection = [self.key_column, SpecialFields.ROW_ID.name]
        projection.extend(columns)
        read_builder = read_builder.with_projection(projection)
        read_builder = read_builder.with_filter(self._keys_predicate(keys))
        scan = read_builder.new_scan()
        plan = scan.plan_for_write()
        table = read_builder.new_read().to_arrow(plan.splits())

        targets = {}
        duplicates = []
        for row in table.to_pylist():
            key = row[self.key_column]
            if key in targets:
                duplicates.append(key)
                continue
            targets[key] = {
                "row_id": row[SpecialFields.ROW_ID.name],
                "columns": {
                    name: row.get(name)
                    for name in columns
                },
            }

        missing = [key for key in keys if key not in targets]
        if missing:
            raise NoSuchKey(missing[0])
        if duplicates:
            raise ValueError("Multiple rows found for blob keys: %s" % duplicates)
        return targets

    def _put_result_from_row(self, row: Mapping[str, object]) -> PutObjectResult:
        blob = row.get(self.column)
        return PutObjectResult(
            key=row[self.key_column],
            size=_blob_size(blob),
        )

    def _rows_to_generic_rows(self, rows: List[Mapping[str, object]]) -> List[GenericRow]:
        return [
            self._row_to_generic_row(row)
            for row in rows
        ]

    def _row_to_generic_row(self, row: Mapping[str, object]) -> GenericRow:
        return GenericRow(
            [row.get(field.name) for field in self._raw_table.fields],
            self._raw_table.fields,
        )

    def _read_rows(
            self,
            keys: Optional[Sequence[object]] = None,
            include_blob: bool = False,
            columns: Optional[Sequence[str]] = None) -> List[dict]:
        read_table = self._raw_table.copy({
            CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
        }) if include_blob else self._raw_table
        read_builder = read_table.new_read_builder()
        projection = self._projection(include_blob, columns)
        read_builder = read_builder.with_projection(projection)
        if keys is not None:
            read_builder = read_builder.with_filter(self._keys_predicate(keys))
        plan = read_builder.new_scan().plan()
        table = read_builder.new_read().to_arrow(plan.splits())
        return table.to_pylist()

    def _projection(
            self,
            include_blob: bool,
            selected_columns: Optional[Sequence[str]]) -> List[str]:
        projection = [self.key_column]
        if include_blob:
            projection.append(self.column)
        projection.extend(self._selected_columns(selected_columns))
        return projection

    def _row_to_info(self, row: Mapping[str, object]) -> ObjectInfo:
        descriptor_bytes = row.get(self.column)
        descriptor = None
        if descriptor_bytes is not None:
            descriptor = BlobDescriptor.deserialize(_bytes_value(descriptor_bytes))
        columns = {
            name: value
            for name, value in row.items()
            if name not in (self.key_column, self.column)
        }
        return ObjectInfo(
            key=row[self.key_column],
            size=descriptor.length if descriptor is not None else None,
            descriptor=descriptor,
            columns=columns,
        )

    def _keys_predicate(self, keys: Sequence[object]):
        builder = PredicateBuilder(self._raw_table.fields)
        values = list(keys)
        if len(values) == 1:
            return builder.equal(self.key_column, values[0])
        return builder.is_in(self.key_column, values)

    def _infer_blob_column(self) -> str:
        blob_columns = [
            field.name for field in self._raw_table.fields
            if _is_blob_field(field)
        ]
        if len(blob_columns) != 1:
            raise ValueError(
                "Blob column is required when table has %d BLOB columns: %s."
                % (len(blob_columns), blob_columns)
            )
        return blob_columns[0]

    def _infer_key_column(self) -> str:
        for candidate in ("key", "object_key", "path"):
            if candidate in self._raw_table.field_names:
                return candidate
        raise ValueError(
            "key_column is required; no default key/object_key/path column "
            "exists in table schema."
        )

    def _validate_columns(self):
        if self.key_column not in self._raw_table.field_names:
            raise ValueError("key_column %r is not in table schema." % self.key_column)
        if self.column not in self._raw_table.field_names:
            raise ValueError("blob column %r is not in table schema." % self.column)
        if not _is_blob_field(self._raw_table.field_dict[self.column]):
            raise ValueError("Column %r is not a BLOB column." % self.column)
        if self.key_column == self.column:
            raise ValueError("key_column and blob column must be different.")

    def _object_to_blob(self, obj: Mapping[str, object]) -> Blob:
        has_body = "body" in obj and obj.get("body") is not None
        has_reference = obj.get("descriptor") is not None or obj.get("uri") is not None
        if has_body and has_reference:
            raise ValueError(
                "Blob object spec must use either 'body' or descriptor/uri, not both."
            )
        if has_reference:
            descriptor = _coerce_descriptor(obj)
            uri_reader = self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
            return Blob.from_descriptor(uri_reader, descriptor)
        if not has_body:
            raise ValueError("Blob object spec requires 'body', 'descriptor', or 'uri'.")
        body = obj.get("body")
        if isinstance(body, Blob):
            try:
                descriptor = body.to_descriptor()
            except RuntimeError:
                return body
            uri_reader = self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
            return Blob.from_descriptor(uri_reader, descriptor)
        return BlobData(_body_to_bytes(body))

    def _selected_columns(
            self,
            columns: Optional[Sequence[str]]) -> List[str]:
        if columns is None:
            return [
                name for name in self._raw_table.field_names
                if name not in (self.key_column, self.column)
            ]
        if isinstance(columns, str):
            names = [columns]
        else:
            names = list(columns)
        self._validate_unique_selected_columns(names)
        for name in names:
            if name in (self.key_column, self.column):
                raise ValueError(
                    "columns must not include key or blob column %r." % name
                )
            if name not in self._raw_table.field_names:
                raise ValueError("Column %r is not in table schema." % name)
        return names

    def _selected_update_columns(self, columns: Sequence[str]) -> List[str]:
        names = self._selected_columns(columns)
        partition_keys = set(self._raw_table.partition_keys)
        for name in names:
            if name in partition_keys:
                raise ValueError(
                    "columns must not include partition column %r." % name
                )
        return names

    @staticmethod
    def _validate_unique_selected_columns(names: Sequence[str]):
        seen = set()
        duplicates = set()
        for name in names:
            if name in seen:
                duplicates.add(name)
            seen.add(name)
        if duplicates:
            raise ValueError("Duplicate columns: %s" % sorted(duplicates))

    @staticmethod
    def _validate_unique_keys(keys: Sequence[object]):
        seen = set()
        duplicates = set()
        for key in keys:
            if key in seen:
                duplicates.add(key)
            seen.add(key)
        if duplicates:
            raise ValueError("Duplicate blob keys: %s" % sorted(duplicates))


def _require_key(obj: Mapping[str, object]):
    if "key" not in obj:
        raise ValueError("Blob object spec requires 'key'.")
    key = obj["key"]
    if key is None:
        raise ValueError("Blob object key must not be None.")
    return key


def _body_to_bytes(body) -> bytes:
    if isinstance(body, bytes):
        return body
    if isinstance(body, bytearray):
        return bytes(body)
    if isinstance(body, memoryview):
        return body.tobytes()
    if isinstance(body, str):
        return body.encode("utf-8")
    if isinstance(body, io.IOBase) or hasattr(body, "read"):
        data = body.read()
        if isinstance(data, str):
            return data.encode("utf-8")
        return bytes(data)
    raise ValueError("Unsupported blob body type: %r." % type(body))


def _coerce_descriptor(obj: Mapping[str, object]) -> BlobDescriptor:
    descriptor = obj.get("descriptor")
    if descriptor is not None:
        if isinstance(descriptor, BlobDescriptor):
            return descriptor
        if isinstance(descriptor, (bytes, bytearray)):
            return BlobDescriptor.deserialize(bytes(descriptor))
        raise ValueError("descriptor must be BlobDescriptor or serialized bytes.")

    uri = obj.get("uri")
    if not uri:
        raise ValueError("Reference object spec requires 'uri' or 'descriptor'.")
    return BlobDescriptor(
        uri=str(uri),
        offset=int(obj.get("offset", 0)),
        length=int(obj.get("length", -1)),
    )


def _descriptor_for_range(
        descriptor: BlobDescriptor,
        range_header: Optional[str]) -> BlobDescriptor:
    if range_header is None:
        return descriptor
    start, length = _parse_range(range_header, descriptor.length)
    return BlobDescriptor(descriptor.uri, descriptor.offset + start, length)


def _parse_range(range_header: str, total_length: int):
    match = _RANGE_PATTERN.match(range_header)
    if match is None:
        raise ValueError("Range must use S3 syntax like 'bytes=0-1023'.")
    start_text, end_text = match.groups()
    if not start_text and not end_text:
        raise ValueError("Range must specify a start or end byte.")

    if start_text:
        start = int(start_text)
        if total_length >= 0 and start >= total_length:
            raise ValueError("Range start is past the end of the object.")
        if end_text:
            end = int(end_text)
            if end < start:
                raise ValueError("Range end must be greater than or equal to start.")
            if total_length >= 0:
                end = min(end, total_length - 1)
            length = end - start + 1
        else:
            if total_length < 0:
                length = -1
            else:
                length = max(total_length - start, 0)
    else:
        if total_length < 0:
            raise ValueError("Suffix ranges require a known object length.")
        suffix_length = int(end_text)
        if suffix_length < 0:
            raise ValueError("Suffix range length must be non-negative.")
        length = min(suffix_length, total_length)
        start = total_length - length

    return start, length


def _blob_descriptor(blob) -> Optional[BlobDescriptor]:
    if blob is None:
        return None
    try:
        return blob.to_descriptor()
    except RuntimeError:
        return None


def _blob_size(blob) -> Optional[int]:
    descriptor = _blob_descriptor(blob)
    if descriptor is not None:
        return descriptor.length if descriptor.length >= 0 else None
    if isinstance(blob, BlobData):
        return len(blob.data)
    return None


def _bytes_value(value) -> bytes:
    if hasattr(value, "as_py"):
        value = value.as_py()
    if isinstance(value, bytearray):
        value = bytes(value)
    if not isinstance(value, bytes):
        raise ValueError("Expected serialized BlobDescriptor bytes.")
    return value


def _is_blob_field(field) -> bool:
    return is_blob_type(field.type)
