"""
The MIT License (MIT)

Copyright (c) 2024-present MCausc78

Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from inspect import isawaitable
import io
import logging
import typing
from urllib.parse import quote

import aiohttp
from attrs import define, field
from multidict import CIMultiDict

from . import __version__, utils
from .adapter import HTTPResponse, HTTPAdapter, AIOHTTPAdapter
from .enums import AssetMetadataType
from .errors import HTTPException

if typing.TYPE_CHECKING:
    from typing_extensions import Self

    from . import raw
    from .state import State


_L = logging.getLogger(__name__)


@define(slots=True)
class AssetMetadata:
    """Represents metadata associated with an asset."""

    type: AssetMetadataType = field(repr=True, kw_only=True, eq=True)
    """:class:`.AssetMetadataType`: The metadata's type."""

    width: typing.Optional[int] = field(repr=True, kw_only=True, eq=True)
    """Optional[:class:`int`]: The image/video's width, if applicable."""

    height: typing.Optional[int] = field(repr=True, kw_only=True, eq=True)
    """Optional[:class:`int`]: The image/video's height, if applicable."""

    def to_dict(self) -> raw.Metadata:
        """:class:`dict`: Convert metadata to raw data."""

        if self.type is AssetMetadataType.file:
            return {'type': 'File'}
        elif self.type is AssetMetadataType.text:
            return {'type': 'Text'}
        elif self.type is AssetMetadataType.image:
            return {
                'type': 'Image',
                'width': self.width or 0,
                'height': self.height or 0,
            }
        elif self.type is AssetMetadataType.video:
            return {
                'type': 'Video',
                'width': self.width or 0,
                'height': self.height or 0,
            }
        elif self.type is AssetMetadataType.audio:
            return {'type': 'Audio'}
        else:
            raise TypeError(f'Unknown type: {self.type!r}')


Tag = typing.Literal['icons', 'banners', 'emojis', 'backgrounds', 'avatars', 'attachments']


@define(slots=True)
class StatelessAsset:
    """Represents a stateless file on Revolt generated by Autumn.

    For better user experience, prefer using ``parent.foo`` rather than ``parent.internal_foo``.
    """

    id: str = field(repr=True, kw_only=True)
    """:class:`str`: The asset's ID."""

    filename: str = field(repr=True, kw_only=True)
    """:class:`str`: The asset's original filename."""

    metadata: AssetMetadata = field(repr=True, kw_only=True)
    """:class:`.AssetMetadata`: Parsed metadata of this file."""

    content_type: str = field(repr=True, kw_only=True)
    """:class:`str` The asset's content type."""

    size: int = field(repr=True, kw_only=True)
    """:class:`int`: The asset's size, in bytes."""

    deleted: bool = field(repr=True, kw_only=True)
    """:class:`bool`: Whether the file was deleted."""

    reported: bool = field(repr=True, kw_only=True)
    """:class:`bool`: Whether the file was reported."""

    message_id: typing.Optional[str] = field(repr=True, kw_only=True)
    """Optional[:class:`str`]: The message's ID the asset is associated with."""

    user_id: typing.Optional[str] = field(repr=True, kw_only=True)
    """Optional[:class:`str`]: The user's ID the asset is associated with."""

    server_id: typing.Optional[str] = field(repr=True, kw_only=True)
    """Optional[:class:`str`]: The server's ID the asset is associated with."""

    object_id: typing.Optional[str] = field(repr=True, kw_only=True)
    """Optional[:class:`str`]: The object's ID the asset is associated with."""

    def __hash__(self) -> int:
        return hash(self.id)

    def __eq__(self, other: object, /) -> bool:
        return self is other or isinstance(other, StatelessAsset) and self.id == other.id

    def attach_state(self, state: State, tag: Tag, /) -> Asset:
        """:class:`.Asset`: Attach a state to asset.

        Parameters
        ----------
        state: :class:`.State`
            The state to attach.
        tag: :class:`.Tag`
            The asset's tag.
        """
        return Asset(
            id=self.id,
            filename=self.filename,
            metadata=self.metadata,
            content_type=self.content_type,
            size=self.size,
            deleted=self.deleted,
            reported=self.reported,
            message_id=self.message_id,
            user_id=self.user_id,
            server_id=self.server_id,
            object_id=self.object_id,
            # Stateful properties
            state=state,
            tag=tag,
        )

    def to_dict(self, tag: Tag, /) -> raw.File:
        """:class:`dict`: Convert asset to raw data.

        Parameters
        ----------
        tag: :class:`.Tag`
            The asset tag.
        """

        payload: raw.File = {
            '_id': self.id,
            'tag': tag,
            'filename': self.filename,
            'metadata': self.metadata.to_dict(),
            'content_type': self.content_type,
            'size': self.size,
        }
        if self.deleted:
            payload['deleted'] = self.deleted
        if self.reported:
            payload['reported'] = self.reported
        if self.message_id is not None:
            payload['message_id'] = self.message_id
        if self.user_id is not None:
            payload['user_id'] = self.user_id
        if self.server_id is not None:
            payload['server_id'] = self.server_id
        if self.object_id is not None:
            payload['object_id'] = self.object_id
        return payload


