#######################################################################
# Copyright (c) 2019-present, Blosc Development Team <blosc@blosc.org>
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
#######################################################################

from __future__ import annotations

import os
import pathlib
import weakref
import zipfile
from collections import namedtuple
from collections.abc import Iterator, Mapping, MutableMapping
from contextlib import contextmanager
from dataclasses import asdict, replace
from typing import Any, NamedTuple

import numpy as np

import blosc2
from blosc2 import SpecialValue, blosc2_ext
from blosc2.info import InfoReporter, format_nbytes_info
from blosc2.msgpack_utils import msgpack_packb, msgpack_unpackb


class vlmeta(MutableMapping, blosc2_ext.vlmeta):
    """
    Class providing access to user metadata on an :ref:`SChunk`.
    It is available via the `.vlmeta` property of an :ref:`SChunk`.

    Values are serialized using the general Blosc2 msgpack extensions; see
    :ref:`MsgpackSerialization`. Besides ordinary
    msgpack-safe Python values, this includes:

    - CFrame-backed Blosc2 objects such as :class:`blosc2.NDArray`,
      :class:`blosc2.SChunk`, :class:`blosc2.ObjectArray`,
      :class:`blosc2.BatchArray`, and :class:`blosc2.EmbedStore`
    - structured references and lazy objects such as :class:`blosc2.Ref`,
      :class:`blosc2.C2Array`, :class:`blosc2.LazyExpr`, and
      :class:`blosc2.LazyUDF` backed by :func:`blosc2.dsl_kernel`

    Lazy expressions and supported lazy UDFs still require durable operand
    references only; purely in-memory operands are intentionally rejected.
    """

    def __init__(self, owner, schunk):
        self._owner_ref = weakref.ref(owner)
        super().__init__(schunk)

    @property
    def _owner(self):
        owner = self._owner_ref()
        if owner is None:
            raise ReferenceError("The parent SChunk for this vlmeta object no longer exists")
        return owner

    @property
    def urlpath(self):
        return self._owner.urlpath

    @urlpath.setter
    def urlpath(self, value):
        self._owner.urlpath = value

    @property
    def mode(self):
        return self._owner.mode

    @mode.setter
    def mode(self, value):
        self._owner.mode = value

    @property
    def mmap_mode(self):
        return self._owner.mmap_mode

    @mmap_mode.setter
    def mmap_mode(self, value):
        self._owner.mmap_mode = value

    @property
    def initial_mapping_size(self):
        return self._owner.initial_mapping_size

    @initial_mapping_size.setter
    def initial_mapping_size(self, value):
        self._owner.initial_mapping_size = value

    @property
    def locking(self):
        return self._owner.locking

    @locking.setter
    def locking(self, value):
        self._owner.locking = value

    def __setitem__(self, name, content):
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        # If name is a slice, assume that content is a dictionary and copy all the items
        if isinstance(name, slice):
            if name.start is None and name.stop is None:
                for k, v in content.items():
                    self.set_vlmeta(k, v)
                return
            raise NotImplementedError("Slicing is not supported, unless [:]")
        cparams = {"typesize": 1}
        content = msgpack_packb(content)
        super().set_vlmeta(name, content, **cparams)

    def __getitem__(self, name):
        if isinstance(name, slice):
            if name.start is None and name.stop is None:
                # Return all the vlmetalayers
                return self.getall()
            raise NotImplementedError("Slicing is not supported, unless [:]")
        return msgpack_unpackb(super().get_vlmeta(name))

    def __delitem__(self, name):
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        super().del_vlmeta(name)

    def __len__(self):
        return super().nvlmetalayers()

    def __iter__(self):
        yield from super().get_names()

    def getall(self):
        """
        Return all the variable length metalayers as a dictionary

        """
        return {name: self[name] for name in self}

    def __repr__(self):
        return repr(self.getall())

    def __str__(self):
        return str(self.getall())


class Meta(Mapping):
    """
    Class providing access to fixed-length metadata on an :ref:`SChunk`.
    It is available via the `.meta` property of an :ref:`SChunk`.
    """

    def get(self, key: str, default: Any = None) -> Any:
        """Return the value for `key` if `key` is in the dictionary, else return `default`.
        If `default` is not given, it defaults to ``None``."""
        return self.get(key, default)

    def __init__(self, schunk):
        self.schunk = schunk

    def __contains__(self, key: str) -> bool:
        """Check if the `key` metalayer exists or not."""
        return blosc2_ext.meta__contains__(self.schunk, key)

    def __delitem__(self, key: str) -> None:
        raise NotImplementedError("Cannot remove a metalayer")

    def __setitem__(self, key: str, value: bytes) -> None:
        """Update the `key` metalayer with `value`.

        Parameters
        ----------
        key: str
            The name of the metalayer to update.
        value: bytes
            The buffer containing the new content for the metalayer.

            ..warning: Note that the *length* of the metalayer cannot change,
            otherwise an exception will be raised.
        """
        value = msgpack_packb(value)
        blosc2_ext.meta__setitem__(self.schunk, key, value)

    def __getitem__(self, item: str | slice) -> bytes | dict[str, bytes]:
        """Return the specified metalayer.

        Parameters
        ----------
        item: str or slice
            The name of the metalayer to return.  If a slice is passed,
            and start and stop are None ([:]), all the metalayers are returned;
            else, a NotImplementedError is raised.

        Returns
        -------
        bytes or dict
            The buffer containing the metalayer information. If a slice is passed,
            a dictionary with all the metalayers is returned.
        """
        if isinstance(item, slice):
            if item.start is None and item.stop is None:
                return self.getall()
            raise NotImplementedError("Slicing is not supported, unless [:]")
        if self.__contains__(item):
            return msgpack_unpackb(blosc2_ext.meta__getitem__(self.schunk, item))
        else:
            raise KeyError(f"{item} not found")

    def keys(self) -> list[str]:
        """Return the metalayers keys."""
        return blosc2_ext.meta_keys(self.schunk)

    def values(self):
        raise NotImplementedError("Values can not be accessed")

    def items(self):
        raise NotImplementedError("Items can not be accessed")

    def __iter__(self) -> Iterator[str]:
        """Iter over the keys of the metalayers."""
        return iter(self.keys())

    def __len__(self) -> int:
        """Return the number of metalayers."""
        return blosc2_ext.meta__len__(self.schunk)

    def getall(self):
        """
        Return all the variable length metalayers as a dictionary

        """
        return {key: self[key] for key in self.keys()}

    def __repr__(self):
        return repr(self.getall())

    def __str__(self):
        return str(self.getall())


