##########################################################################
# Copyright (c) 2023-2024 Reinhard Caspary                               #
# <reinhard.caspary@phoenixd.uni-hannover.de>                            #
# This program is free software under the terms of the MIT license.      #
##########################################################################
#
# This module provides the Scientific Data Container as class
# DataContainer which may be stored or uploaded as a ZIP package
# containing items (files). Do not use this class directly! Use the
# class Container provided by the package scidatacontainer instead.
#
##########################################################################

import copy
import hashlib
import io
import json
import pathlib
import typing
import uuid
from abc import ABC
from collections.abc import Iterator
from datetime import datetime, timezone
from queue import Queue
from threading import Thread
from types import SimpleNamespace
from zipfile import ZIP_DEFLATED, ZipFile

import requests

from .config import load_config
from .filebase import BinaryFile, JsonFile, TextFile

# Version of the implemented data model
MODELVERSION = "1.0.1"


##########################################################################
# Timestamp function


def timestamp() -> str:
    """Return the current ISO 8601 compatible timestamp as string.

    Returns:
        str: timestamp as string
    """
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


##########################################################################
# Data container class


class AbstractContainer(ABC):
    """Scientific data container with minimal file support.

    The following file types are supported:
        - .json <-> dict
        - .txt <-> str
        - .bin <-> bytes
    """

    _config = None
    _suffixes = {"json": JsonFile, "txt": TextFile, "bin": BinaryFile}
    _classes = {dict: JsonFile, str: TextFile, bytes: BinaryFile}
    _formats = [TextFile]

    def __init__(
        self,
        items: dict | None = None,
        file: str | None = None,
        uuid: str | None = None,
        config: dict | None = None,
        compression: int = ZIP_DEFLATED,
        compresslevel: int = -1,
        ignore_items: list[str] = [],
        **kwargs,
    ):
        """Construct a DataContainer object.

        It will try the following in the specified order:
            - Create a new DataContainer if items is passed as argument.
            - Load local DataContainer from hard drive if file is passed.
            - Download a DataContainer from a server if uuid is passed.

        Args:
            items: Dictionary of items to build a new DataContainer.
            file: Filename to read a DataContainer from local hard drive.
            uuid: UUID of a Container to download from a server instance.
            author: Author name for meta.json
            email: Author email for meta.json
            server: URL of the server instance providing the Container.
            key: API-Key from the server to identify yourself.
            compression: Numeric constant for the compression method
            compresslevel: Level of compression, 0-fastest, 9-best compression
            ignore_items: List of container items that are not loaded to memory
        """
        self.kwargs = {
            "items": items,
            "file": file,
            "uuid": uuid,
            "config": config,
            "compression": compression,
            "compresslevel": compresslevel,
            "ignore_items": ignore_items,
        }
        self.kwargs.update(kwargs)

        self._items = {}
        self.__pre_init__()

        # Load variables from kwargs in namespace
        n = SimpleNamespace(**self.kwargs)

        # Container must be mutable initially
        self.mutable = True

        # Load configuration
        if n.config is not None:
            self._config = dict(n.config)
        else:
            self._config = load_config()

        # Store all items in the container
        if n.items is not None:
            self._store(n.items, True, False)
            self.mutable = not self["content.json"]["static"]

        # Load local container file
        elif n.file is not None:
            self._read(fn=n.file, ignore_items=n.ignore_items)

        # Download container from server
        elif n.uuid is not None:
            server = self._config["server"]
            key = self._config["key"]
            self._download(
                uuid=n.uuid, server=server, key=key, ignore_items=n.ignore_items
            )

        # No data source
        else:
            raise RuntimeError("No data!")

        self.compression = n.compression
        self.compresslevel = n.compresslevel
        self.ignore_items = n.ignore_items

        # Check validity of author ORCID
        self._norm_orcid()

        if self.mutable and len(self.ignore_items) > 0:
            raise RuntimeError(
                "Partial loading of items only supported for immutable containers!"
            )
        self.__post_init__()

    def __pre_init__(self):
        """Manipulate the container before initialization.

        This method can be overwritten by inheriting classes to manipulate the
        container object or the arguments passed to __init__ before the
        arguments are processed in __init__. To do so, manipulate the
        dictionary self.kwargs inside this function.
        """

    def __post_init__(self):
        """Manipulate the container after initialization.

        This method can be overwritten by inheriting classes to manipulate the
        container object after __init__ is executed. This might be useful to
        implement custom checks after creation of a container.
        """

    def _store(self, items, validate=True, strict=True):
        """Store all items in the container."""
        # Add all items in the container
        self._items = {}
        mutable = self.mutable
        self.mutable = True
        for path, data in items.items():
            self[path] = data

        # Make sure that the items content.json and meta.json exist and
        # contain all required attributes
        if "content.json" not in self:
            raise RuntimeError("Item 'content.json' is missing!")
        if "meta.json" not in self:
            raise RuntimeError("Item 'meta.json' is missing!")
        if validate:
            self.validate_content()
            self.validate_meta()

        # validate hash for old modelVersions
        if (
            strict
            and self["content.json"]["hash"]
            and self["content.json"]["modelVersion"] < "1.0.1"
        ):
            old_hash = self["content.json"]["hash"]
            self.hash()
            if old_hash != self["content.json"]["hash"]:
                raise RuntimeError("Wrong hash!")

        if self["content.json"]["static"] and not self["content.json"]["hash"]:
            self.hash()

        # Restore mutable flag
        self.mutable = mutable

    @property
    def content(self) -> dict:
        return self["content.json"]

    @content.setter
    def content(self, value: dict):
        self["content.json"] = value

    @property
    def meta(self) -> dict:
        return self["meta.json"]

    @meta.setter
    def meta(self, value: dict):
        self["meta.json"] = value

    @property
    def uuid(self) -> str:
        return self["content.json"]["uuid"]

    def __contains__(self, path):
        """Return true, if the given path matches an item in this container."""
        return path in self._items

    def __setitem__(self, path, data):
        """Store data as a container item."""
        # Immutable container must not be modified
        if not self.mutable:
            raise RuntimeError("Immutable container!")

        # Store dict with pathlib.Path object as specil types.
        if isinstance(data, dict) and isinstance(data.get("path", None), pathlib.Path):
            self._items[path] = _OnDiskFile(**data)
            return

        # Get file extension
        ext = path.rsplit(".", 1)[1]

        # Unregistered file extension
        if ext not in self._suffixes:
            # Try to convert bytes. Default is BinaryFile.
            if isinstance(data, bytes):
                for cls in self._formats:
                    try:
                        item = cls(data)
                        break
                    except Exception:
                        pass
                else:
                    item = BinaryFile(data)

            # Other Python object must be registered
            else:
                if type(data) in self._classes:
                    cls = self._classes[type(data)]
                    item = cls(data)
                else:
                    raise RuntimeError(
                        "No matching file format found for " + "item '%s'!" % path
                    )

        # Registered file extension
        else:
            cls = self._suffixes[ext]
            item = cls(data)

        # Store conversion object containing data
        self._items[path] = item

    def __getitem__(self, path):
        """Get the data content of a container item."""
        if path in self:
            if isinstance(self._items[path], _OnDiskFile):
                return self._items[path]
            return self._items[path].data
        if path in self.ignore_items:
            raise KeyError(f"Item '{path}' was ignored while reading the file.")
        raise KeyError("Unknown item '%s'!" % path)

    def validate_content(self):
        """Make sure that the item "content.json" exists and contains
        all required attributes.
        """
        # Get a copy of the item "content.json"
        content = copy.deepcopy(self["content.json"])

        # Keep UUID of a multi-step container and create a new one otherwise
        if "uuid" not in content or not content["uuid"]:
            content["uuid"] = str(uuid.uuid4())

        # The optional attribute 'replace' contains the UUID of the
        # predecessor of this container. It replaces the former one,
        # which must have the same containerType and owner and a smaller
        # or equal creation time. The replacement feature should only be
        # used for minor data modifications (e.g. additional keywords or
        # comment in meta.json). The server returns always the latest
        # version.
        if "replaces" not in content:
            content["replaces"] = None

        # The attribute 'containerType' is a dictionary which must at
        # least contain the type of the container as short string
        # without spaces. If the container type is standardized, it must
        # also contain a type id and a version string.
        if "containerType" not in content:
            raise RuntimeError("Attribute 'containerType' is missing!")
        ptype = content["containerType"]
        if not isinstance(ptype, dict):
            raise RuntimeError("Attribute containerType is no dictionary!")
        if "name" not in ptype:
            raise RuntimeError("Name of containerType is missing!")
        if "id" in ptype and "version" not in ptype:
            raise RuntimeError("Version of containerType is missing!")

        # The boolean attribute 'static' is required. Default is False.
        if "static" in content:
            content["static"] = bool(content["static"])
        else:
            content["static"] = False

        # The boolean attribute 'complete' is required. Default is True.
        if not content["static"] and "complete" in content:
            content["complete"] = bool(content["complete"])
        else:
            content["complete"] = True

        # Current time
        ts = timestamp()

        # The attribute 'created' is required. It is created
        # automatically for a new dataset.
        if "created" not in content or not content["created"]:
            content["created"] = ts

        # The attribute 'storageTime' is updated automatically when the
        # container is stored
        if "storageTime" not in content or not content["complete"]:
            content["storageTime"] = ts

        # The attribute 'hash' is optional
        if "hash" not in content or not content["hash"]:
            content["hash"] = None

        # The attribute 'usedSoftware' is a list of dictionaries, which
        # may be empty. Each dictionary must contain atleast the items
        # "name" and "version" specifying name and version of a
        # software. It may also contain the items "id" and "idType"
        # specifying a reference id (e.g. GitHub-URL) and its type.
        if "usedSoftware" not in content or not content["usedSoftware"]:
            content["usedSoftware"] = []
        for sw in content["usedSoftware"]:
            if "name" not in sw:
                raise RuntimeError("Software name is missing!")
            if "version" not in sw:
                raise RuntimeError("Software version is missing!")
            if "id" in sw and "idType" not in sw:
                raise RuntimeError("Type of software reference id is missing!")

        # Version of the data model provided by this package
        content["modelVersion"] = MODELVERSION

        # Store the item "content.json"
        self["content.json"] = content

    def validate_meta(self):
        """Make sure that the item "meta.json" exists and contains all
        required attributes."""
        # Get a copy of the item "meta.json"
        meta = copy.deepcopy(self["meta.json"])

        # Author name is required
        if "author" not in meta:
            meta["author"] = self._config["author"]
        if not meta["author"]:
            raise RuntimeError("Author name is missing!")

        # Author email address is required
        if "email" not in meta:
            meta["email"] = self._config["email"]

        # Author ORCiD is optional
        if "orcid" not in meta:
            meta["orcid"] = self._config["orcid"]

        # Author affiliation is optional
        if "organization" not in meta:
            meta["organization"] = self._config["organization"]

        # Comment on dataset is optional
        if "comment" not in meta:
            meta["comment"] = ""

        # Title of dataset is required
        if "title" not in meta:
            meta["title"] = ""
        if not meta["title"]:
            raise RuntimeError("Data title is missing!")

        # List of keywords is optional
        if "keywords" not in meta:
            meta["keywords"] = []

        # Description of dataset is optional
        if "description" not in meta:
            meta["description"] = ""

        # Data creation time is optional
        if "timestamp" not in meta:
            meta["timestamp"] = ""

        # Data DOI is optional
        if "doi" not in meta:
            meta["doi"] = ""

        # Data license name is optional
        if "license" not in meta:
            meta["license"] = ""

        # Store the item "meta.json"
        self["meta.json"] = meta

    def __delitem__(self, path):
        """Delete the given item."""
        # Immutable container must not be modified
        if not self.mutable:
            raise RuntimeError("Immutable container!")

        # Delete item
        if path in self:
            del self._items[path]

    def keys(self) -> typing.List[str]:
        """Return a sorted list of the full paths of all items.

        Returns:
            typing.List[str]: List of paths of Container items.
        """
        return sorted(self._items.keys())

    def values(self) -> typing.List:
        """Return a list of all item objects.

        Returns:
            typing.List: List of item objects of the Container.
        """
        return [self[k] for k in self.keys()]

    def items(self):
        """Return this container as a dictionary of item objects (key, value)
        tuples.
        """
        return {k: self[k] for k in self.keys()}

    def _hash(
        self,
        hash_object,
        item_name: str,
        content: bytes | typing.IO[bytes] | None = None,
        legacy: bool = False,
    ):
        if not legacy:
            hash_object.update(item_name.encode("utf8"))

        if isinstance(content, bytes):
            hash_object.update(content)
            return
        if content is not None and hasattr(content, "read"):
            while chunk := content.read(65536):
                hash_object.update(chunk)
            return
        if item_name in self._items and isinstance(self._items[item_name], _OnDiskFile):
            if legacy:
                raise RuntimeError(
                    "File like objects are only supported from modelVersion '1.0.1' on."
                )
            with open(self._items[item_name].path, "rb") as fp:
                while chunk := fp.read(65536):
                    hash_object.update(chunk)
            return
        if legacy:
            hash_object.update(self._items[item_name].hash().encode("ascii"))
        else:
            hash_object.update(self._items[item_name].encode())

    def hash(self):
        """Calculate and save the hash value of this container."""
        # Some attributes of content.json are excluded from the hash
        # calculation
        save = ("uuid", "created", "storageTime")
        save = {k: self["content.json"][k] for k in save}
        for key in save:
            self["content.json"][key] = None
        self["content.json"]["hash"] = None

        legacy = bool(self["content.json"]["modelVersion"] < "1.0.1")

        h = hashlib.sha256()
        for i, p in enumerate(sorted(self.items())):
            if legacy and i != 0:
                h.update(b" ")
            self._hash(h, p, legacy=legacy)
        self["content.json"]["hash"] = h.hexdigest()

        # Restore excluded attributes
        for key, value in save.items():
            self["content.json"][key] = value

        # Make container immutable
        self.mutable = False
        self["content.json"]["storageTime"] = timestamp()

    def freeze(self):
        """Calculate the hash value of this container and make it
        static. The container cannot be modified any more when this
        method was called once."""
        self["content.json"]["static"] = True
        self["content.json"]["complete"] = True
        self.hash()

    def release(self):
        """Make this container mutable. If it was immutable, this method
        will create a new UUID and initialize the attributes created,
        storageTime and modelVersion in the item "content.json".
        It will also delete an existing hash and make it a new
        container."""
        # Do nothing if the container is already mutable
        if self.mutable:
            return

        if len(self.ignore_items) > 0:
            raise RuntimeError(
                "Modifying a file that was only partially read is not supported!"
                + " Make sure 'ignore_items' is empty during initialisation."
            )
        self.mutable = True

        # Remove and initialize certain container attributes
        content = self["content.json"]
        content["static"] = False
        content["complete"] = True
        for key in ("uuid", "created", "storageTime", "hash"):
            content.pop(key, None)
        self.validate_content()

    def encode(self) -> Iterator[bytes]:
        """Encode container as ZIP package.

        Yields:
            bytes: next chunk of the generated zip file."""

        # Check/format of author ORCID
        self._norm_orcid()

        in_memory_items = {
            p: self._items[p]
            for p in self.items()
            if not isinstance(self[p], _OnDiskFile)
        }

        in_filesystem_items = {
            p: self._items[p] for p in self.items() if isinstance(self[p], _OnDiskFile)
        }

        # create FiFo queue with limited size
        queue = _StreamingQueue()

        # function to generate zip content in a seperate thread
        def _zip_generator():
            try:
                with ZipFile(
                    queue,
                    mode="w",
                    compression=self.compression,
                    compresslevel=self.compresslevel,
                ) as zfp:
                    for path in sorted(in_memory_items.keys()):
                        zfp.writestr(path, in_memory_items[path].encode())

                    for path in sorted(in_filesystem_items.keys()):
                        zfp.write(
                            in_filesystem_items[path].path,
                            arcname=path,
                            compress_type=in_filesystem_items[path].compression,
                            compresslevel=in_filesystem_items[path].compression_level,
                        )
            except Exception as e:
                queue.error = e
            finally:
                queue.close()

        # start thread to fill the queue
        thread = Thread(target=_zip_generator, daemon=True)
        thread.start()

        # yield next chunk if available
        while True:
            chunk = queue.get()
            if chunk is None:
                if queue.error is not None:
                    raise queue.error
                break

            yield chunk

    def decode(
        self,
        fp: io.RawIOBase | io.BufferedIOBase,
        ignore_items: list[str] = [],
        validate: bool = True,
        strict: bool = True,
    ):
        """Take ZIP package as file object. Read items from the
        package and store them in this object.

        Args:
            fp: File object to read from.
            ignore_items: List of file paths that are not read into memory.
            validate: If true, validate the content.
            strict: If true, validate the hash, too.
        """
        with ZipFile(fp, "r") as zfp:
            items = {p: zfp.read(p) for p in zfp.namelist() if p not in ignore_items}

            # trigger decoding content.json
            self["content.json"] = items["content.json"]
            hash = self["content.json"]["hash"]
            modelVersion = self["content.json"]["modelVersion"]

            # validate hash value, if the legacy hash function is not required
            if strict and hash and modelVersion >= "1.0.1":
                for key in ("uuid", "created", "storageTime", "hash"):
                    self["content.json"][key] = None

                h = hashlib.sha256()
                for p in sorted(zfp.namelist()):
                    if p == "content.json":
                        self._hash(h, p)
                    else:
                        with zfp.open(p) as f:
                            self._hash(h, p, content=f)

                # Check validity of hash
                if hash != h.hexdigest():
                    raise RuntimeError("Wrong hash!")
        self._store(items, validate, strict)

    def write(self, fn: str, data: bytes | None = None):
        """Write the container to a ZIP package file.

        If data is passed to the function, data will be written to the file.
        Otherwise the byte representation of the class instance will be written
        to the file, which is what you typically want.

        Args:
            fn: Filename of export file.
            data: If given, data to write to the file.
        """
        if self.mutable:
            self["content.json"]["storageTime"] = timestamp()

        if data is None:
            stream = self.encode()
        with open(fn, "wb") as fp:
            if data is not None:
                fp.write(data)
            else:
                for chunk in stream:
                    fp.write(chunk)
        self.mutable = not (
            self["content.json"]["static"] or self["content.json"]["complete"]
        )

    def _read(self, fn: str, ignore_items: list[str] = [], strict: bool = True):
        """Read a ZIP package file and store it as container in this
        object.
        """
        with open(fn, "rb") as fp:
            self.decode(fp, ignore_items, False, strict)

        self.mutable = not (
            self["content.json"]["static"] or self["content.json"]["complete"]
        )

    def upload(
        self,
        data: bytes | None = None,
        server: str | None = None,
        key: str | None = None,
    ):
        """Create a ZIP archive of the DataContainer and upload it to a server.

        If data is passed to the function, data will be written to the file.
        Otherwise the byte representation of the class instance will be written
        to the file, which is what you typically want.

        Args:
            data: If given, data to write to the file.
            server: URL of the server.
            key: API Key from the server to identify yourself.
        """
        # Server name is required and must be provided either via config
        # file, environment variable or method parameter
        if server is None:
            server = self._config["server"]
        if not server:
            raise RuntimeError("Server URL is missing!")

        # API key is required and must be provided either via config
        # file, environment variable or method parameter
        if key is None:
            key = self._config["key"]
        if not key:
            raise RuntimeError("Server API key is missing!")

        # Upload container as byte string
        if self.mutable:
            self["content.json"]["storageTime"] = timestamp()

        try:
            streamer = _MultipartRequestStreamer(data or self.encode())
            response = requests.post(
                server + "/api/datasets/",
                headers={
                    "Authorization": "Token " + key,
                    "Content-Type": f"multipart/form-data; boundary={streamer.boundary}",
                },
                data=streamer,
            )
        except Exception:
            response = None
        if response is None:
            raise ConnectionError("Connection to server %s failed!" % server)

        # HTTP status code 409 is returned when a dataset is already
        # available on the server. Static datasets require a special
        # treatment: The current dataset is replaced by the one from the
        # server.
        if response.status_code == 400:
            if self["content.json"]["static"]:
                data = json.loads(response.content.decode("UTF-8"))
                if isinstance(data, dict) and data["static"]:
                    self._download(uuid=data["id"], server=server, key=key)
                    return
            raise requests.HTTPError("400 Bad Request: Invalid container content")

        # Unauthorized access
        elif response.status_code == 403:
            raise requests.HTTPError("403 Forbidden: Unauthorized access")

        # Duplicate UUID
        elif response.status_code == 409:
            raise requests.HTTPError("409 Conflict: UUID is already existing")

        # Invalid container format
        elif response.status_code == 415:
            raise requests.HTTPError("415 Unsupported: Invalid container format")

        # Standard exception handler for other HTTP status codes
        else:
            response.raise_for_status()

        # Make container immutable
        self.mutable = not (
            self["content.json"]["static"] or self["content.json"]["complete"]
        )

    def _download(
        self, uuid, strict=True, server=None, key=None, ignore_items: list[str] = []
    ):
        # Server name is required and must be provided either via config
        # file, environment variable or method parameter
        if server is None:
            server = self._config["server"]
        if not server:
            raise RuntimeError("Server URL is missing!")

        # API key is required and must be provided either via config
        # file, environment variable or method parameter
        if key is None:
            key = self._config["key"]
        if not key:
            raise RuntimeError("Server API key is missing!")

        # Download container as byte stream from the server
        try:
            url = server + "/api/datasets/" + uuid + "/download/"
            response = requests.get(url, headers={"Authorization": "Token " + key})
        except Exception:
            response = None
        if response is None:
            raise ConnectionError("Connection to server %s failed!" % server)
        data = response.content
        fp = io.BytesIO(data)

        # Valid dataset: Store in this container
        if response.status_code == 200:
            self.decode(fp, ignore_items, False, strict)

        # Deleted dataset: Raise exception
        elif response.status_code == 204:
            raise requests.HTTPError("204 No Content: Deleted dataset")

        # Replaced dataset: Store in this container
        elif response.status_code == 301:
            self.decode(fp, ignore_items, strict)

        # Unauthorized access
        elif response.status_code == 403:
            raise requests.HTTPError("403 Forbidden: Unauthorized access")

        # Unknown dataset: Raise exception
        elif response.status_code == 404:
            raise requests.HTTPError("404 Not Found: Unknown dataset")

        # Standard exception handler for other HTTP status codes
        else:
            response.raise_for_status()

        # Make container immutable
        self.mutable = not (
            self["content.json"]["static"] or self["content.json"]["complete"]
        )

    def __str__(self):
        content = self["content.json"]
        meta = self["meta.json"]

        if content["static"]:
            ctype = "Static Container"
        elif content["complete"]:
            ctype = "Complete Container"
        else:
            ctype = "Incomplete Container"

        s = [ctype]
        ptype = content["containerType"]
        name = ptype["name"]
        if "id" in ptype:
            name = "%s %s (%s)" % (name, ptype["version"], ptype["id"])
        s.append("  type:        " + name)
        s.append("  uuid:        " + content["uuid"])
        if content["replaces"]:
            s.append("  replaces:    " + content["replaces"])
        if content["hash"]:
            s.append("  hash:        " + content["hash"])
        s.append("  created:     " + content["created"])
        s.append("  storageTime: " + content["storageTime"])
        s.append("  author:      " + meta["author"])

        return "\n".join(s)

    def _norm_orcid(self, orcid: typing.Optional[str] = None) -> None:
        """Set ORCID to normalized string if the given string is a valid ORCiD
        or to an empty string otherwise."""

        if orcid is None:
            orcid = self["meta.json"]["orcid"]

        assert isinstance(orcid, str)

        # Pick all digits and normalize
        orcid = orcid.replace("-", "").replace(" ", "").upper()
        if len(orcid) != 16:
            self["meta.json"]["orcid"] = ""
            return

        # Check if ORCID is in the correct number space.
        try:
            orcid_number = int(orcid[:-1])
        except ValueError:
            self["meta.json"]["orcid"] = ""
            return

        if not (
            (15000000 <= int(orcid_number) <= 35000000)
            or (900000000000 <= int(orcid_number) <= 900100000000)
        ):
            self["meta.json"]["orcid"] = ""
            return

        # Calculate checksum product
        r = 2
        m = 11
        try:
            product = 0
            for digit in orcid[:-1]:
                product = ((int(digit) + product) * r) % m
        except ValueError:
            self["meta.json"]["orcid"] = ""
            return

        # Calculate checksum digit
        checksum = (m + 1 - product) % m
        check = "0123456789X"[checksum]
        if orcid[-1] != check:
            self["meta.json"]["orcid"] = ""
            return

        # Valid ORCID. Set orcid variable to formatted string
        self["meta.json"]["orcid"] = (
            orcid[:4] + "-" + orcid[4:8] + "-" + orcid[8:12] + "-" + orcid[12:]
        )


