# -*- coding: utf-8 -*-
# Copyright © Nekoka.tt 2019-2020
#
# This file is part of Hikari.
#
# Hikari is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Hikari is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Hikari. If not, see <https://www.gnu.org/licenses/>.
"""Utilities and classes for interacting with files and web resources."""

from __future__ import annotations

__all__ = [
    "ensure_path",
    "ensure_resource",
    "unwrap_bytes",
    "Pathish",
    "Rawish",
    "Resourceish",
    "LazyByteIteratorish",
    "AsyncReader",
    "AsyncReaderContextManager",
    "Resource",
    "File",
    "FileReader",
    "WebResource",
    "URL",
    "WebReader",
    "Bytes",
    "IteratorReader",
]

import abc
import asyncio
import base64
import concurrent.futures
import inspect
import io
import logging
import mimetypes
import os
import pathlib
import typing
import urllib.parse
import urllib.request

import aiohttp.client
import attr

from hikari.utilities import aio
from hikari.utilities import date
from hikari.utilities import net

if typing.TYPE_CHECKING:
    import types

_LOGGER: typing.Final[logging.Logger] = logging.getLogger(__name__)
_MAGIC: typing.Final[int] = 50 * 1024


Pathish = typing.Union["os.PathLike[str]", str]
"""Type hint representing a literal file or path.

This may be one of:

- `builtins.str` path.
- `os.PathLike` derivative, such as `pathlib.PurePath` and `pathlib.Path`.
"""

RAWISH_TYPES = (bytes, bytearray, memoryview, io.BytesIO, io.StringIO)

Rawish = typing.Union[bytes, bytearray, memoryview, io.BytesIO, io.StringIO]
"""Type hint representing valid raw data types.

This may be one of:

- `bytes`
- `bytearray`
- `memoryview`
- `io.BytesIO`
- `io.StringIO` (assuming UTF-8 encoding).
"""

LazyByteIteratorish = typing.Union[
    typing.AsyncIterator[bytes],
    typing.AsyncIterable[bytes],
    typing.Iterator[bytes],
    typing.Iterable[bytes],
    typing.AsyncIterator[str],
    typing.AsyncIterable[str],
    typing.Iterator[str],
    typing.Iterable[str],
    typing.AsyncGenerator[bytes, typing.Any],
    typing.Generator[bytes, typing.Any, typing.Any],
    typing.AsyncGenerator[str, typing.Any],
    typing.Generator[str, typing.Any, typing.Any],
    asyncio.StreamReader,
    aiohttp.StreamReader,
]
"""Type hint representing an iterator/iterable of bytes.

This may be one of:

- `typing.AsyncIterator[bytes]`
- `typing.AsyncIterable[bytes]`
- `typing.Iterator[bytes]`
- `typing.Iterable[bytes]`
- `typing.AsyncIterator[str]` (assuming UTF-8 encoding).
- `typing.AsyncIterable[str]` (assuming UTF-8 encoding).
- `typing.Iterator[str]` (assuming UTF-8 encoding).
- `typing.Iterable[str]` (assuming UTF-8 encoding).
- `asyncio.StreamReader`
- `aiohttp.StreamReader`
"""

Resourceish = typing.Union["Resource", Pathish, Rawish]
"""Type hint representing a file or path to a file/URL/data URI.

This may be one of:

- `Resource` or a derivative.
- `builtins.str` path.
- `os.PathLike` derivative, such as `pathlib.PurePath` and `pathlib.Path`.
- `bytes`
- `bytearray`
- `memoryview`
- `io.BytesIO`
- `io.StringIO` (assuming UTF-8 encoding).
"""


def ensure_path(pathish: Pathish) -> pathlib.Path:
    """Convert a path-like object to a `pathlib.Path` instance."""
    return pathlib.Path(pathish)


def unwrap_bytes(data: Rawish) -> bytes:
    """Convert a byte-like object to bytes."""
    if isinstance(data, bytearray):
        data = bytes(data)
    elif isinstance(data, memoryview):
        data = data.tobytes()
    elif isinstance(data, io.StringIO):
        data = bytes(data.read(), "utf-8")
    elif isinstance(data, io.BytesIO):
        data = data.read()

    return data


@typing.overload
def ensure_resource(url_or_resource: None, /) -> None:
    """Given None, return None."""


