# SPDX-License-Identifier: MIT

from __future__ import annotations

import datetime
from typing import (
    TYPE_CHECKING,
    Any,
    ClassVar,
    Dict,
    List,
    Literal,
    Mapping,
    Optional,
    Protocol,
    Sized,
    Union,
    cast,
    overload,
)

from . import utils
from .colour import Colour
from .file import File
from .utils import MISSING, classproperty, warn_deprecated

__all__ = ("Embed",)


# backwards compatibility, hidden from type-checkers to have them show errors when accessed
if not TYPE_CHECKING:

    def __getattr__(name: str) -> None:
        if name == "EmptyEmbed":
            warn_deprecated(
                "`EmptyEmbed` is deprecated and will be removed in a future version. Use `None` instead.",
                stacklevel=2,
            )
            return None
        raise AttributeError(f"module '{__name__}' has no attribute '{name}'")


class EmbedProxy:
    def __init__(self, layer: Optional[Mapping[str, Any]]) -> None:
        if layer is not None:
            self.__dict__.update(layer)

    def __len__(self) -> int:
        return len(self.__dict__)

    def __repr__(self) -> str:
        inner = ", ".join((f"{k}={v!r}" for k, v in self.__dict__.items() if not k.startswith("_")))
        return f"EmbedProxy({inner})"

    def __getattr__(self, attr: str) -> None:
        return None

    def __eq__(self, other: Any) -> bool:
        return isinstance(other, EmbedProxy) and self.__dict__ == other.__dict__


if TYPE_CHECKING:
    from typing_extensions import Self

    from disnake.types.embed import (
        Embed as EmbedData,
        EmbedAuthor as EmbedAuthorPayload,
        EmbedField as EmbedFieldPayload,
        EmbedFooter as EmbedFooterPayload,
        EmbedImage as EmbedImagePayload,
        EmbedProvider as EmbedProviderPayload,
        EmbedThumbnail as EmbedThumbnailPayload,
        EmbedType,
        EmbedVideo as EmbedVideoPayload,
    )

    class _EmbedFooterProxy(Sized, Protocol):
        text: Optional[str]
        icon_url: Optional[str]
        proxy_icon_url: Optional[str]

    class _EmbedFieldProxy(Sized, Protocol):
        name: Optional[str]
        value: Optional[str]
        inline: Optional[bool]

    class _EmbedMediaProxy(Sized, Protocol):
        url: Optional[str]
        proxy_url: Optional[str]
        height: Optional[int]
        width: Optional[int]

    class _EmbedVideoProxy(Sized, Protocol):
        url: Optional[str]
        proxy_url: Optional[str]
        height: Optional[int]
        width: Optional[int]

    class _EmbedProviderProxy(Sized, Protocol):
        name: Optional[str]
        url: Optional[str]

    class _EmbedAuthorProxy(Sized, Protocol):
        name: Optional[str]
        url: Optional[str]
        icon_url: Optional[str]
        proxy_icon_url: Optional[str]

    _FileKey = Literal["image", "thumbnail", "footer", "author"]