class _OnDiskFile:
    """Class to represent an OnDiskFile that doesn't need to be encoded."""

    def __init__(
        self,
        path: pathlib.Path,
        compression: int | None = None,
        compression_level: int | None = None,
    ) -> None:
        self.path: pathlib.Path = path
        self.compression: int | None = compression
        self.compression_level: int | None = compression_level


class _StreamingQueue:
    def __init__(self, chunk_size: int = 65536, maxsize: int = 8):
        self.queue: Queue[bytes | None] = Queue(maxsize=maxsize)
        self.buffer: bytearray = bytearray()
        self.chunk_size: int = chunk_size
        self._closed: bool = False
        self.error: Exception | None = None

    def write(self, b: bytes) -> int:
        self.buffer += b
        if len(self.buffer) >= self.chunk_size:
            self.flush()
        return len(b)

    def flush(self):
        if self.buffer:
            try:
                self.queue.put(bytes(self.buffer), block=True, timeout=5)
            except Exception as e:
                self.error = e
            self.buffer.clear()

    def seekable(self) -> bool:
        return False

    def close(self):
        if not self._closed:
            self.flush()
            try:
                self.queue.put(None, block=True, timeout=5)
            except Exception as e:
                self.error = e
            self._closed = True

    def get(self) -> bytes | None:
        item = self.queue.get(block=True)
        if item is None and self.error:
            raise self.error
        return item


class _MultipartRequestStreamer:
    def __init__(self, data: io.RawIOBase | bytes, boundary: str | None = None) -> None:
        self.boundary: str = f"--{boundary or uuid.uuid4().hex}"
        self.data: io.RawIOBase | io.BytesIO
        if isinstance(data, bytes):
            self.data = io.BytesIO(data)
        else:
            self.data = data

        self._head_sent: bool = False
        self._file_sent: bool = False

    def __iter__(self):
        return self

    def _head(self) -> str:
        return f'--{self.boundary}\r\nContent-Disposition: form-data; name="uploadfile"; filename="dataset.zdc"\r\nContent-Type: application/octet-stream\r\n\r\n'

    def __next__(self) -> bytes:
        if not self._head_sent:
            self._head_sent = True
            return self._head().encode("utf-8")

        if not self._file_sent:
            try:
                chunk = next(self.data)
                return chunk
            except StopIteration:
                self._file_sent = True
                return f"\r\n--{self.boundary}--\r\n".encode("utf-8")
        raise StopIteration