@typing.overload
def ensure_resource(url_or_resource: Resourceish, /) -> Resource:
    """Given a non null value, parse a resource from it.."""


def ensure_resource(url_or_resource: typing.Optional[Resourceish], /) -> typing.Optional[Resource]:
    """Given a resource or string, convert it to a valid resource as needed.

    Parameters
    ----------
    url_or_resource : Resourceish
        The item to convert. If the item is `builtins.None`, then
        `builtins.None` is returned. Likewise if a `Resource` is passed, it is
        simply returned again. Anything else is converted to a `Resource` first.

    Returns
    -------
    Resource or builtins.None
        The resource to use, or `builtins.None` if `builtins.None` was input.
    """
    if isinstance(url_or_resource, RAWISH_TYPES):
        data = unwrap_bytes(url_or_resource)
        filename = generate_filename_from_details(mimetype=None, extension=None, data=data)
        return Bytes(url_or_resource, filename)

    if isinstance(url_or_resource, Resource):
        return url_or_resource

    if url_or_resource is None:
        return None

    url_or_resource = str(url_or_resource)

    if url_or_resource.startswith(("https://", "http://")):
        return URL(url_or_resource)
    if url_or_resource.startswith("data:"):
        try:
            return Bytes.from_data_uri(url_or_resource)
        except ValueError:
            # If we can't parse it, maybe it is some malformed file?
            pass

    path = pathlib.Path(url_or_resource)
    return File(path, path.name)


def guess_mimetype_from_filename(name: str, /) -> typing.Optional[str]:
    """Guess the mimetype of an object given a filename.

    Parameters
    ----------
    name : builtins.bytes
        The filename to inspect.

    Returns
    -------
    builtins.str or builtins.None
        The closest guess to the given filename. May be `builtins.None` if
        no match was found.
    """
    guess, _ = mimetypes.guess_type(name)
    return guess


def guess_mimetype_from_data(data: bytes, /) -> typing.Optional[str]:
    """Guess the mimetype of some data from the header.

    !!! warning
        This function only detects valid image headers that Discord allows
        the use of. Anything else will go undetected.

    Parameters
    ----------
    data : builtins.bytes
        The byte content to inspect.

    Returns
    -------
    builtins.str or builtins.None
        The mimetype, if it was found. If the header is unrecognised, then
        `builtins.None` is returned.
    """
    if data.startswith(b"\211PNG\r\n\032\n"):
        return "image/png"
    if data[6:].startswith((b"Exif", b"JFIF")):
        return "image/jpeg"
    if data.startswith((b"GIF87a", b"GIF89a")):
        return "image/gif"
    if data.startswith(b"RIFF") and data[8:].startswith(b"WEBP"):
        return "image/webp"
    return None


def guess_file_extension(mimetype: str) -> typing.Optional[str]:
    """Guess the file extension for a given mimetype.

    Parameters
    ----------
    mimetype : builtins.str
        The mimetype to guess the extension for.

    Example
    -------
    ```py
    >>> guess_file_extension("image/png")
    ".png"
    ```

    Returns
    -------
    builtins.str or builtins.None
        The file extension, prepended with a `.`. If no match was found,
        return `builtins.None`.
    """
    return mimetypes.guess_extension(mimetype)


def generate_filename_from_details(
    *,
    mimetype: typing.Optional[str] = None,
    extension: typing.Optional[str] = None,
    data: typing.Optional[bytes] = None,
) -> str:
    """Given optional information about a resource, generate a filename.

    Parameters
    ----------
    mimetype : builtins.str or builtins.None
        The mimetype of the content, or `builtins.None` if not known.
    extension : builtins.str or builtins.None
        The file extension to use, or `builtins.None` if not known.
    data : builtins.bytes or builtins.None
        The data to inspect, or `builtins.None` if not known.

    Returns
    -------
    builtins.str
        A generated quasi-unique filename.
    """
    if data is not None and mimetype is None:
        mimetype = guess_mimetype_from_data(data)

    if extension is None and mimetype is not None:
        extension = guess_file_extension(mimetype)

    if not extension:
        extension = ""
    elif not extension.startswith("."):
        extension = f".{extension}"

    # Nanosecond precision will be less likely to collide.
    return date.uuid() + extension