@define(slots=True)
class Asset(StatelessAsset):
    """Represents an asset on Revolt."""

    state: State = field(repr=False, hash=False, kw_only=True, eq=False)
    tag: Tag = field(repr=True, kw_only=True)

    def __hash__(self) -> int:
        return hash(self.id)

    def url(self) -> str:
        """:class:`str`: The asset URL."""
        return self.state.cdn_client.url_for(self.id, self.tag)

    async def read(
        self,
    ) -> bytes:
        """|coro|

        Read asset contents.

        Returns
        -------
        :class:`bytes`
            The asset contents.
        """
        return await self.state.cdn_client.read(self.tag, self.id)


class Resource(ABC):
    @abstractmethod
    async def upload(self, cdn_client: CDNClient, tag: Tag, /) -> str:
        """:class:`str`: Uploads the resource to CDN, then returns ID.

        Parameters
        ----------
        cdn_client: :class:`.CDNClient`
            The CDN client to use.
        tag: :class:`.Tag`
            The tag to upload resource to.
        """
        ...


_cdn_session: typing.Optional[aiohttp.ClientSession] = None

DEFAULT_CDN_USER_AGENT = f'pyvolt (https://github.com/MCausc78/pyvolt, {__version__})'


def _get_session() -> aiohttp.ClientSession:
    global _cdn_session
    if _cdn_session:
        return _cdn_session
    _cdn_session = aiohttp.ClientSession()
    return _cdn_session


Content = typing.Union[bytes, str, bytearray, io.IOBase]


def resolve_content(content: Content, /) -> typing.Union[bytes, io.IOBase]:
    if isinstance(content, bytearray):
        return bytes(content)
    elif isinstance(content, str):
        return content.encode()
    else:
        return content


class Upload(Resource):
    """Represents a file upload.

    Attributes
    ----------
    content: Union[:class:`bytes`, :class:`~io.IOBase`]
        The file contents.
    tag: Optional[:class:`Tag`]
        The attachment tag. If none, this is determined automatically.
    filename: :class:`str`
        The file name.
    """

    __slots__ = (
        'tag',
        'filename',
        'content',
    )

    def __init__(self, content: Content, *, tag: typing.Optional[Tag] = None, filename: str) -> None:
        self.tag: typing.Optional[Tag] = tag
        self.filename: str = filename
        self.content: Content = resolve_content(content)

    @classmethod
    def attachment(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'attachments'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='attachments', filename=filename)

    @classmethod
    def avatar(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'avatars'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='avatars', filename=filename)

    @classmethod
    def background(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'backgrounds'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='backgrounds', filename=filename)

    @classmethod
    def banner(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'banners'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='banners', filename=filename)

    @classmethod
    def emoji(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'emojis'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='emojis', filename=filename)

    @classmethod
    def icon(cls, content: Content, *, filename: str) -> Self:
        """Creates an upload with ``'icons'`` tag.

        Parameters
        ----------
        content: Union[:class:`bytes`, :class:`~io.IOBase`]
            The content to upload.
        filename: :class:`str`
            The filename.

        Returns
        -------
        :class:`.Upload`
            The constructed upload.
        """
        return cls(content, tag='icons', filename=filename)

    @utils.copy_doc(Resource.upload)
    async def upload(self, cdn_client: CDNClient, tag: Tag, /) -> str:
        form = aiohttp.FormData()
        form.add_field('file', self.content, filename=self.filename)

        return await cdn_client.upload(self.tag or tag, form)


ResolvableResource = typing.Union[Resource, str, bytes, tuple[str, Content]]


async def resolve_resource(state: State, resolvable: ResolvableResource, /, *, tag: Tag) -> str:
    """Resolve a resource.

    Parameters
    ----------
    state: :class:`.State`
        The state.
    resolvable: :class:`ResolvableResource`
        The object that should be resolved.

    Returns
    -------
    :class:`str`
        The uploaded file ID.
    """
    if isinstance(resolvable, Resource):
        return await resolvable.upload(state.cdn_client, tag)
    elif isinstance(resolvable, str):
        return resolvable
    elif isinstance(resolvable, bytes):
        return await Upload(resolvable, filename='untitled0.png').upload(state.cdn_client, tag)
        # return await state.cdn_client.upload(Upload(resolvable, filename="untitled0"), tag)
    elif isinstance(resolvable, tuple):
        return await Upload(resolve_content(resolvable[1]), filename=resolvable[0]).upload(state.cdn_client, tag)
        # return await state.cdn_client.upload(Upload(resolve_content(resolvable[1]), filename=resolvable[0]), tag)
    else:
        return ''