class Embed:
    """Represents a Discord embed.

    .. collapse:: operations

        .. describe:: x == y

            Checks if two embeds are equal.

            .. versionadded:: 2.6

        .. describe:: x != y

            Checks if two embeds are not equal.

            .. versionadded:: 2.6

        .. describe:: len(x)

            Returns the total size of the embed.
            Useful for checking if it's within the 6000 character limit.
            Check if all aspects of the embed are within the limits with :func:`Embed.check_limits`.

        .. describe:: bool(b)

            Returns whether the embed has any data set.

            .. versionadded:: 2.0

    Certain properties return an ``EmbedProxy``, a type
    that acts similar to a regular :class:`dict` except using dotted access,
    e.g. ``embed.author.icon_url``.

    For ease of use, all parameters that expect a :class:`str` are implicitly
    cast to :class:`str` for you.

    Attributes
    ----------
    title: Optional[:class:`str`]
        The title of the embed.
    type: Optional[:class:`str`]
        The type of embed. Usually "rich".
        Possible strings for embed types can be found on Discord's
        :ddocs:`api-docs <resources/channel#embed-object-embed-types>`.
    description: Optional[:class:`str`]
        The description of the embed.
    url: Optional[:class:`str`]
        The URL of the embed.
    timestamp: Optional[:class:`datetime.datetime`]
        The timestamp of the embed content. This is an aware datetime.
        If a naive datetime is passed, it is converted to an aware
        datetime with the local timezone.
    colour: Optional[:class:`Colour`]
        The colour code of the embed. Aliased to ``color`` as well.
        In addition to :class:`Colour`, :class:`int` can also be assigned to it,
        in which case the value will be converted to a :class:`Colour` object.
    """

    __slots__ = (
        "title",
        "url",
        "type",
        "_timestamp",
        "_colour",
        "_footer",
        "_image",
        "_thumbnail",
        "_video",
        "_provider",
        "_author",
        "_fields",
        "description",
        "_files",
    )

    _default_colour: ClassVar[Optional[Colour]] = None
    _colour: Optional[Colour]

    def __init__(
        self,
        *,
        title: Optional[Any] = None,
        type: Optional[EmbedType] = "rich",
        description: Optional[Any] = None,
        url: Optional[Any] = None,
        timestamp: Optional[datetime.datetime] = None,
        colour: Optional[Union[int, Colour]] = MISSING,
        color: Optional[Union[int, Colour]] = MISSING,
    ) -> None:
        self.title: Optional[str] = str(title) if title is not None else None
        self.type: Optional[EmbedType] = type
        self.description: Optional[str] = str(description) if description is not None else None
        self.url: Optional[str] = str(url) if url is not None else None

        self.timestamp = timestamp

        # possible values:
        # - MISSING: embed color will be _default_color
        # - None: embed color will not be set
        # - Color: embed color will be set to specified color
        if colour is not MISSING:
            color = colour
        self.colour = color

        self._thumbnail: Optional[EmbedThumbnailPayload] = None
        self._video: Optional[EmbedVideoPayload] = None
        self._provider: Optional[EmbedProviderPayload] = None
        self._author: Optional[EmbedAuthorPayload] = None
        self._image: Optional[EmbedImagePayload] = None
        self._footer: Optional[EmbedFooterPayload] = None
        self._fields: Optional[List[EmbedFieldPayload]] = None

        self._files: Dict[_FileKey, File] = {}

    # see `EmptyEmbed` above
    if not TYPE_CHECKING:

        @classproperty
        def Empty(self) -> None:
            warn_deprecated(
                "`Embed.Empty` is deprecated and will be removed in a future version. Use `None` instead.",
                stacklevel=3,
            )
            return None

    @classmethod
    def from_dict(cls, data: EmbedData) -> Self:
        """Converts a :class:`dict` to a :class:`Embed` provided it is in the
        format that Discord expects it to be in.

        You can find out about this format in the
        :ddocs:`official Discord documentation <resources/channel#embed-object>`.

        Parameters
        ----------
        data: :class:`dict`
            The dictionary to convert into an embed.
        """
        # we are bypassing __init__ here since it doesn't apply here
        self = cls.__new__(cls)

        # fill in the basic fields

        self.title = str(title) if (title := data.get("title")) is not None else None
        self.type = data.get("type")
        self.description = (
            str(description) if (description := data.get("description")) is not None else None
        )
        self.url = str(url) if (url := data.get("url")) is not None else None

        self._files = {}

        # try to fill in the more rich fields

        self.colour = data.get("color")
        self.timestamp = utils.parse_time(data.get("timestamp"))

        self._thumbnail = data.get("thumbnail")
        self._video = data.get("video")
        self._provider = data.get("provider")
        self._author = data.get("author")
        self._image = data.get("image")
        self._footer = data.get("footer")
        self._fields = data.get("fields")

        return self

    def copy(self) -> Self:
        """Returns a shallow copy of the embed."""
        embed = type(self).from_dict(self.to_dict())

        # assign manually to keep behavior of default colors
        embed._colour = self._colour

        # copy files and fields collections
        embed._files = self._files.copy()
        if self._fields is not None:
            embed._fields = self._fields.copy()

        return embed

    def __len__(self) -> int:
        total = len((self.title or "").strip()) + len((self.description or "").strip())
        if self._fields:
            for field in self._fields:
                total += len(field["name"].strip()) + len(field["value"].strip())

        if self._footer and (footer_text := self._footer.get("text")):
            total += len(footer_text.strip())

        if self._author and (author_name := self._author.get("name")):
            total += len(author_name.strip())

        return total

    def __bool__(self) -> bool:
        return any(
            (
                self.title,
                self.url,
                self.description,
                self._colour,
                self._fields,
                self._timestamp,
                self._author,
                self._thumbnail,
                self._footer,
                self._image,
                self._provider,
                self._video,
            )
        )

    def __eq__(self, other: Any) -> bool:
        if not isinstance(other, Embed):
            return False
        for slot in self.__slots__:
            if slot == "_colour":
                slot = "color"
            if (getattr(self, slot) or None) != (getattr(other, slot) or None):
                return False
        return True

    @property
    def colour(self) -> Optional[Colour]:
        col = self._colour
        return col if col is not MISSING else type(self)._default_colour

    @colour.setter
    def colour(self, value: Optional[Union[int, Colour]]) -> None:
        if isinstance(value, int):
            self._colour = Colour(value=value)
        elif value is MISSING or value is None or isinstance(value, Colour):
            self._colour = value
        else:
            raise TypeError(
                f"Expected disnake.Colour, int, or None but received {type(value).__name__} instead."
            )

    @colour.deleter
    def colour(self) -> None:
        self._colour = MISSING

    color = colour

    @property
    def timestamp(self) -> Optional[datetime.datetime]:
        return self._timestamp

    @timestamp.setter
    def timestamp(self, value: Optional[datetime.datetime]) -> None:
        if isinstance(value, datetime.datetime):
            if value.tzinfo is None:
                value = value.astimezone()
            self._timestamp = value
        elif value is None:
            self._timestamp = value
        else:
            raise TypeError(
                f"Expected datetime.datetime or None received {type(value).__name__} instead"
            )

    @property
    def footer(self) -> _EmbedFooterProxy:
        """Returns an ``EmbedProxy`` denoting the footer contents.

        Possible attributes you can access are:

        - ``text``
        - ``icon_url``
        - ``proxy_icon_url``

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedFooterProxy", EmbedProxy(self._footer))

    @overload
    def set_footer(self, *, text: Any, icon_url: Optional[Any] = ...) -> Self:
        ...

    @overload
    def set_footer(self, *, text: Any, icon_file: File = ...) -> Self:
        ...

    def set_footer(
        self, *, text: Any, icon_url: Optional[Any] = MISSING, icon_file: File = MISSING
    ) -> Self:
        """Sets the footer for the embed content.

        This function returns the class instance to allow for fluent-style
        chaining.

        At most one of ``icon_url`` or ``icon_file`` may be passed.

        .. warning::
            Passing a :class:`disnake.File` object will make the embed not
            reusable.

        .. warning::
            If used with the other ``set_*`` methods, you must ensure
            that the :attr:`.File.filename` is unique to avoid duplication.

        Parameters
        ----------
        text: :class:`str`
            The footer text.

            .. versionchanged:: 2.6
                No longer optional, must be set to a valid string.

        icon_url: Optional[:class:`str`]
            The URL of the footer icon. Only HTTP(S) is supported.
        icon_file: :class:`File`
            The file to use as the footer icon.

            .. versionadded:: 2.10
        """
        self._footer = {
            "text": str(text),
        }

        result = self._handle_resource(icon_url, icon_file, key="footer", required=False)
        if result is not None:
            self._footer["icon_url"] = result

        return self

    def remove_footer(self) -> Self:
        """Clears embed's footer information.

        This function returns the class instance to allow for fluent-style
        chaining.

        .. versionadded:: 2.0
        """
        self._footer = None
        return self

    @property
    def image(self) -> _EmbedMediaProxy:
        """Returns an ``EmbedProxy`` denoting the image contents.

        Possible attributes you can access are:

        - ``url``
        - ``proxy_url``
        - ``width``
        - ``height``

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedMediaProxy", EmbedProxy(self._image))

    @overload
    def set_image(self, url: Optional[Any]) -> Self:
        ...

    @overload
    def set_image(self, *, file: File) -> Self:
        ...

    def set_image(self, url: Optional[Any] = MISSING, *, file: File = MISSING) -> Self:
        """Sets the image for the embed content.

        This function returns the class instance to allow for fluent-style
        chaining.

        Exactly one of ``url`` or ``file`` must be passed.

        .. warning::
            Passing a :class:`disnake.File` object will make the embed not
            reusable.

        .. warning::
            If used with the other ``set_*`` methods, you must ensure
            that the :attr:`.File.filename` is unique to avoid duplication.

        .. versionchanged:: 1.4
            Passing ``None`` removes the image.

        Parameters
        ----------
        url: Optional[:class:`str`]
            The source URL for the image. Only HTTP(S) is supported.
        file: :class:`File`
            The file to use as the image.

            .. versionadded:: 2.2
        """
        result = self._handle_resource(url, file, key="image")
        self._image = {"url": result} if result is not None else None
        return self

    @property
    def thumbnail(self) -> _EmbedMediaProxy:
        """Returns an ``EmbedProxy`` denoting the thumbnail contents.

        Possible attributes you can access are:

        - ``url``
        - ``proxy_url``
        - ``width``
        - ``height``

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedMediaProxy", EmbedProxy(self._thumbnail))

    @overload
    def set_thumbnail(self, url: Optional[Any]) -> Self:
        ...

    @overload
    def set_thumbnail(self, *, file: File) -> Self:
        ...

    def set_thumbnail(self, url: Optional[Any] = MISSING, *, file: File = MISSING) -> Self:
        """Sets the thumbnail for the embed content.

        This function returns the class instance to allow for fluent-style
        chaining.

        Exactly one of ``url`` or ``file`` must be passed.

        .. warning::
            Passing a :class:`disnake.File` object will make the embed not
            reusable.

        .. warning::
            If used with the other ``set_*`` methods, you must ensure
            that the :attr:`.File.filename` is unique to avoid duplication.

        .. versionchanged:: 1.4
            Passing ``None`` removes the thumbnail.

        Parameters
        ----------
        url: Optional[:class:`str`]
            The source URL for the thumbnail. Only HTTP(S) is supported.
        file: :class:`File`
            The file to use as the image.

            .. versionadded:: 2.2
        """
        result = self._handle_resource(url, file, key="thumbnail")
        self._thumbnail = {"url": result} if result is not None else None
        return self

    @property
    def video(self) -> _EmbedVideoProxy:
        """Returns an ``EmbedProxy`` denoting the video contents.

        Possible attributes include:

        - ``url`` for the video URL.
        - ``proxy_url`` for the proxied video URL.
        - ``height`` for the video height.
        - ``width`` for the video width.

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedVideoProxy", EmbedProxy(self._video))

    @property
    def provider(self) -> _EmbedProviderProxy:
        """Returns an ``EmbedProxy`` denoting the provider contents.

        The only attributes that might be accessed are ``name`` and ``url``.

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedProviderProxy", EmbedProxy(self._provider))

    @property
    def author(self) -> _EmbedAuthorProxy:
        """Returns an ``EmbedProxy`` denoting the author contents.

        See :meth:`set_author` for possible values you can access.

        If an attribute is not set, it will be ``None``.
        """
        return cast("_EmbedAuthorProxy", EmbedProxy(self._author))

    @overload
    def set_author(
        self, *, name: Any, url: Optional[Any] = ..., icon_url: Optional[Any] = ...
    ) -> Self:
        ...

    @overload
    def set_author(self, *, name: Any, url: Optional[Any] = ..., icon_file: File = ...) -> Self:
        ...

    def set_author(
        self,
        *,
        name: Any,
        url: Optional[Any] = None,
        icon_url: Optional[Any] = MISSING,
        icon_file: File = MISSING,
    ) -> Self:
        """Sets the author for the embed content.

        This function returns the class instance to allow for fluent-style
        chaining.

        At most one of ``icon_url`` or ``icon_file`` may be passed.

        .. warning::
            Passing a :class:`disnake.File` object will make the embed not
            reusable.

        .. warning::
            If used with the other ``set_*`` methods, you must ensure
            that the :attr:`.File.filename` is unique to avoid duplication.

        Parameters
        ----------
        name: :class:`str`
            The name of the author.
        url: Optional[:class:`str`]
            The URL for the author.
        icon_url: Optional[:class:`str`]
            The URL of the author icon. Only HTTP(S) is supported.
        icon_file: :class:`File`
            The file to use as the author icon.

            .. versionadded:: 2.10
        """
        self._author = {
            "name": str(name),
        }

        if url is not None:
            self._author["url"] = str(url)

        result = self._handle_resource(icon_url, icon_file, key="author", required=False)
        if result is not None:
            self._author["icon_url"] = result

        return self

    def remove_author(self) -> Self:
        """Clears embed's author information.

        This function returns the class instance to allow for fluent-style
        chaining.

        .. versionadded:: 1.4
        """
        self._author = None
        return self

    @property
    def fields(self) -> List[_EmbedFieldProxy]:
        """List[``EmbedProxy``]: Returns a :class:`list` of ``EmbedProxy`` denoting the field contents.

        See :meth:`add_field` for possible values you can access.

        If an attribute is not set, it will be ``None``.
        """
        return cast("List[_EmbedFieldProxy]", [EmbedProxy(d) for d in (self._fields or [])])

    def add_field(self, name: Any, value: Any, *, inline: bool = True) -> Self:
        """Adds a field to the embed object.

        This function returns the class instance to allow for fluent-style
        chaining.

        Parameters
        ----------
        name: :class:`str`
            The name of the field.
        value: :class:`str`
            The value of the field.
        inline: :class:`bool`
            Whether the field should be displayed inline.
            Defaults to ``True``.
        """
        field: EmbedFieldPayload = {
            "inline": inline,
            "name": str(name),
            "value": str(value),
        }

        if self._fields is not None:
            self._fields.append(field)
        else:
            self._fields = [field]

        return self

    def insert_field_at(self, index: int, name: Any, value: Any, *, inline: bool = True) -> Self:
        """Inserts a field before a specified index to the embed.

        This function returns the class instance to allow for fluent-style
        chaining.

        .. versionadded:: 1.2

        Parameters
        ----------
        index: :class:`int`
            The index of where to insert the field.
        name: :class:`str`
            The name of the field.
        value: :class:`str`
            The value of the field.
        inline: :class:`bool`
            Whether the field should be displayed inline.
            Defaults to ``True``.
        """
        field: EmbedFieldPayload = {
            "inline": inline,
            "name": str(name),
            "value": str(value),
        }

        if self._fields is not None:
            self._fields.insert(index, field)
        else:
            self._fields = [field]

        return self

    def clear_fields(self) -> None:
        """Removes all fields from this embed."""
        self._fields = None

    def remove_field(self, index: int) -> None:
        """Removes a field at a specified index.

        If the index is invalid or out of bounds then the error is
        silently swallowed.

        .. note::

            When deleting a field by index, the index of the other fields
            shift to fill the gap just like a regular list.

        Parameters
        ----------
        index: :class:`int`
            The index of the field to remove.
        """
        if self._fields is not None:
            try:
                del self._fields[index]
            except IndexError:
                pass

    def set_field_at(self, index: int, name: Any, value: Any, *, inline: bool = True) -> Self:
        """Modifies a field to the embed object.

        The index must point to a valid pre-existing field.

        This function returns the class instance to allow for fluent-style
        chaining.

        Parameters
        ----------
        index: :class:`int`
            The index of the field to modify.
        name: :class:`str`
            The name of the field.
        value: :class:`str`
            The value of the field.
        inline: :class:`bool`
            Whether the field should be displayed inline.
            Defaults to ``True``.

        Raises
        ------
        IndexError
            An invalid index was provided.
        """
        if not self._fields:
            raise IndexError("field index out of range")
        try:
            self._fields[index]
        except IndexError:
            raise IndexError("field index out of range") from None

        field: EmbedFieldPayload = {
            "inline": inline,
            "name": str(name),
            "value": str(value),
        }
        self._fields[index] = field
        return self

    def to_dict(self) -> EmbedData:
        """Converts this embed object into a dict."""
        # add in the raw data into the dict
        result: EmbedData = {}
        if self._footer is not None:
            result["footer"] = self._footer
        if self._image is not None:
            result["image"] = self._image
        if self._thumbnail is not None:
            result["thumbnail"] = self._thumbnail
        if self._video is not None:
            result["video"] = self._video
        if self._provider is not None:
            result["provider"] = self._provider
        if self._author is not None:
            result["author"] = self._author
        if self._fields is not None:
            result["fields"] = self._fields

        # deal with basic convenience wrappers
        if self.colour:
            result["color"] = self.colour.value

        if self._timestamp:
            result["timestamp"] = utils.isoformat_utc(self._timestamp)

        # add in the non raw attribute ones
        if self.type:
            result["type"] = self.type

        if self.description:
            result["description"] = self.description

        if self.url:
            result["url"] = self.url

        if self.title:
            result["title"] = self.title

        return result

    @classmethod
    def set_default_colour(cls, value: Optional[Union[int, Colour]]):
        """Set the default colour of all new embeds.

        .. versionadded:: 2.4

        Returns
        -------
        Optional[:class:`Colour`]
            The colour that was set.
        """
        if value is None or isinstance(value, Colour):
            cls._default_colour = value
        elif isinstance(value, int):
            cls._default_colour = Colour(value=value)
        else:
            raise TypeError(
                f"Expected disnake.Colour, int, or None but received {type(value).__name__} instead."
            )
        return cls._default_colour

    set_default_color = set_default_colour

    @classmethod
    def get_default_colour(cls) -> Optional[Colour]:
        """Get the default colour of all new embeds.

        .. versionadded:: 2.4

        Returns
        -------
        Optional[:class:`Colour`]
            The default colour.

        """
        return cls._default_colour

    get_default_color = get_default_colour

    def _handle_resource(
        self, url: Optional[Any], file: Optional[File], *, key: _FileKey, required: bool = True
    ) -> Optional[str]:
        if required:
            if not (url is MISSING) ^ (file is MISSING):
                raise TypeError("Exactly one of url or file must be provided")
        else:
            if url is not MISSING and file is not MISSING:
                raise TypeError("At most one of url or file may be provided, not both.")

        if file:
            if file.filename is None:
                raise TypeError("File must have a filename")
            self._files[key] = file
            return f"attachment://{file.filename}"
        else:
            self._files.pop(key, None)
            return str(url) if url else None

    def check_limits(self) -> None:
        """Checks if this embed fits within the limits dictated by Discord.
        There is also a 6000 character limit across all embeds in a message.

        Returns nothing on success, raises :exc:`ValueError` if an attribute exceeds the limits.

        +--------------------------+------------------------------------+
        |   Field                  |              Limit                 |
        +--------------------------+------------------------------------+
        | title                    |        256 characters              |
        +--------------------------+------------------------------------+
        | description              |        4096 characters             |
        +--------------------------+------------------------------------+
        | fields                   |        Up to 25 field objects      |
        +--------------------------+------------------------------------+
        | field.name               |        256 characters              |
        +--------------------------+------------------------------------+
        | field.value              |        1024 characters             |
        +--------------------------+------------------------------------+
        | footer.text              |        2048 characters             |
        +--------------------------+------------------------------------+
        | author.name              |        256 characters              |
        +--------------------------+------------------------------------+

        .. versionadded:: 2.6

        Raises
        ------
        ValueError
            One or more of the embed attributes are too long.
        """
        if self.title and len(self.title.strip()) > 256:
            raise ValueError("Embed title cannot be longer than 256 characters")

        if self.description and len(self.description.strip()) > 4096:
            raise ValueError("Embed description cannot be longer than 4096 characters")

        if self._footer and len(self._footer.get("text", "").strip()) > 2048:
            raise ValueError("Embed footer text cannot be longer than 2048 characters")

        if self._author and len(self._author.get("name", "").strip()) > 256:
            raise ValueError("Embed author name cannot be longer than 256 characters")

        if self._fields:
            if len(self._fields) > 25:
                raise ValueError("Embeds cannot have more than 25 fields")

            for field_index, field in enumerate(self._fields):
                if len(field["name"].strip()) > 256:
                    raise ValueError(
                        f"Embed field {field_index} name cannot be longer than 256 characters"
                    )
                if len(field["value"].strip()) > 1024:
                    raise ValueError(
                        f"Embed field {field_index} value cannot be longer than 1024 characters"
                    )

        if len(self) > 6000:
            raise ValueError("Embed total size cannot be longer than 6000 characters")