def to_data_uri(data: bytes, mimetype: typing.Optional[str]) -> str:
    """Convert the data and mimetype to a data URI.

    Parameters
    ----------
    data : builtins.bytes
        The data to encode as base64.
    mimetype : builtins.str or builtins.None
        The mimetype, or `builtins.None` if we should attempt to guess it.

    Returns
    -------
    builtins.str
        A data URI string.
    """
    if mimetype is None:
        mimetype = guess_mimetype_from_data(data)

        if mimetype is None:
            raise TypeError("Cannot infer mimetype from input data, specify it manually.")

    b64 = base64.b64encode(data).decode()
    return f"data:{mimetype};base64,{b64}"


@attr.s(auto_attribs=True, slots=True)
class AsyncReader(typing.AsyncIterable[bytes], abc.ABC):
    """Protocol for reading a resource asynchronously using bit inception.

    This supports being used as an async iterable, although the implementation
    detail is left to each implementation of this class to define.
    """

    filename: str
    """The filename of the resource."""

    mimetype: typing.Optional[str]
    """The mimetype of the resource. May be `builtins.None` if not known."""

    async def data_uri(self) -> str:
        """Fetch the data URI.

        This reads the entire resource.
        """
        return to_data_uri(await self.read(), self.mimetype)

    async def read(self) -> bytes:
        """Read the rest of the resource and return it in a `builtins.bytes` object."""
        buff = bytearray()
        async for chunk in self:
            buff.extend(chunk)
        return buff


ReaderImplT = typing.TypeVar("ReaderImplT", bound=AsyncReader)


class AsyncReaderContextManager(abc.ABC, typing.Generic[ReaderImplT]):
    """Context manager that returns a reader."""

    __slots__: typing.Sequence[str] = ()

    @abc.abstractmethod
    async def __aenter__(self) -> ReaderImplT:
        ...

    @abc.abstractmethod
    async def __aexit__(
        self,
        exc_type: typing.Optional[typing.Type[BaseException]],
        exc: typing.Optional[BaseException],
        exc_tb: typing.Optional[types.TracebackType],
    ) -> None:
        ...


@typing.final
class _NoOpAsyncReaderContextManagerImpl(typing.Generic[ReaderImplT], AsyncReaderContextManager[ReaderImplT]):
    __slots__: typing.Sequence[str] = ("impl",)

    def __init__(self, impl: ReaderImplT) -> None:
        self.impl = impl

    async def __aenter__(self) -> ReaderImplT:
        return self.impl

    async def __aexit__(
        self,
        exc_type: typing.Optional[typing.Type[BaseException]],
        exc: typing.Optional[BaseException],
        exc_tb: typing.Optional[types.TracebackType],
    ) -> None:
        pass


class Resource(typing.Generic[ReaderImplT], abc.ABC):
    """Base for any uploadable or downloadable representation of information.

    These representations can be streamed using bit inception for performance,
    which may result in significant decrease in memory usage for larger
    resources.
    """

    __slots__: typing.Sequence[str] = ()

    @property
    @abc.abstractmethod
    def url(self) -> str:
        """URL of the resource."""

    @property
    @abc.abstractmethod
    def filename(self) -> str:
        """Filename of the resource."""

    @abc.abstractmethod
    def stream(
        self, *, executor: typing.Optional[concurrent.futures.Executor] = None, head_only: bool = False,
    ) -> AsyncReaderContextManager[ReaderImplT]:
        """Produce a stream of data for the resource.

        Parameters
        ----------
        executor : concurrent.futures.Executor or builtins.None
            The executor to run in for blocking operations.
            If `builtins.None`, then the default executor is used for the
            current event loop.
        head_only : builtins.bool
            Defaults to `builtins.False`. If `builtins.True`, then the
            implementation may only retrieve HEAD information if supported.
            This currently only has any effect for web requests.

        Returns
        -------
        AsyncReaderContextManager[AsyncReader]
            An async iterable of bytes to stream.
        """

    def __str__(self) -> str:
        return self.url

    def __repr__(self) -> str:
        return f"{type(self).__name__}(url={self.url!r}, filename={self.filename!r})"

    def __eq__(self, other: typing.Any) -> bool:
        if isinstance(other, Resource):
            return self.url == other.url
        return False

    def __hash__(self) -> int:
        return hash((self.__class__, self.url))