class CDNClient:
    """Represents a HTTP client sending HTTP requests to the Revolt Autumn API.

    Attributes
    ----------
    state: :class:`.State`
        The state.
    user_agent: :class:`str`
        The HTTP user agent used when making requests.
    """

    __slots__ = (
        '_adapter',
        '_base',
        'state',
        'user_agent',
    )

    def __init__(
        self,
        *,
        base: typing.Optional[str] = None,
        adapter: typing.Optional[typing.Union[utils.MaybeAwaitableFunc[[CDNClient], HTTPAdapter], HTTPAdapter]] = None,
        state: State,
        user_agent: typing.Optional[str] = None,
    ) -> None:
        if base is None:
            base = 'https://cdn.revoltusercontent.com'

        self._adapter: typing.Optional[
            typing.Union[utils.MaybeAwaitableFunc[[CDNClient], HTTPAdapter], HTTPAdapter]
        ] = adapter
        self._base = base.rstrip('/')
        self.state: State = state
        self.user_agent: str = user_agent or DEFAULT_CDN_USER_AGENT

    async def get_adapter(self) -> HTTPAdapter:
        if self._adapter is None:
            adapter = AIOHTTPAdapter()
            self._adapter = adapter
            return adapter

        if callable(self._adapter):
            ret = self._adapter(self)
            if isawaitable(ret):
                ret = await ret
            await ret.startup()
            self._adapter = ret
            return ret

        return self._adapter

    def maybe_get_adapter(self) -> typing.Optional[HTTPAdapter]:
        if self._adapter is None or not isinstance(self._adapter, HTTPAdapter):
            return None
        return self._adapter

    @property
    def adapter(self) -> HTTPAdapter:
        if self._adapter is None or (callable(self._adapter) and not isinstance(self._adapter, HTTPAdapter)):
            raise TypeError('No adapter is available')
        return self._adapter

    @property
    def base(self) -> str:
        """:class:`str`: The base URL"""
        return self._base

    @property
    def bot(self) -> bool:
        """:class:`bool`: Whether the token belongs to bot account."""
        return self.state.http.bot

    @property
    def token(self) -> str:
        """:class:`str`: The token in use. May be empty if not started."""
        return self.state.http.token

    async def request(self, method: str, route: str, /, **kwargs) -> HTTPResponse:
        headers: CIMultiDict[str]

        try:
            tmp = kwargs.pop('headers')
        except KeyError:
            headers = CIMultiDict()
        else:
            headers = CIMultiDict(tmp)

        if kwargs.pop('authenticated', True):
            th = 'X-Bot-Token' if self.bot else 'X-Session-Token'
            headers[th] = self.token

        if not kwargs.pop('manual_accept', False):
            headers['Accept'] = 'application/json'

        headers['User-Agent'] = self.user_agent

        url = self._base + route

        _L.debug('Sending request to %s', route)

        adapter = await self.get_adapter()

        response = await adapter.request(
            method,
            url,
            headers=headers,
            **kwargs,
        )
        if response.status >= 400:
            data = await utils._json_or_text(response)
            if isinstance(data, dict) and isinstance(data.get('error'), dict):
                error = data['error']
                code = error.get('code')
                reason = error.get('reason')
                description = error.get('description')
                data['type'] = 'Rocket error'
                data['err'] = f'{code} {reason}: {description}'

            from .http import _STATUS_TO_ERRORS

            raise _STATUS_TO_ERRORS.get(response.status, HTTPException)(response, data)
        return response

    def url_for(
        self,
        id: str,
        tag: Tag,
    ) -> str:
        """:class:`str`: Generates asset URL.

        Parameters
        ----------
        id: :class:`str`
            The asset's ID.
        tag: :class:`Tag`
            The asset's tag.
        """

        return f'{self._base}/{quote(tag)}/{quote(id)}'

    async def read(
        self,
        tag: Tag,
        id: str,
    ) -> bytes:
        response = await self.request('GET', f'/{quote(tag)}/{quote(id)}')
        data = await response.read()
        if not response.closed:
            ret = response.close()
            if isawaitable(ret):
                await ret
        return data

    async def upload(
        self,
        tag: Tag,
        data: aiohttp.FormData,
    ) -> str:
        response = await self.request('POST', f'/{quote(tag)}', data=data)
        rd = await utils._json_or_text(response)
        if not response.closed:
            ret = response.close()
            if isawaitable(ret):
                await ret
        return rd['id']


__all__ = (
    'AssetMetadata',
    'StatelessAsset',
    'Asset',
    'Tag',
    'Resource',
    '_cdn_session',
    'DEFAULT_CDN_USER_AGENT',
    '_get_session',
    'Content',
    'resolve_content',
    'Upload',
    'ResolvableResource',
    'resolve_resource',
    'CDNClient',
)