class SChunk(blosc2_ext.SChunk):
    """Compressed super-chunk storing a sequence of compressed chunks."""

    def __init__(  # noqa: C901
        self,
        chunksize: int | None = None,
        data: object = None,
        **kwargs: dict | blosc2.CParams | blosc2.Storage | blosc2.DParams,
    ) -> None:
        """Create a new super-chunk, or open an existing one.

        Parameters
        ----------
        chunksize: int, optional
            The size, in bytes, of the chunks in the super-chunk. If not provided,
            it is set automatically to a reasonable value.

        data: bytes-like object, optional
            The data to be split into different chunks of size :paramref:`chunksize`.
            If None, the Schunk instance will be empty initially.

        kwargs: dict, optional
            Storage parameters. The default values are in :class:`blosc2.Storage`.
            Supported keyword arguments:

                storage: :class:`blosc2.Storage` or dict
                    All the storage parameters that you want to use as
                    a :class:`blosc2.Storage` or dict instance.
                cparams: :class:`blosc2.CParams` or dict
                    All the compression parameters that you want to use as
                    a :class:`blosc2.CParams` or dict instance.
                dparams: :class:`blosc2.DParams` or dict
                    All the decompression parameters that you want to use as
                    a :class:`blosc2.DParams` or dict instance.
                others: Any
                    If `storage` is not passed, all the parameters of a :class:`blosc2.Storage`
                    can be passed as keyword arguments.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> import os.path
        >>> import shutil
        >>> import tempfile
        >>> cparams = blosc2.CParams()
        >>> dparams = blosc2.DParams()
        >>> storage = blosc2.Storage(contiguous=True)
        >>> schunk = blosc2.SChunk(cparams=cparams, dparams=dparams, storage=storage)

        In the following, we will write and read a super-chunk to and from disk
        via memory-mapped files.

        >>> a = np.arange(3, dtype=np.int64)
        >>> chunksize = a.size * a.itemsize
        >>> n_chunks = 2
        >>> tmpdirname = tempfile.mkdtemp()
        >>> urlpath = os.path.join(tmpdirname, 'schunk.b2frame')

        Optional: we intend to write 2 chunks of 24 bytes each, and we expect
        the compressed size to be smaller than the original size. Therefore, we
        generously set the initial size of the mapping to 48 bytes
        effectively avoiding remappings.

        >>> initial_mapping_size = chunksize * n_chunks
        >>> schunk_mmap = blosc2.SChunk(
        ...     chunksize=chunksize,
        ...     mmap_mode="w+",
        ...     initial_mapping_size=initial_mapping_size,
        ...     urlpath=urlpath,
        ... )
        >>> schunk_mmap.append_data(a)
        1
        >>> schunk_mmap.append_data(a * 2)
        2

        Optional: explicitly close the file and free the mapping.

        >>> del schunk_mmap

        Reading the data back again via memory-mapped files:

        >>> schunk_mmap = blosc2.open(urlpath, mmap_mode="r")
        >>> np.frombuffer(schunk_mmap.decompress_chunk(0), dtype=np.int64).tolist()
        [0, 1, 2]
        >>> np.frombuffer(schunk_mmap.decompress_chunk(1), dtype=np.int64).tolist()
        [0, 2, 4]
        >>> shutil.rmtree(tmpdirname)
        """
        # Check only allowed kwarg are passed
        allowed_kwargs = [
            "urlpath",
            "contiguous",
            "cparams",
            "dparams",
            "_schunk",
            "meta",
            "mode",
            "mmap_mode",
            "initial_mapping_size",
            "locking",
            "_is_view",
            "storage",
        ]
        for kwarg in kwargs:
            if kwarg not in allowed_kwargs:
                raise ValueError(f"{kwarg} is not supported as keyword argument")
        if kwargs.get("storage") is not None:
            if any(key in list(blosc2.Storage.__annotations__) for key in kwargs):
                raise AttributeError(
                    "Cannot pass both `storage` and other kwargs already included in Storage"
                )
            storage = kwargs.get("storage")
            if isinstance(storage, blosc2.Storage):
                kwargs = {**kwargs, **asdict(storage)}
            else:
                kwargs = {**kwargs, **storage}

        if isinstance(kwargs.get("cparams"), blosc2.CParams):
            kwargs["cparams"] = asdict(kwargs.get("cparams"))

        if isinstance(kwargs.get("dparams"), blosc2.DParams):
            kwargs["dparams"] = asdict(kwargs.get("dparams"))

        urlpath = kwargs.get("urlpath")
        if "contiguous" not in kwargs:
            # Make contiguous true for disk, else sparse (for in-memory performance)
            kwargs["contiguous"] = urlpath is not None

        # This a private param to get an SChunk from a blosc2_schunk*
        sc = kwargs.pop("_schunk", None)

        # If not passed, set a sensible typesize
        itemsize = data.itemsize if data is not None and hasattr(data, "itemsize") else 1
        if "cparams" in kwargs:
            if "typesize" not in kwargs["cparams"]:
                cparams = kwargs.pop("cparams").copy()
                cparams["typesize"] = itemsize
                kwargs["cparams"] = cparams
        else:
            kwargs["cparams"] = {"typesize": itemsize}
        if blosc2.IS_WASM:
            # wasm32 runtime is effectively single-threaded for Blosc operations.
            cparams = kwargs.get("cparams")
            if isinstance(cparams, dict) and cparams.get("nthreads", 1) != 1:
                cparams = cparams.copy()
                cparams["nthreads"] = 1
                kwargs["cparams"] = cparams
            dparams = kwargs.get("dparams")
            if isinstance(dparams, dict) and dparams.get("nthreads", 1) != 1:
                dparams = dparams.copy()
                dparams["nthreads"] = 1
                kwargs["dparams"] = dparams

        # chunksize handling
        if chunksize is None:
            chunksize = 2**24
            if data is not None:
                if hasattr(data, "itemsize"):
                    chunksize = data.size * data.itemsize
                    # Make that a multiple of typesize
                    chunksize = chunksize // data.itemsize * data.itemsize
                else:
                    chunksize = len(data)
            # Use a cap of 256 MB (modern boxes should all have this RAM available)
            if chunksize > 2**28:
                chunksize = 2**28

        super().__init__(_schunk=sc, chunksize=chunksize, data=data, **kwargs)
        self._vlmeta = vlmeta(self, super().c_schunk)
        self._cparams = super().get_cparams()
        self._dparams = super().get_dparams()

    def __enter__(self) -> SChunk:
        """Enter a context manager and return this super-chunk."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
        """Exit a context manager.

        For regular :func:`blosc2.open` handles this is a logical no-op kept for
        API symmetry with higher-level persistent containers.
        """
        return False

    @property
    def cparams(self) -> blosc2.CParams:
        """
        :class:`blosc2.CParams` instance with the compression parameters.
        """
        return self._cparams

    @cparams.setter
    def cparams(self, value: blosc2.CParams) -> None:
        if blosc2.IS_WASM and value.nthreads != 1:
            value = replace(value, nthreads=1)
        super().update_cparams(value)
        self._cparams = super().get_cparams()

    @property
    def dparams(self) -> blosc2.DParams:
        """
        :class:`blosc2.DParams` instance with the decompression parameters.
        """
        return self._dparams

    @dparams.setter
    def dparams(self, value: blosc2.DParams) -> None:
        if blosc2.IS_WASM and value.nthreads != 1:
            value = replace(value, nthreads=1)
        super().update_dparams(value)
        self._dparams = super().get_dparams()

    @contextmanager
    def holding_lock(self) -> Iterator[SChunk]:
        """Hold the exclusive frame lock across several operations.

        When file locking is enabled on this handle (the `locking` storage
        parameter or the ``BLOSC_LOCKING`` environment variable), every
        operation locks and unlocks the on-disk container individually, so a
        sequence of operations is not atomic as a whole.  Operations performed
        inside this context manager nest on the already-held lock, making the
        whole block atomic against other handles and processes.

        Everything inside the block is serialized exclusively — including
        plain reads through other locked handles — so keep the block short.
        When locking is not enabled on this handle, this is a no-op.

        Examples
        --------
        >>> with schunk.holding_lock():  # doctest: +SKIP
        ...     schunk.update_data(0, data0, copy=True)
        ...     schunk.update_data(1, data1, copy=True)
        """
        super().lock()
        try:
            self.refresh()
            yield self
        finally:
            super().unlock()

    @property
    def meta(self) -> Meta:
        """
        Access to the fixed-length metadata of the `SChunk`.
        """
        return Meta(self)

    @property
    def vlmeta(self) -> vlmeta:
        """
        Access to the variable-length metadata of the `SChunk`.
        """
        return self._vlmeta

    @property
    def chunkshape(self) -> int:
        """
        Number of elements per chunk.
        """
        return self.chunksize // self.typesize

    @property
    def chunksize(self) -> int:
        """
        Number of bytes in each chunk.
        """
        return super().chunksize

    @property
    def blocksize(self) -> int:
        """The block size (in bytes)."""
        return super().blocksize

    @property
    def nchunks(self) -> int:
        """The number of chunks."""
        return super().nchunks

    def refresh(self) -> bool:
        """Re-sync the cached counters and metadata of a disk-based super-chunk.

        In a single-writer, multiple-readers (SWMR) workflow, a reader handle
        follows changes made through another handle (e.g. an :meth:`append_data`
        in another process) automatically on data access. Call this to observe
        changes such as :attr:`nchunks`, :attr:`nbytes` or :attr:`cbytes`
        without accessing data, e.g. when polling while waiting for a writer.

        Returns
        -------
        out: bool
            True if the cached state was re-synced with the on-disk state,
            False if it was already current. Always False for in-memory
            super-chunks.
        """
        return super().refresh()

    @property
    def change_tick(self) -> int:
        """Counter bumped whenever this handle re-syncs from a stale on-disk state.

        Useful to detect, without re-reading data, that another handle (in
        this process or another) has mutated the container since the last
        access — e.g. to invalidate a cache keyed off this schunk. Always 0
        for in-memory containers.
        """
        return super().change_tick

    @property
    def cratio(self) -> float:
        """
        Compression ratio.
        """
        if self.cbytes == 0:
            return 0.0
        return self.nbytes / self.cbytes

    @property
    def nbytes(self) -> int:
        """
        Amount of uncompressed data bytes.
        """
        return super().nbytes

    @property
    def cbytes(self) -> int:
        """
        Amount of compressed data bytes (data size + chunk headers size).
        """
        return super().cbytes

    @property
    def typesize(self) -> int:
        """
        Type size of the `SChunk`.
        """
        return super().typesize

    @property
    def urlpath(self) -> str:
        """
        Path where the `SChunk` is stored.
        """
        return super().urlpath

    @property
    def contiguous(self) -> bool:
        """
        Whether the `SChunk` is stored contiguously or sparsely.
        """
        return super().contiguous

    @property
    def info(self) -> InfoReporter:
        """
        Print information about this schunk.

        Examples
        --------
        >>> schunk = blosc2.SChunk(data=b"a large, repeated string" * 1000)
        >>> schunk.info
        type      : SChunk
        chunksize : 24000
        blocksize : 0
        typesize  : 1
        nbytes    : 24000 (23.44 KiB)
        cbytes    : 82 (82 B)
        cratio    : 292.68x
        cparams   : CParams(codec=<Codec.ZSTD: 5>, codec_meta=0, clevel=5, use_dict=False, typesize=1,
                  : nthreads=8, blocksize=0, splitmode=<SplitMode.AUTO_SPLIT: 3>,
                  : filters=[<Filter.NOFILTER: 0>, <Filter.NOFILTER: 0>, <Filter.NOFILTER: 0>,
                  : <Filter.NOFILTER: 0>, <Filter.NOFILTER: 0>, <Filter.SHUFFLE: 1>], filters_meta=[0,
                  : 0, 0, 0, 0, 0], tuner=<Tuner.STUNE: 0>)
        dparams   : DParams(nthreads=8)
        <BLANKLINE>
        """
        return InfoReporter(self)

    @property
    def info_items(self) -> list:
        """A list of tuples with the information about this schunk.
        Each tuple contains the name of the attribute and its value.
        """
        items = []
        items += [("type", f"{self.__class__.__name__}")]
        items += [("chunksize", self.chunksize)]
        items += [("blocksize", self.blocksize)]
        items += [("typesize", self.typesize)]
        items += [("nbytes", format_nbytes_info(self.nbytes))]
        items += [("cbytes", format_nbytes_info(self.cbytes))]
        items += [("cratio", f"{self.cratio:.2f}x")]
        items += [("cparams", self.cparams)]
        items += [("dparams", self.dparams)]
        return items

    def append_data(self, data: object) -> int:
        """Append a data buffer to the SChunk.

        The data buffer must be of size `chunksize` specified in
        :func:`SChunk.__init__ <blosc2.schunk.SChunk.__init__>`.

        Parameters
        ----------
        data: bytes-like object
            The data to be compressed and added as a chunk.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If the :paramref:`data` could not be appended.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> schunk = blosc2.SChunk(chunksize=200*1000*4)
        >>> data = np.arange(200 * 1000, dtype='int32')
        >>> schunk.append_data(data)
        1
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().append_data(data)

    def fill_special(
        self,
        nitems: int,
        special_value: blosc2.SpecialValue,
        value: bytes | int | float | bool | None = None,
    ) -> int:
        """Fill the SChunk with a special value. The SChunk must be empty.

        Parameters
        ----------
        nitems: int
            The number of items to fill with the special value.
        special_value: SpecialValue
            The special value to be used for filling the SChunk.
        value: bytes, int, float, bool (optional)
            The value to fill the SChunk. This parameter is only supported if
            :paramref:`special_value` is ``blosc2.SpecialValue.VALUE``.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If the SChunk could not be filled with the special value.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> import time
        >>> nitems = 100_000_000
        >>> dtype = np.dtype(np.float64)
        >>> # Measure the time to create SChunk from a NumPy array
        >>> t0 = time.time()
        >>> data = np.full(nitems, np.pi, dtype)
        >>> cparams = blosc2.CParams(typesize=dtype.itemsize)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> t = (time.time() - t0) * 1000.
        >>> f"Time creating a schunk with a numpy array: {t:10.3f} ms"
        Time creating a schunk with a numpy array:    710.273 ms
        >>> # Measure the time to create SChunk using fill_special
        >>> t0 = time.time()
        >>> cparams = blosc2.CParams(typesize=dtype.itemsize)
        >>> schunk = blosc2.SChunk(cparams=cparams)
        >>> schunk.fill_special(nitems, blosc2.SpecialValue.VALUE, np.pi)
        >>> t = (time.time() - t0) * 1000.
        >>> f"Time passing directly the value to `fill_special`: {t:10.3f} ms"
        Time passing directly the value to `fill_special`:      2.109 ms
        """
        if not isinstance(special_value, SpecialValue) or special_value == SpecialValue.NOT_SPECIAL:
            raise TypeError("special_value must be a SpecialValue instance other than NOT_SPECIAL")
        if special_value == SpecialValue.VALUE and value is None:
            raise ValueError("value cannot be None when special_value is VALUE")

        nchunks = super().fill_special(nitems, special_value.value, value)
        if nchunks < 0:
            raise RuntimeError("Unable to fill with special values")
        return nchunks

    def update_special(
        self,
        nchunk: int,
        special_value: blosc2.SpecialValue,
        value: bytes | int | float | bool | None = None,
    ) -> int:
        """Replace the chunk at :paramref:`nchunk` with a special value chunk.

        Useful as a cache-eviction primitive: replacing a chunk with a
        ``SpecialValue.UNINIT`` chunk reclaims its storage (on sparse/disk
        frames) without deleting the chunk slot, so it will be treated as
        unfetched and recomputed/refetched on next access.

        Parameters
        ----------
        nchunk: int
            The index of the chunk to replace.
        special_value: SpecialValue
            The special value to fill the chunk with.
        value: bytes, int, float, bool (optional)
            The value to fill the chunk with. Only supported if
            :paramref:`special_value` is ``blosc2.SpecialValue.VALUE``.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=400, cparams=cparams)
        >>> _ = schunk.append_data(np.arange(100, dtype=np.int32))
        >>> schunk.update_special(0, blosc2.SpecialValue.UNINIT)
        1
        """
        if not isinstance(special_value, SpecialValue) or special_value == SpecialValue.NOT_SPECIAL:
            raise TypeError("special_value must be a SpecialValue instance other than NOT_SPECIAL")
        if special_value == SpecialValue.VALUE and value is None:
            raise ValueError("value cannot be None when special_value is VALUE")
        if nchunk < 0 or nchunk >= self.nchunks:
            raise IndexError(f"nchunk must be in range [0, {self.nchunks}), got {nchunk}")

        chunk_items = self.chunksize // self.typesize
        total_items = self.nbytes // self.typesize
        nitems = min(chunk_items, total_items - nchunk * chunk_items)

        tmp = SChunk(chunksize=self.chunksize, cparams=blosc2.CParams(typesize=self.typesize))
        tmp.fill_special(nitems, special_value, value)
        return self.update_chunk(nchunk, tmp.get_chunk(0))

    def decompress_chunk(self, nchunk: int, dst: object = None) -> str | bytes:
        """Decompress the chunk given by its index :paramref:`nchunk`.

        Parameters
        ----------
        nchunk: int
            The index of the chunk that will be decompressed.
        dst: NumPy object or bytearray
            The destination NumPy object or bytearray to fill, the length
            of which must be greater than 0. The user must ensure
            that it has enough capacity to host the decompressed
            chunk. Default is None, meaning that a new bytes object
            is created, filled and returned.

        Returns
        -------
        out: str or bytes
            The decompressed chunk as a Python str or bytes object if
            :paramref:`dst` is `None`. Otherwise, it returns `None` because the
            result will already be in :paramref:`dst`.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> cparams = blosc2.CParams(typesize=1)
        >>> schunk = blosc2.SChunk(cparams=cparams)
        >>> buffer = b"wermqeoir23"
        >>> schunk.append_data(buffer)
        1
        >>> schunk.decompress_chunk(0)
        b'wermqeoir23'
        >>> # Construct a mutable bytearray object
        >>> bytes_obj = bytearray(len(buffer))
        >>> schunk.decompress_chunk(0, dst=bytes_obj)
        >>> bytes_obj == buffer
        True
        """
        return super().decompress_chunk(nchunk, dst)

    def get_chunk(self, nchunk: int) -> bytes:
        """Return the compressed chunk that is in the SChunk.

        Parameters
        ----------
        nchunk: int
            The index of the chunk that will be returned.

        Returns
        -------
        out: bytes object
            The compressed chunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create an SChunk with 3 chunks
        >>> nchunks = 3
        >>> data = np.arange(200 * 1000 * nchunks, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Retrieve the first chunk (index 0)
        >>> chunk = schunk.get_chunk(0)
        >>> # Check the type and length of the compressed chunk
        >>> type(chunk)
        <class 'bytes'>
        >>> len(chunk)
        10552
        """
        return super().get_chunk(nchunk)

    def get_vlblock(self, nchunk: int, nblock: int) -> bytes:
        """Return the decompressed payload of one VL block from a chunk."""
        return super().get_vlblock(nchunk, nblock)

    def delete_chunk(self, nchunk: int) -> int:
        """Delete the specified chunk from the SChunk.

        Parameters
        ----------
        nchunk: int
            The index of the chunk that will be removed.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create an SChunk with 3 chunks
        >>> nchunks = 3
        >>> data = np.arange(200 * 1000 * nchunks, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=200 * 1000 * 4, data=data, cparams=cparams)
        >>> # Check the number of chunks before deletion
        >>> schunk.nchunks
        3
        >>>  # Delete the second chunk (index 1)
        >>> schunk.delete_chunk(1)
        >>>  # Check the number of chunks after deletion
        >>> schunk.nchunks
        2
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().delete_chunk(nchunk)

    def insert_chunk(self, nchunk: int, chunk: bytes) -> int:
        """Insert an already compressed chunk into the SChunk.

        Parameters
        ----------
        nchunk: int
            The index at which the chunk will be inserted.
        chunk: bytes object
            The compressed chunk.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create an SChunk with 2 chunks
        >>> data = np.arange(400 * 1000, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=200*1000*4, data=data, cparams=cparams)
        >>> # Get a compressed chunk from the SChunk
        >>> chunk = schunk.get_chunk(0)
        >>> # Insert a chunk in the second position (index 1)"
        >>> schunk.insert_chunk(1, chunk)
        >>> # Verify the total number of chunks after insertion
        >>> schunk.nchunks
        3
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().insert_chunk(nchunk, chunk)

    def insert_data(self, nchunk: int, data: object, copy: bool) -> int:
        """Insert the data in the specified position in the SChunk.

        Parameters
        ----------
        nchunk: int
            The index at which the chunk will be inserted.
        data: bytes object
            The data that will be compressed and inserted as a chunk.
        copy: bool
            Whether to make an internal copy of the chunk to insert it or not.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create an SChunk with 2 chunks
        >>> data = np.arange(400 * 1000, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=200*1000*4, data=data, cparams=cparams)
        >>> # Create a new array to insert into the second chunk of the SChunk
        >>> new_data = np.arange(200 * 1000, dtype=np.int32)
        >>> # Insert the new data at position 1, compressing it
        >>> schunk.insert_data(1, new_data, copy=True)
        >>> # Verify the total number of chunks after insertion
        >>> schunk.nchunks
        3
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().insert_data(nchunk, data, copy)

    def append_chunk(self, chunk: bytes) -> int:
        """Append a compressed chunk to the end of the SChunk.

        Parameters
        ----------
        chunk: bytes object
            The compressed chunk to append.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RuntimeError
            If the chunk could not be appended.
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().append_chunk(chunk)

    def update_chunk(self, nchunk: int, chunk: bytes) -> int:
        """Update an existing chunk in the SChunk.

        Parameters
        ----------
        nchunk: int
            The index of the chunk to be updated.
        chunk: bytes object
            The new compressed chunk that will replace the old chunk's content.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 5
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=chunk_size, data=data, cparams=cparams)
        >>> f"Initial number of chunks: {schunk.nchunks}"
        Initial number of chunks: 5
        >>> c_index = 1
        >>> new_data = np.full(chunk_size // 4, fill_value=c_index, dtype=np.int32).tobytes()
        >>> compressed_data = blosc2.compress2(new_data, typesize=4)
        >>> # Update the 2nd chunk (index 1) with new data
        >>> nchunks = schunk.update_chunk(c_index, compressed_data)
        >>> f"Number of chunks after update: {nchunks}"
        Number of chunks after update: 5
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().update_chunk(nchunk, chunk)

    def reorder_offsets(self, order: Any) -> None:
        """Reorder the chunk offsets of the SChunk in place.

        This is a low-level storage operation that changes the physical chunk
        order of the underlying SChunk. Higher-level containers backed by this
        SChunk will observe the reordered chunk traversal afterwards.

        Parameters
        ----------
        order: array-like of int
            A one-dimensional permutation of ``range(self.nchunks)`` describing
            the new chunk order.

        Raises
        ------
        ValueError
            If ``order`` is not one-dimensional or its length does not match
            the number of chunks in the SChunk.
        RuntimeError
            If the underlying reorder operation fails.
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        order = np.asarray(order, dtype=np.int64)
        if order.ndim != 1:
            raise ValueError("`order` must be a one-dimensional sequence")
        if len(order) != self.nchunks:
            raise ValueError("`order` must have exactly `self.nchunks` elements")
        super().reorder_offsets(order)

    def update_data(self, nchunk: int, data: object, copy: bool) -> int:
        """Update the chunk in the specified position with the given data.

        Parameters
        ----------
        nchunk: int
            The index of the chunk to be updated.
        data: bytes object
            The data to be compressed and will replace the old chunk.
        copy: bool
             Whether to make an internal copy of the chunk before updating it.

        Returns
        -------
        out: int
            The number of chunks in the SChunk.

        Raises
        ------
        RunTimeError
            If a problem is detected.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 4
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=chunk_size, data=data, cparams=cparams)
        >>> f"Initial number of chunks: {schunk.nchunks}"
        Initial number of chunks: 4
        >>> c_index = 1 # Update the 2nd chunk (index 1)
        >>> new_data = np.full(chunk_size // 4, fill_value=c_index, dtype=np.int32).tobytes()
        >>> nchunks = schunk.update_data(c_index, new_data, copy=True)
        >>> f"Number of chunks after update: {schunk.nchunks}"
        Number of chunks after update: 4
        """
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        nchunks = super().nchunks
        return super().update_data(nchunk, data, copy) if nchunks > 0 else nchunks

    def get_slice(self, start: int = 0, stop: int | None = None, out: object = None) -> str | bytes | None:
        """Get a slice from :paramref:`start` to :paramref:`stop`.

        Parameters
        ----------
        start: int
            The starting index of the slice. Default is 0.
        stop: int
            The ending index of the slice (exclusive).
            Default is until the SChunk ends.
        out: bytes-like object or bytearray
            The target object (supporting the
            `Buffer Protocol <https://docs.python.org/3/c-api/buffer.html>`_) to fill.
            Verify that the buffer has enough space for the decompressed data.
            If `None` is provided, a new bytes object will be created, filled,
            and returned.

        Returns
        -------
        out: str or bytes or None
            The decompressed slice a Python str or bytes object if
            :paramref:`out` is `None`. Otherwise, it returns `None` since the result
            will already be in :paramref:`out`.

        Raises
        ------
        ValueError
            If the size to get is negative.
            If there is not enough space in :paramref:`out`.
            If :paramref:`start` is greater or equal to the number of items in the SChunk.
        RunTimeError
            If a problem is detected.

        See Also
        --------
        :func:`__getitem__`

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 4
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Define the slice parameters
        >>> start_index = 200 * 1000
        >>> stop_index = 2 * 200 * 1000
        >>> # Prepare an output buffer
        >>> slice_size = stop_index - start_index
        >>> out_buffer = bytearray(slice_size * 4)  # Ensure the buffer is large enough
        >>> result = schunk.get_slice(start=start_index, stop=stop_index, out=out_buffer)
        >>> # Convert bytearray to NumPy array for easier inspection
        >>> slice_array = np.frombuffer(out_buffer, dtype=np.int32)
        >>> f"Slice data: {slice_array[:10]} ..."  # Print the first 10 elements
        Slice data: [200000 200001 200002 200003 200004 200005 200006 200007 200008 200009] ...
        """
        return super().get_slice(start, stop, out)

    def __len__(self) -> int:
        """
        Return the number of items in the SChunk.
        """
        return self.nbytes // self.typesize

    def __getitem__(self, item: int | slice) -> str | bytes:
        """Get a slice from the SChunk.

        Parameters
        ----------
        item: int or slice
            The index or slice for the data. Note that the step parameter is not honored.

        Returns
        -------
        out: str or bytes
            The decompressed slice as a Python str or bytes object.

        Raises
        ------
        ValueError
            If the size to get is negative.
            If :paramref:`item`.start is greater than or equal to the number of
            items in the SChunk.
        RunTimeError
            If a problem is detected.
        IndexError
            If `step` is not 1.

        See Also
        --------
        :func:`get_slice`

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 4
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(chunksize=chunk_size, data=data, cparams=cparams)
        >>> # Use __getitem__ to retrieve the same slice of data from the SChunk
        >>> res = schunk[150:155]
        >>> f"Slice data: {np.frombuffer(res, dtype=np.int32)}"
        Slice data: [150 151 152 153 154]
        """
        if isinstance(item, int):
            if item == -1:
                return self.get_slice(item)
            return self.get_slice(item, item + 1)
        if item.step is not None and item.step != 1:
            raise IndexError("`step` must be 1")
        return self.get_slice(item.start, item.stop)

    def __setitem__(self, key: int | slice, value: object) -> None:
        """Set slice to :paramref:`value`.

        Parameters
        ----------
        key: int or slice
            The index of the slice to update. Note that step parameter is not honored.
        value: bytes-like object
            An object supporting the
            `Buffer Protocol <https://docs.python.org/3/c-api/buffer.html>`_ used to
            fill the slice.

        Returns
        -------
        out: None

        Raises
        ------
        ValueError
            If the object cannot be modified.
            If the size to get is negative.
            If there is not enough space in :paramref:`value` to update the slice.
            If :paramref:`start` is greater than the number of items in the SChunk.
        RunTimeError
            If a problem is detected.
        IndexError
            If `step` is not 1.

        Notes
        -----
        This method can also be used to append new data if :paramref:`key`.stop
        is greater than the number of items in the SChunk.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 4
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Create a new array of values to update the slice (values from 1000 to 1999 multiplied by 2)
        >>> start_ = 1000
        >>> stop = 2000
        >>> new_values = np.arange(start_, stop, dtype=np.int32) * 2
        >>> schunk[start_:stop] = new_values
        >>> # Retrieve the updated slice using the slicing syntax
        >>> retrieved_slice = np.frombuffer(schunk[start_:stop], dtype=np.int32)
        >>> f"First 10 values of the updated slice: {retrieved_slice[:10]}"
        >>> f"Last 10 values of the updated slice: {retrieved_slice[-10:]}"
        First 10 values of the updated slice: [2000 2002 2004 2006 2008 2010 2012 2014 2016 2018]
        Last 10 values of the updated slice: [3980 3982 3984 3986 3988 3990 3992 3994 3996 3998]
        """
        if key.step is not None and key.step != 1:
            raise IndexError("`step` must be 1")
        blosc2_ext.check_access_mode(self.urlpath, self.mode)
        return super().set_slice(start=key.start, stop=key.stop, value=value)

    def to_cframe(self) -> bytes:
        """Get a bytes object containing the serialized :ref:`SChunk` instance.

        Returns
        -------
        out: bytes
            The buffer containing the serialized :ref:`SChunk` instance.

        See Also
        --------
        :func:`~blosc2.schunk_from_cframe`

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> nchunks = 4
        >>> chunk_size = 200 * 1000 * 4
        >>> data = np.arange(nchunks * chunk_size // 4, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Serialize the SChunk instance to a bytes object
        >>> serialized_schunk = schunk.to_cframe()
        >>> f"Serialized SChunk length: {len(serialized_schunk)} bytes"
        Serialized SChunk length: 14129 bytes
        >>> # Create a new SChunk from the serialized data
        >>> deserialized_schunk = blosc2.schunk_from_cframe(serialized_schunk)
        >>> start = 500
        >>> stop = 505
        >>> sl_bytes = deserialized_schunk[start:stop]
        >>> sl = np.frombuffer(sl_bytes, dtype=np.int32)
        >>> res = data[start:stop]
        >>> f"Original slice: {res}"
        Original slice: [500 501 502 503 504]
        >>> f"Deserialized slice: {sl}"
        Deserialized slice: [500 501 502 503 504]
        """
        return super().to_cframe()

    def iterchunks(self, dtype: np.dtype) -> Iterator[np.ndarray]:
        """
        Iterate over the :paramref:`self` chunks of the SChunk.

        Parameters
        ----------
        dtype: np.dtype
            The data type to use for the decompressed chunks.

        Yields
        ------
        chunk: NumPy ndarray
           The decompressed chunk.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create sample data and an SChunk
        >>> data = np.arange(400 * 1000, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Iterate over chunks using the iterchunks method
        >>> for chunk in schunk.iterchunks(dtype=np.int32):
        >>>     f"Chunk shape: {chunk.shape} "
        >>>     f"First 5 elements of chunk: {chunk[:5]}"
        Chunk shape: (400000,)
        First 5 elements of chunk: [0 1 2 3 4]
        """
        out = np.empty(self.chunkshape, dtype)
        for i in range(0, len(self), self.chunkshape):
            self.get_slice(i, i + self.chunkshape, out)
            yield out

    def iterchunks_info(
        self,
    ) -> Iterator[
        NamedTuple(
            "info",
            nchunk=int,
            cratio=float,
            special=blosc2.SpecialValue,
            repeated_value=bytes | None,
            lazychunk=bytes,
        )
    ]:
        """
        Iterate over the chunks of the SChunk, providing info on index and special values.

        Yields
        ------
        info: namedtuple
            A namedtuple with the following fields:

                nchunk: int
                    The index of the chunk.
                cratio: float
                    The compression ratio of the chunk.
                special: :class:`~blosc2.SpecialValue`
                    The special value enum of the chunk; if 0, the chunk is not special.
                repeated_value: bytes or None
                    The repeated value for the chunk; if not SpecialValue.VALUE, it is None.
                lazychunk: bytes
                    A buffer with the complete lazy chunk.

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> # Create sample data and an SChunk
        >>> data = np.arange(400 * 1000, dtype=np.int32)
        >>> cparams = blosc2.CParams(typesize=4)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams)
        >>> # Iterate over chunks and print detailed information
        >>> for chunk_info in schunk.iterchunks_info():
        >>>     f"Chunk index: {chunk_info.nchunk}"
        >>>     f"Compression ratio: {chunk_info.cratio:.2f}"
        >>>     f"Special value: {chunk_info.special.name}"
        >>>     f"Repeated value: {chunk_info.repeated_value[:10] if chunk_info.repeated_value else None}"
        Chunk index: 0
        Compression ratio: 223.56
        Special value: NOT_SPECIAL
        Repeated value: None
        """
        ChunkInfo = namedtuple("ChunkInfo", ["nchunk", "cratio", "special", "repeated_value", "lazychunk"])
        for nchunk in range(self.nchunks):
            lazychunk = self.get_lazychunk(nchunk)
            # Blosc2 flags are encoded at the end of the header
            # (see https://github.com/Blosc/c-blosc2/blob/main/README_CHUNK_FORMAT.rst)
            is_special = (lazychunk[31] & 0x70) >> 4
            special = SpecialValue(is_special)
            # The special value is encoded at the end of the header
            repeated_value = lazychunk[32:] if special == SpecialValue.VALUE else None
            # Compression ratio (nbytes and cbytes are little-endian)
            cratio = (
                np.frombuffer(lazychunk[4:8], dtype="<i4")[0]
                / np.frombuffer(lazychunk[12:16], dtype="<i4")[0]
            )
            yield ChunkInfo(nchunk, cratio, special, repeated_value, lazychunk)

    def postfilter(self, input_dtype: np.dtype, output_dtype: np.dtype = None) -> None:
        """Decorator to set a function as a postfilter.

        The postfilter function will be executed each time after decompressing
        blocks of data. It will receive three parameters:

        * the input `ndarray` to be read from
        * the output `ndarray` to be filled out
        * the offset inside the `SChunk` instance where the corresponding block begins (see example below).

        Parameters
        ----------
        input_dtype: np.dtype
            Data type of the input that will receive the postfilter function.
        output_dtype: np.dtype
            Data type of the output that will receive and fill the postfilter function.
            If None (default) it will be set to :paramref:`input_dtype`.

        Returns
        -------
        out: None

        Notes
        -----
        * `nthreads` must be 1 when decompressing.

        * The :paramref:`input_dtype` itemsize must be the same as the
          :paramref:`output_dtype` itemsize.

        See Also
        --------
        :meth:`remove_postfilter`
        :meth:`prefilter`

        Examples
        --------
        .. code-block:: python

            # Create SChunk
            input_dtype = np.dtype(np.int64)
            cparams = blosc2.CParams(typesize=input_dtype.itemsize)
            dparams = blosc2.DParams(nthreads=1)
            schunk = blosc2.SChunk(
                chunksize=20_000 * input_dtype.itemsize, cparams=cparams, dparams=dparams
            )


            # Create postfilter and associate it to the schunk
            @schunk.postfilter(input_dtype)
            def postfilter(input, output, offset):
                output[:] = offset + np.arange(input.size)
        """

        def initialize(func):
            super(SChunk, self)._set_postfilter(func, input_dtype, output_dtype)

            def exec_func(*args):
                func(*args)

            return exec_func

        return initialize

    def remove_postfilter(self, func_name: str, _new_ctx: bool = True) -> None:
        """Remove the postfilter from the `SChunk` instance.

        Parameters
        ----------
        func_name: str
            The name of the postfilter function to remove.

        Returns
        -------
        out: None

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> dtype = np.dtype(np.int32)
        >>> cparams = blosc2.CParams(typesize=dtype.itemsize)
        >>> dparams = blosc2.DParams(nthreads=1)
        >>> data = np.arange(500, dtype=np.int32)
        >>> schunk = blosc2.SChunk(data=data, cparams=cparams, dparams=dparams)
        >>> # Define the postfilter function
        >>> @schunk.postfilter(dtype)
        >>> def postfilter(input, output, offset):
        >>>     output[:] = input + offset + np.arange(input.size)
        >>> out = np.empty(data.size, dtype=dtype)
        >>> schunk.get_slice(out=out)
        >>> f"Data slice with postfilter applied (first 8 elements): {out[:8]}"
        Data slice with postfilter applied (first 8 elements): [ 0  2  4  6  8 10 12 14]
        >>> schunk.remove_postfilter('postfilter')
        >>> retrieved_data = np.empty(data.size, dtype=dtype)
        >>> schunk.get_slice(out=retrieved_data)
        >>> f"Original data (first 8 elements): {data[:8]}"
        Original data (first 8 elements): [0 1 2 3 4 5 6 7]
        """
        return super().remove_postfilter(func_name)

    def filler(self, inputs_tuple: tuple[tuple], schunk_dtype: np.dtype, nelem: int | None = None) -> None:
        """Decorator to set a filler function.

        This function will fill :paramref:`self` according to :paramref:`nelem`.
        It will receive three parameters: a tuple with the inputs as `ndarrays`
        from which to read, the `ndarray` to fill :paramref:`self` and the
        offset inside the `SChunk` instance where the corresponding block
        begins (see example below).

        Parameters
        ----------
        inputs_tuple: tuple of tuples
            Tuple containing a tuple for each argument that the function will receive, along with their
            corresponding np.dtype.
            Supported operand types are :ref:`SChunk`, `ndarray` and
            Python scalars.
        schunk_dtype: np.dtype
            The data type to use to fill :paramref:`self`.
        nelem: int
            Number of elements to append to :paramref:`self`. If None (default) it
            will be the number of elements from the operands.

        Returns
        -------
        out: None

        Notes
        -----
        * Compression `nthreads` must be 1 when using this.
        * This does not need to be removed from the created `SChunk` instance.

        See Also
        --------
        :meth:`prefilter`

        Examples
        --------
        .. code-block:: python

            # Set the compression and decompression parameters
            schunk_dtype = np.dtype(np.float64)
            cparams = blosc2.CParams(typesize=schunk_dtype.itemsize, nthreads=1)
            # Create empty SChunk
            schunk = blosc2.SChunk(chunksize=20_000 * schunk_dtype.itemsize, cparams=cparams)

            # Create operands
            op_dtype = np.dtype(np.int32)
            data = np.full(20_000 * 3, 12, dtype=op_dtype)
            schunk_op = blosc2.SChunk(chunksize=20_000 * op_dtype.itemsize, data=data)


            # Create filler
            @schunk.filler(((schunk_op, op_dtype), (np.e, np.float32)), schunk_dtype)
            def filler(inputs_tuple, output, offset):
                output[:] = inputs_tuple[0] - inputs_tuple[1]

        """

        def initialize(func):
            if self.nbytes != 0:
                raise ValueError("Cannot apply a filler to a non empty SChunk")
            nelem_ = blosc2_ext.nelem_from_inputs(inputs_tuple, nelem)
            super(SChunk, self)._set_filler(func, id(inputs_tuple), schunk_dtype)
            chunksize = self.chunksize
            written_nbytes = 0
            nbytes = nelem_ * self.typesize
            while written_nbytes < nbytes:
                chunk = np.zeros(chunksize // self.typesize, dtype=schunk_dtype)
                self.append_data(chunk)
                written_nbytes += chunksize
                if (nbytes - written_nbytes) < self.chunksize:
                    chunksize = nbytes - written_nbytes
            self.remove_prefilter(func.__name__)

            def exec_func(*args):
                func(*args)

            return exec_func

        return initialize

    def prefilter(self, input_dtype: np.dtype, output_dtype: np.dtype = None) -> None:
        """Decorator to set a function as a prefilter.

        This function will be executed each time before compressing the data.
        It will receive three parameters:

        * The actual data as a `ndarray` from which to read,
        * The `ndarray` to be filled,
        * The offset inside the `SChunk` instance where the corresponding block begins (see example below).

        Parameters
        ----------
        input_dtype: np.dtype
            Data type of the input that will be processed the prefilter function.
        output_dtype: np.dtype, optional
            Data type of the output that will be filled by the prefilter function.
            If None (default), it will be the same as :paramref:`input_dtype`.

        Returns
        -------
        out: None

        Notes
        -----
        * `nthreads` must be 1 when compressing.

        * The :paramref:`input_dtype` itemsize must be the same as the
          :paramref:`output_dtype` itemsize.

        See Also
        --------
        :meth:`remove_prefilter`
        :meth:`postfilter`
        :meth:`filler`

        Examples
        --------
        .. code-block:: python

            # Set the compression and decompression parameters
            input_dtype = np.dtype(np.int32)
            output_dtype = np.dtype(np.float32)
            cparams = blosc2.CParams(typesize=output_dtype.itemsize, nthreads=1)
            # Create schunk
            schunk = blosc2.SChunk(chunksize=200 * 1000 * input_dtype.itemsize, cparams=cparams)


            # Set prefilter with decorator
            @schunk.prefilter(input_dtype, output_dtype)
            def prefilter(input, output, offset):
                output[:] = input - np.pi
        """

        def initialize(func):
            super(SChunk, self)._set_prefilter(func, input_dtype, output_dtype)

            def exec_func(*args):
                func(*args)

            return exec_func

        return initialize

    def remove_prefilter(self, func_name: str, _new_ctx: bool = True) -> None:
        """Remove the prefilter from the `SChunk` instance.

        Parameters
        ----------
        func_name: str
            Name of the prefilter function.

        Returns
        -------
        out: None

        Examples
        --------
        >>> import blosc2
        >>> import numpy as np
        >>> dtype = np.dtype(np.int32)
        >>> cparams = blosc2.CParams(typesize=dtype.itemsize, nthreads=1)
        >>> data = np.arange(1000, dtype=np.int32)
        >>> output_dtype = np.float32
        >>> schunk = blosc2.SChunk(cparams=cparams)
        >>> # Define the prefilter function
        >>> @schunk.prefilter(dtype, output_dtype)
        >>> def prefilter(input, output, offset):
        >>>     output[:] = input - np.pi
        >>> schunk[:1000] = data
        >>> # Retrieve and convert compressed data with the prefilter to a NumPy array.
        >>> compressed_array_with_filter = np.frombuffer(schunk.get_slice(), dtype=output_dtype)
        >>> f"Compressed data with prefilter applied (first 8 elements): {compressed_array_with_filter[:8]}"
        Compressed data with prefilter applied (first 8 elements): [-3.1415927  -2.1415927  -1.1415926  -0.14159265  0.8584073   1.8584074
         2.8584073   3.8584073 ]
        >>> schunk.remove_prefilter('prefilter')
        >>> schunk[:1000] = data
        >>> compressed_array_without_filter = np.frombuffer(schunk.get_slice(), dtype=dtype)
        >>> f"Compressed data without prefilter (first 8 elements): {compressed_array_without_filter[:8]}"
        Compressed data without prefilter (first 8 elements): [0. 1. 2. 3. 4. 5. 6. 7.]
        """
        return super().remove_prefilter(func_name)

    def __dealloc__(self):
        super().__dealloc__()


def _meta_from_store(urlpath, offset):
    """Try to read the SChunk meta from a store path (b2e, b2d, or b2z)."""

    def _open_meta(path, off=0):
        try:
            return blosc2.blosc2_ext.open(path, mode="r", offset=off).meta
        except Exception:
            return None

    if urlpath.endswith(".b2e") and offset == 0:
        return _open_meta(urlpath)
    if os.path.isdir(urlpath):
        embed_path = os.path.join(urlpath, "embed.b2e")
        if os.path.exists(embed_path):
            return _open_meta(embed_path)
    if os.path.isfile(urlpath) and not urlpath.endswith(".b2e"):
        try:
            with open(urlpath, "rb") as f, zipfile.ZipFile(f) as zf:
                for info in zf.infolist():
                    if info.filename == "embed.b2e":
                        f.seek(info.header_offset)
                        local_header = f.read(30)
                        filename_len = int.from_bytes(local_header[26:28], "little")
                        extra_len = int.from_bytes(local_header[28:30], "little")
                        data_offset = info.header_offset + 30 + filename_len + extra_len
                        return _open_meta(urlpath, data_offset)
        except Exception:
            pass
    return None


def _store_from_extension(urlpath, mode, offset, **kwargs):
    """Dispatch to the right store constructor based on file extension."""
    if urlpath.endswith(".b2d"):
        if offset != 0:
            raise ValueError("Offset must be 0 for DictStore")
        from blosc2.dict_store import DictStore

        return DictStore(urlpath, mode=mode, **kwargs)
    if urlpath.endswith(".b2z"):
        if offset != 0:
            raise ValueError("Offset must be 0 for TreeStore")
        from blosc2.tree_store import TreeStore

        return TreeStore(urlpath, mode=mode, **kwargs)
    if urlpath.endswith(".b2e"):
        if offset != 0:
            raise ValueError("Offset must be 0 for EmbedStore")
        from blosc2.embed_store import EmbedStore

        return EmbedStore(urlpath, mode=mode, **kwargs)
    return None


def _resolve_store_alias(urlpath):
    if os.path.exists(urlpath) or urlpath.endswith((".b2d", ".b2z", ".b2e")):
        return urlpath
    for suffix in (".b2d", ".b2z", ".b2e"):
        candidate = urlpath + suffix
        if os.path.exists(candidate):
            return candidate
    return urlpath


def _open_special_store(urlpath, mode, offset, **kwargs):
    # Meta-based detection has priority over extension
    schunk_meta = _meta_from_store(urlpath, offset)
    if schunk_meta is not None:
        if "b2embed" in schunk_meta:
            if offset != 0:
                raise ValueError("Offset must be 0 for EmbedStore")
            from blosc2.embed_store import EmbedStore

            return EmbedStore(urlpath, mode=mode, **kwargs)
        if "b2dict" in schunk_meta:
            if offset != 0:
                raise ValueError("Offset must be 0 for DictStore")
            from blosc2.dict_store import DictStore

            return DictStore(urlpath, mode=mode, **kwargs)
        if "b2tree" in schunk_meta:
            if offset != 0:
                raise ValueError("Offset must be 0 for TreeStore")
            from blosc2.tree_store import TreeStore

            return TreeStore(urlpath, mode=mode, **kwargs)

    return _store_from_extension(urlpath, mode, offset, **kwargs)


def _set_default_dparams(kwargs):
    dparams = kwargs.get("dparams")
    if dparams is None:
        # Use multiple threads for decompression by default, unless we are in WASM
        # (does not support threads).  The only drawback for using multiple threads
        # is that access time will be slower because of the overhead of spawning threads
        # (but could be fixed in the future with more intelligent thread pools).
        dparams = (
            blosc2.DParams(nthreads=blosc2.nthreads) if not blosc2.IS_WASM else blosc2.DParams(nthreads=1)
        )
        kwargs["dparams"] = dparams
    if blosc2.IS_WASM:
        dparams = kwargs.get("dparams")
        if isinstance(dparams, blosc2.DParams) and dparams.nthreads != 1:
            dparams = asdict(dparams)
            dparams["nthreads"] = 1
            kwargs["dparams"] = dparams
        elif isinstance(dparams, dict) and dparams.get("nthreads", 1) != 1:
            dparams = dparams.copy()
            dparams["nthreads"] = 1
            kwargs["dparams"] = dparams


def process_opened_object(res):
    meta = getattr(res, "schunk", res).meta
    if "proxy-source" in meta:
        proxy_cache = res
        proxy_src = meta["proxy-source"]
        if proxy_src["local_abspath"] is not None:
            src = blosc2.open(proxy_src["local_abspath"], mode="r")
            return blosc2.Proxy(src, _cache=proxy_cache)
        elif proxy_src["urlpath"] is not None:
            src = blosc2.C2Array(proxy_src["urlpath"][0], proxy_src["urlpath"][1], proxy_src["urlpath"][2])
            return blosc2.Proxy(src, _cache=proxy_cache)
        elif not proxy_src["caterva2_env"]:
            raise RuntimeError("Could not find the source when opening a Proxy")

    if "b2o" in meta:
        return blosc2.open_b2object(res)

    if "listarray" in meta:
        from blosc2.list_array import ListArray

        return ListArray(_from_schunk=getattr(res, "schunk", res))

    if "vlarray" in meta:
        from blosc2.objectarray import ObjectArray

        return ObjectArray(_from_schunk=getattr(res, "schunk", res))

    if "batcharray" in meta:
        from blosc2.batch_array import BatchArray

        return BatchArray(_from_schunk=getattr(res, "schunk", res))

    if isinstance(res, blosc2.NDArray) and "LazyArray" in res.schunk.meta:
        return blosc2.open_lazyarray(res)
    else:
        return res


def _read_treestore_root_manifest(store):
    try:
        meta_obj = store["/_meta"]
    except KeyError:
        return None

    if not isinstance(meta_obj, blosc2.SChunk):
        raise ValueError("TreeStore root manifest '/_meta' must be an SChunk.")

    vlmeta = meta_obj.vlmeta
    try:
        kind = vlmeta["kind"]
    except KeyError as exc:
        raise ValueError("TreeStore root manifest is missing required field 'kind'.") from exc
    try:
        version = vlmeta["version"]
    except KeyError as exc:
        raise ValueError("TreeStore root manifest is missing required field 'version'.") from exc

    if isinstance(kind, bytes):
        kind = kind.decode()
    if not isinstance(kind, str):
        raise ValueError("TreeStore root manifest field 'kind' must be a string.")
    if not isinstance(version, int):
        raise ValueError("TreeStore root manifest field 'version' must be an integer.")

    return {"kind": kind, "version": version, "meta": meta_obj}


def _open_treestore_root_object(store, urlpath, mode):
    manifest = _read_treestore_root_manifest(store)
    if manifest is None:
        return store

    if manifest["kind"] == "ctable":
        if mode not in {"r", "a"}:
            return store
        # Reuse the TreeStore that was opened to inspect the root manifest.
        # This avoids a second TreeStore open when dispatching root CTables.
        return blosc2.CTable._open_from_existing_filestore(urlpath, mode=mode, store=store)

    return store


def _finalize_special_open(special, urlpath, mode):
    if special is None:
        return None
    if isinstance(special, blosc2.TreeStore):
        return _open_treestore_root_object(special, urlpath, mode)
    return special


def open(
    urlpath: str | pathlib.Path | blosc2.URLPath,
    mode: str = "r",
    offset: int = 0,
    **kwargs: dict,
) -> (
    blosc2.SChunk
    | blosc2.NDArray
    | blosc2.BatchArray
    | blosc2.ObjectArray
    | blosc2.C2Array
    | blosc2.LazyArray
    | blosc2.Proxy
    | blosc2.DictStore
    | blosc2.TreeStore
    | blosc2.EmbedStore
):
    """Open a persistent :ref:`SChunk`, :ref:`NDArray`, a remote :ref:`C2Array`,
    a :ref:`Proxy`, a :ref:`DictStore`, :ref:`EmbedStore`, or :ref:`TreeStore`.

    See the `Notes` section for more info on opening `Proxy` objects.

    Parameters
    ----------
    urlpath: str | pathlib.Path | :ref:`URLPath`
        The path where the :ref:`SChunk` (or :ref:`NDArray`)
        is stored. If it is a remote array, a :ref:`URLPath` must be passed.
    mode: str, optional
        Persistence mode: 'r' means read only (must exist);
        'a' means read/write (create if it doesn't exist);
        'w' means create (overwrite if it exists). Defaults to 'r' (
        read-only).

        Open modes also define the allowed persistence side effects:

        - ``'r'`` never writes to the persistent object or any sidecar/cache file.
          Query acceleration and other execution caches remain process-local only.
        - ``'a'`` and ``'w'`` may persist explicit user-visible changes such as data,
          metadata, and index maintenance, but execution caches and query memoization
          still remain process-local only.
    offset: int, optional
        An offset in the file where super-chunk or array data is located
        (e.g. in a file containing several such objects).
    kwargs: dict, optional
        mmap_mode: str, optional
            If set, the file will be memory-mapped instead of using the default
            I/O functions and the `mode` argument will be ignored.
            For more info, see :class:`blosc2.Storage`. Please note that the `w+` mode, which
            can be used to create new files, is not supported here since only existing files
            can be opened. You can use :func:`SChunk.__init__ <blosc2.schunk.SChunk.__init__>`
            to create new files.
        initial_mapping_size: int, optional
            The initial size of the memory mapping. For more info, see :class:`blosc2.Storage`.
        locking: bool, optional
            Serialize accesses against other handles and other processes via a
            sidecar lock file. Enable it when several processes operate on the
            same container. The locking is advisory (every handle on the
            container must enable it) and cannot be combined with `mmap_mode`.
            The ``BLOSC_LOCKING`` environment variable enables it globally.
            For more info, see :class:`blosc2.Storage`.
        cparams: dict
            A dictionary with the compression parameters, which are the same that can be
            used in the :func:`~blosc2.compress2` function.
            Typesize and blocksize cannot be changed.
        dparams: dict
            A dictionary with the decompression parameters, which are the same that can
            be used in the :func:`~blosc2.decompress2` function.

    Returns
    -------
    out: :ref:`SChunk`, :ref:`NDArray`, :ref:`C2Array`, :ref:`DictStore`, :ref:`EmbedStore`, or :ref:`TreeStore`
        The object found in the path.

    Notes
    -----
    * Returned objects can be used as context managers for API consistency.
      For objects with an explicit ``close()`` implementation, exiting the
      context will close/flush them; for logical handles such as regular
      :class:`SChunk`, :class:`NDArray`, :class:`C2Array`, :class:`Proxy`, and
      :class:`LazyArray`, exiting the context is currently a no-op.

    * If :paramref:`urlpath` is a :ref:`URLPath` instance, :paramref:`mode`
      must be 'r', :paramref:`offset` must be 0, and kwargs cannot be passed.

    * Persistent data handling follows a strict no-hidden-writes rule:

      - ``mode='r'`` is observational only and never mutates the opened object.
      - ``mode='a'`` / ``mode='w'`` only persist explicit mutations requested by the
        caller; runtime caches are not serialized back to disk.

    * If the original object saved in :paramref:`urlpath` is a :ref:`Proxy`,
      this function will only return a :ref:`Proxy` if its source is a local
      :ref:`SChunk`, :ref:`NDArray` or a remote :ref:`C2Array`. Otherwise,
      it will return the Python-Blosc2 container used to cache the data which
      can be a :ref:`SChunk` or a :ref:`NDArray` and may not have all the data
      initialized (e.g. if the user has not accessed to it yet).

    * When opening a :ref:`LazyExpr` keep in mind the note above regarding operands.

    Examples
    --------
    >>> import blosc2
    >>> import numpy as np
    >>> import os
    >>> import tempfile
    >>> tmpdirname = tempfile.mkdtemp()
    >>> urlpath = os.path.join(tmpdirname, 'b2frame')
    >>> storage = blosc2.Storage(contiguous=True, urlpath=urlpath, mode="w")
    >>> nelem = 20 * 1000
    >>> nchunks = 5
    >>> chunksize = nelem * 4 // nchunks
    >>> data = np.arange(nelem, dtype="int32")
    >>> # Create SChunk and append data
    >>> schunk = blosc2.SChunk(chunksize=chunksize, data=data.tobytes(), storage=storage)
    >>> # Open SChunk
    >>> sc_open = blosc2.open(urlpath=urlpath, mode="r")
    >>> for i in range(nchunks):
    ...     dest = np.empty(nelem // nchunks, dtype=data.dtype)
    ...     schunk.decompress_chunk(i, dest)
    ...     dest1 = np.empty(nelem // nchunks, dtype=data.dtype)
    ...     sc_open.decompress_chunk(i, dest1)
    ...     np.array_equal(dest, dest1)
    True
    True
    True
    True
    True

    To open the same schunk memory-mapped, we simply need to pass the `mmap_mode` parameter:

    >>> sc_open_mmap = blosc2.open(urlpath=urlpath, mode="r", mmap_mode="r")
    >>> sc_open.nchunks == sc_open_mmap.nchunks
    True
    >>> all(sc_open.decompress_chunk(i, dest1) == sc_open_mmap.decompress_chunk(i, dest1) for i in range(nchunks))
    True
    """
    if isinstance(urlpath, blosc2.URLPath):
        if mode != "r" or offset != 0 or kwargs != {}:
            raise NotImplementedError(
                "Cannot open a C2Array with mode != 'r', or offset != 0 or some kwargs"
            )
        return blosc2.C2Array(urlpath.path, urlbase=urlpath.urlbase, auth_token=urlpath.auth_token)

    if isinstance(urlpath, pathlib.PurePath):
        urlpath = str(urlpath)

    # Keep explicit store paths on the direct dispatch path.  For regular
    # Blosc containers, try the standard open first and only fall back to the
    # more expensive store probing when that fails.
    if urlpath.endswith((".b2d", ".b2z", ".b2e")):
        special = _open_special_store(urlpath, mode, offset, **kwargs)
        special = _finalize_special_open(special, urlpath, mode)
        if special is not None:
            return special

    regular_exc = None
    if os.path.exists(urlpath):
        _set_default_dparams(kwargs)
        try:
            res = blosc2_ext.open(urlpath, mode, offset, **kwargs)
        except Exception as exc:
            regular_exc = exc
        else:
            return process_opened_object(res)

    resolved_urlpath = _resolve_store_alias(urlpath)
    special_path = (
        resolved_urlpath if resolved_urlpath != urlpath or not os.path.exists(urlpath) else urlpath
    )
    special = _open_special_store(special_path, mode, offset, **kwargs)
    special = _finalize_special_open(special, special_path, mode)
    if special is not None:
        return special

    if regular_exc is not None:
        raise regular_exc
    if not os.path.exists(special_path):
        raise FileNotFoundError(f"No such file or directory: {special_path}")

    _set_default_dparams(kwargs)
    res = blosc2_ext.open(special_path, mode, offset, **kwargs)

    return process_opened_object(res)


def load(
    urlpath: str | pathlib.Path,
    offset: int = 0,
    **kwargs: dict,
):
    """Load a persistent Blosc2 object into memory.

    This is the in-memory counterpart to :func:`open`.  It opens *urlpath* in
    read-only mode and returns a standalone object that is not backed by the
    original file.  For :class:`CTable`, this dispatches to
    :meth:`CTable.load`; for array-like containers it returns an in-memory copy.

    Parameters
    ----------
    urlpath: str | pathlib.Path
        Path to the persistent Blosc2 object.
    offset: int, optional
        Offset in the file where the object is located.  This is mainly useful
        for SChunk/NDArray objects embedded in a larger file.
    kwargs: dict, optional
        Additional read-time keyword arguments passed to :func:`open`, such as
        ``dparams``.

    Returns
    -------
    out
        A standalone in-memory Blosc2 object.

    Raises
    ------
    TypeError
        If the opened object cannot be loaded as a standalone in-memory object.

    Examples
    --------
    >>> import blosc2
    >>> import numpy as np
    >>> arr = blosc2.asarray(np.arange(10), urlpath="example.b2nd", mode="w")
    >>> loaded = blosc2.load("example.b2nd")
    >>> loaded.urlpath is None
    True
    >>> np.array_equal(loaded[:], arr[:])
    True
    >>> blosc2.remove_urlpath("example.b2nd")
    """
    opened = open(urlpath, mode="r", offset=offset, **kwargs)

    if isinstance(opened, blosc2.CTable):
        storage = getattr(opened, "_storage", None)
        root = getattr(storage, "_root", urlpath)
        close = getattr(opened, "close", None)
        if close is not None:
            close()
        return blosc2.CTable.load(str(root))

    if isinstance(opened, blosc2.NDArray):
        return opened.copy()

    if isinstance(opened, blosc2.SChunk):
        return blosc2.schunk_from_cframe(opened.to_cframe())

    if isinstance(opened, blosc2.ListArray | blosc2.BatchArray | blosc2.ObjectArray):
        return opened.copy()

    if isinstance(opened, blosc2.C2Array):
        return blosc2.asarray(opened[:])

    raise TypeError(f"Cannot load object of type {type(opened).__name__!r} into memory")