###################
# WEBSITE STREAMS #
###################


@attr.s(auto_attribs=True, slots=True)
class WebReader(AsyncReader):
    """Asynchronous reader to use to read data from a web resource."""

    stream: aiohttp.StreamReader
    """The `aiohttp.StreamReader` to read the content from."""

    url: str
    """The URL being read from."""

    status: int
    """The initial HTTP response status."""

    reason: str
    """The HTTP response status reason."""

    charset: typing.Optional[str]
    """Optional character set information, if known."""

    size: typing.Optional[int]
    """The size of the resource, if known."""

    head_only: bool
    """If `builtins.True`, then only the HEAD was requested.

    In this case, neither `__aiter__` nor `read` would return anything other
    than an empty byte string.
    """

    async def read(self) -> bytes:
        return b"" if self.head_only else await self.stream.read()

    async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
        if self.head_only:
            yield b""
        else:
            while not self.stream.at_eof():
                chunk, _ = await self.stream.readchunk()
                yield chunk


@typing.final
class _WebReaderAsyncReaderContextManagerImpl(AsyncReaderContextManager[WebReader]):
    __slots__: typing.Sequence[str] = ("_web_resource", "_head_only", "_client_response_ctx", "_client_session")

    def __init__(self, web_resource: WebResource, head_only: bool) -> None:
        self._web_resource = web_resource
        self._head_only = head_only
        self._client_session: aiohttp.ClientSession = NotImplemented
        self._client_response_ctx: typing.AsyncContextManager = NotImplemented

    async def __aenter__(self) -> WebReader:
        client_session = aiohttp.ClientSession()

        method = "HEAD" if self._head_only else "GET"

        ctx = client_session.request(method, self._web_resource.url, raise_for_status=False)

        try:
            resp: aiohttp.ClientResponse = await ctx.__aenter__()

            if 200 <= resp.status < 400:
                mimetype = None
                filename = self._web_resource.filename

                if resp.content_disposition is not None:
                    mimetype = resp.content_disposition.type

                if mimetype is None:
                    mimetype = resp.content_type

                self._client_response_ctx = ctx
                self._client_session = client_session

                return WebReader(
                    stream=resp.content,
                    url=str(resp.real_url),
                    status=resp.status,
                    reason=str(resp.reason),
                    filename=filename,
                    charset=resp.charset,
                    mimetype=mimetype,
                    size=resp.content_length,
                    head_only=self._head_only,
                )
            else:
                raise await net.generate_error_response(resp)

        except Exception as ex:
            await ctx.__aexit__(type(ex), ex, ex.__traceback__)
            await client_session.close()
            raise

    async def __aexit__(
        self,
        exc_type: typing.Optional[typing.Type[BaseException]],
        exc: typing.Optional[BaseException],
        exc_tb: typing.Optional[types.TracebackType],
    ) -> None:
        await self._client_response_ctx.__aexit__(exc_type, exc, exc_tb)
        await self._client_session.close()


class WebResource(Resource[WebReader], abc.ABC):
    """Base class for a resource that resides on the internet.

    The logic for identifying this resource is left to each implementation
    to define.

    !!! info
        For a usable concrete implementation, use `URL` instead.

    !!! note
        Some components may choose to not upload this resource directly and
        instead simply refer to the URL as needed. The main place this will
        occur is within embeds.

        If you need to re-upload the resource, you should download it into
        a `builtins.bytes` and pass that instead in these cases.
    """

    __slots__: typing.Sequence[str] = ()

    def stream(
        self, *, executor: typing.Optional[concurrent.futures.Executor] = None, head_only: bool = False,
    ) -> AsyncReaderContextManager[WebReader]:
        """Start streaming the content into memory by downloading it.

        You can use this to fetch the entire resource, parts of the resource,
        or just to view any metadata that may be provided.

        Parameters
        ----------
        executor : concurrent.futures.Executor or builtins.None
            Not used. Provided only to match the underlying interface.
        head_only : builtins.bool
            Defaults to `builtins.False`. If `builtins.True`, then the
            implementation may only retrieve HEAD information if supported.
            This currently only has any effect for web requests.

        Examples
        --------
        Downloading an entire resource at once into memory:
        ```py
        async with obj.stream() as stream:
            data = await stream.read()
        ```
        Checking the metadata:
        ```py
        async with obj.stream() as stream:
            mimetype = stream.mimetype

        if mimetype is None:
            ...
        elif mimetype not in whitelisted_mimetypes:
            ...
        else:
            ...
        ```
        Fetching the data-uri of a resource:
        ```py
        async with obj.stream() as stream:
            data_uri = await stream.data_uri()
        ```

        Returns
        -------
        AsyncReaderContextManager[WebReader]
            An async context manager that when entered, produces the
            data stream.

        Raises
        ------
        hikari.errors.BadRequest
            If a 400 is returned.
        hikari.errors.Unauthorized
            If a 401 is returned.
        hikari.errors.Forbidden
            If a 403 is returned.
        hikari.errors.NotFound
            If a 404 is returned.
        hikari.errors.ClientHTTPErrorResponse
            If any other 4xx is returned.
        hikari.errors.ServerHTTPErrorResponse
            If any other 5xx is returned.
        hikari.errors.HTTPErrorResponse
            If any other unexpected response code is returned.
        """
        return _WebReaderAsyncReaderContextManagerImpl(self, head_only)


@typing.final
class URL(WebResource):
    """A URL that represents a web resource.

    Parameters
    ----------
    url : builtins.str
        The URL of the resource.

    !!! note
        Some components may choose to not upload this resource directly and
        instead simply refer to the URL as needed. The main place this will
        occur is within embeds.

        If you need to re-upload the resource, you should download it into
        a `builtins.bytes` and pass that instead in these cases.
    """

    __slots__: typing.Sequence[str] = ("_url",)

    def __init__(self, url: str) -> None:
        self._url = url

    @property
    def url(self) -> str:
        return self._url

    @property
    def filename(self) -> str:
        url = urllib.parse.urlparse(self._url)
        return os.path.basename(url.path)


########################################
# ON-DISK FILESYSTEM RESOURCE READERS. #
########################################


@attr.s(auto_attribs=True, slots=True)
class FileReader(AsyncReader, abc.ABC):
    """Abstract base for a file reader object.

    Various implementations have to exist in order to cater for situations
    where we cannot pass IO objects around (e.g. ProcessPoolExecutors, since
    they pickle things).
    """

    executor: typing.Optional[concurrent.futures.Executor]
    """The associated `concurrent.futures.Executor` to use for blocking IO."""

    path: pathlib.Path = attr.ib(converter=ensure_path)
    """The path to the resource to read."""


@attr.s(auto_attribs=True, slots=True)
class ThreadedFileReader(FileReader):
    """Asynchronous file reader that reads a resource from local storage.

    This implementation works with pools that exist in the same interpreter
    instance as the caller, namely thread pool executors, where objects
    do not need to be pickled to be communicated.
    """

    async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
        loop = asyncio.get_running_loop()

        path = self.path
        if isinstance(path, pathlib.Path):
            path = await loop.run_in_executor(self.executor, self._expand, self.path)

        fp = await loop.run_in_executor(self.executor, self._open, path)

        try:
            while True:
                chunk = await loop.run_in_executor(self.executor, self._read_chunk, fp, _MAGIC)
                yield chunk
                if len(chunk) < _MAGIC:
                    break

        finally:
            await loop.run_in_executor(self.executor, self._close, fp)

    @staticmethod
    def _expand(path: pathlib.Path) -> pathlib.Path:
        # .expanduser is Platform dependent. Will expand stuff like ~ to /home/<user> on posix.
        # .resolve will follow symlinks and what-have-we to translate stuff like `..` to proper paths.
        return path.expanduser().resolve()

    @staticmethod
    @typing.final
    def _read_chunk(fp: typing.IO[bytes], n: int = 10_000) -> bytes:
        return fp.read(n)

    @staticmethod
    def _open(path: Pathish) -> typing.IO[bytes]:
        return open(path, "rb")

    @staticmethod
    def _close(fp: typing.IO[bytes]) -> None:
        fp.close()


@attr.s(auto_attribs=True, slots=False)
class MultiprocessingFileReader(FileReader):
    """Asynchronous file reader that reads a resource from local storage.

    This implementation works with pools that exist in a different interpreter
    instance to the caller. Currently this only includes ProcessPoolExecutors
    and custom implementations where objects have to be pickled to be used
    by the pool.
    """

    async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
        yield await asyncio.get_running_loop().run_in_executor(self.executor, self._read_all)

    def __getstate__(self) -> typing.Dict[str, typing.Any]:
        return {"path": self.path, "filename": self.filename}

    def __setstate__(self, state: typing.Dict[str, typing.Any]) -> None:
        self.path = state["path"]
        self.filename = state["filename"]
        self.executor = None
        self.mimetype = None

    def _read_all(self) -> bytes:
        # noinspection PyTypeChecker
        with open(self.path, "rb") as fp:
            return fp.read()


class File(Resource[FileReader]):
    """A resource that exists on the local machine's storage to be uploaded.

    Parameters
    ----------
    path : builtins.str or os.PathLike or pathlib.Path
        The path to use.

        !!! note
            If passing a `pathlib.Path`, this must not be a `pathlib.PurePath`
            directly, as it will be used to expand tokens such as `~` that
            denote the home directory, and `..` for relative paths.

            This will all be performed as required in an executor to prevent
            blocking the event loop.

    filename : builtins.str or builtins.None
        The filename to use. If this is `builtins.None`, the name of the file is taken
        from the path instead.
    """

    __slots__: typing.Sequence[str] = ("path", "_filename")

    path: pathlib.Path
    """The path to the file."""

    _filename: typing.Optional[str]

    def __init__(self, path: Pathish, filename: typing.Optional[str] = None) -> None:
        self.path = ensure_path(path)
        self._filename = filename

    @property
    @typing.final
    def url(self) -> str:
        return f"attachment://{self.filename}"

    @property
    def filename(self) -> str:
        if self._filename is None:
            return self.path.name
        return self._filename

    def stream(
        self, *, executor: typing.Optional[concurrent.futures.Executor] = None, head_only: bool = False,
    ) -> AsyncReaderContextManager[FileReader]:
        """Start streaming the resource using a thread pool executor.

        Parameters
        ----------
        executor : concurrent.futures.Executor or builtins.None
            The executor to run the blocking read operations in. If
            `builtins.None`, the default executor for the running event loop
            will be used instead.
        head_only : builtins.bool
            Not used. Provided only to match the underlying interface.

        Returns
        -------
        AsyncReaderContextManager[FileReader]
            An async context manager that when entered, produces the
            data stream.
        """
        # asyncio forces the default executor when this is None to always be a thread pool executor anyway,
        # so this is safe enough to do.:
        is_threaded = executor is None or isinstance(executor, concurrent.futures.ThreadPoolExecutor)
        impl = ThreadedFileReader if is_threaded else MultiprocessingFileReader
        # noinspection PyArgumentList
        return _NoOpAsyncReaderContextManagerImpl(impl(self.filename, None, executor, self.path))


########################################################################
# RAW BYTE, ASYNC ITERATOR, ASYNC ITERABLE, ITERATOR, ITERABLE READERS #
########################################################################


@attr.s(auto_attribs=True, slots=True)
class IteratorReader(AsyncReader):
    """Asynchronous file reader that operates on in-memory data."""

    data: typing.Union[bytes, LazyByteIteratorish]
    """The data that will be yielded in chunks."""

    async def __aiter__(self) -> typing.AsyncGenerator[typing.Any, bytes]:
        buff = bytearray()
        iterator = self._wrap_iter()

        while True:
            try:
                while len(buff) < _MAGIC:
                    chunk = await iterator.__anext__()
                    buff.extend(chunk)
                yield bytes(buff)
                buff.clear()
            except StopAsyncIteration:
                break

        if buff:
            yield bytes(buff)

    async def _wrap_iter(self) -> typing.AsyncGenerator[typing.Any, bytes]:
        if isinstance(self.data, bytes):
            for i in range(0, len(self.data), _MAGIC):
                yield self.data[i : i + _MAGIC]  # noqa: E203

        elif aio.is_async_iterator(self.data) or inspect.isasyncgen(self.data):
            try:
                while True:
                    yield self._assert_bytes(await self.data.__anext__())  # type: ignore[union-attr]
            except StopAsyncIteration:
                pass

        elif isinstance(self.data, typing.Iterator):
            try:
                while True:
                    yield self._assert_bytes(next(self.data))
            except StopIteration:
                pass

        elif inspect.isgenerator(self.data):
            try:
                while True:
                    yield self._assert_bytes(self.data.send(None))  # type: ignore[union-attr]
            except StopIteration:
                pass

        elif aio.is_async_iterable(self.data):
            async for chunk in self.data:  # type: ignore[union-attr]
                yield self._assert_bytes(chunk)

        elif isinstance(self.data, typing.Iterable):
            for chunk in self.data:
                yield self._assert_bytes(chunk)

        else:
            # Will always fail.
            self._assert_bytes(self.data)

    @staticmethod
    def _assert_bytes(data: typing.Any) -> bytes:
        if isinstance(data, str):
            return bytes(data, "utf-8")

        if not isinstance(data, bytes):
            raise TypeError(f"Expected bytes but received {type(data).__name__}")
        return data


class Bytes(Resource[IteratorReader]):
    """Representation of in-memory data to upload.

    Parameters
    ----------
    data : Rawish or LazyByteIteratorish
        The raw data.
    filename : builtins.str
        The filename to use.
    mimetype : builtins.str or builtins.None
        The mimetype, or `builtins.None` if you do not wish to specify this.
        If not provided, then this will be generated from the file extension
        of the filename instead.
    """

    __slots__: typing.Sequence[str] = ("data", "_filename", "mimetype")

    data: typing.Union[bytes, LazyByteIteratorish]
    """The raw data/provider of raw data to upload."""

    mimetype: typing.Optional[str]
    """The provided mimetype, if specified. Otherwise `builtins.None`."""

    def __init__(
        self, data: typing.Union[Rawish, LazyByteIteratorish], filename: str, /, mimetype: typing.Optional[str] = None,
    ) -> None:
        if isinstance(data, RAWISH_TYPES):
            data = unwrap_bytes(data)

        self.data = data

        if mimetype is None:
            mimetype = guess_mimetype_from_filename(filename)

        if mimetype is None:
            # TODO: should I just default to application/octet-stream here?
            mimetype = "text/plain"

        self._filename = filename
        self.mimetype = mimetype

    @property
    def url(self) -> str:
        return f"attachment://{self.filename}"

    @property
    def filename(self) -> str:
        return self._filename

    def stream(
        self, *, executor: typing.Optional[concurrent.futures.Executor] = None, head_only: bool = False,
    ) -> AsyncReaderContextManager[IteratorReader]:
        """Start streaming the content in chunks.

        Parameters
        ----------
        executor : concurrent.futures.Executor or builtins.None
            Not used. Provided only to match the underlying interface.
        head_only : builtins.bool
            Not used. Provided only to match the underlying interface.

        Returns
        -------
        AsyncReaderContextManager[IteratorReader]
            An async context manager that when entered, produces the
            data stream.
        """
        return _NoOpAsyncReaderContextManagerImpl(IteratorReader(self.filename, self.mimetype, self.data))

    @staticmethod
    def from_data_uri(data_uri: str, filename: typing.Optional[str] = None) -> Bytes:
        """Parse a given data URI.

        Parameters
        ----------
        data_uri : builtins.str
            The data URI to parse.
        filename : builtins.str or builtins.None
            Filename to use. If this is not provided, then this is generated
            instead.

        Returns
        -------
        Bytes
            The parsed data URI as a `Bytes` object.

        Raises
        ------
        builtins.ValueError
            If the parsed argument is not a data URI.
        """
        if not data_uri.startswith("data:"):
            raise ValueError("Invalid data URI passed")

        # This won't block for a data URI; if it was a URL, it would block, so
        # we guard against this with the check above.
        try:
            with urllib.request.urlopen(data_uri) as response:  # noqa: S310   audit url open for permitted schemes
                # TODO: make this smarter by using regex or something to get the mimetype.
                # We can't always "just parse" the whole uri using regex, as extra
                # params like encoding can be included, e.g.
                # data:text/plain;charset=utf-8;base64,aGVsbG8gPDM=
                mimetype = data_uri.split(";", 1)[0][5:]
                data = response.read()
        except Exception as ex:
            raise ValueError("Failed to decode data URI") from ex

        if filename is None:
            filename = generate_filename_from_details(mimetype=mimetype, data=data)

        return Bytes(data, filename, mimetype=mimetype)
