import copy
import logging
import warnings
from datetime import datetime
from typing import List, Optional, Set, Union

from . import (
    BaseObject,
    JsonObject,
    JsonValidator,
    show_unknown_key_warning,
)

ButtonStyles = {"danger", "primary"}
DynamicSelectElementTypes = {"channels", "conversations", "users"}


class Link(BaseObject):
    def __init__(self, *, url: str, text: str):
        """Base class used to generate links in Slack's not-quite Markdown, not quite HTML syntax
        https://api.slack.com/docs/message-formatting#linking_to_urls
        """
        self.url = url
        self.text = text

    def __str__(self):
        if self.text:
            separator = "|"
        else:
            separator = ""
        return f"<{self.url}{separator}{self.text}>"


class DateLink(Link):
    def __init__(
        self,
        *,
        date: Union[datetime, int],
        date_format: str,
        fallback: str,
        link: Optional[str] = None,
    ):
        """Text containing a date or time should display that date in the local timezone of the person seeing the text.
        https://api.slack.com/reference/surfaces/formatting#date-formatting
        """
        if isinstance(date, datetime):
            epoch = int(date.timestamp())
        else:
            epoch = date
        if link is not None:
            link = f"^{link}"
        else:
            link = ""
        super().__init__(url=f"!date^{epoch}^{date_format}{link}", text=fallback)


class ObjectLink(Link):
    prefix_mapping = {
        "C": "#",  # channel
        "G": "#",  # group message
        "U": "@",  # user
        "W": "@",  # workspace user (enterprise)
        "B": "@",  # bot user
        "S": "!subteam^",  # user groups, originally known as subteams
    }

    def __init__(self, *, object_id: str, text: str = ""):
        """Convenience class to create links to specific object types
        https://api.slack.com/reference/surfaces/formatting#linking-channels
        """
        prefix = self.prefix_mapping.get(object_id[0].upper(), "@")
        super().__init__(url=f"{prefix}{object_id}", text=text)


class ChannelLink(Link):
    def __init__(self):
        """Represents an @channel link, which notifies everyone present in this channel.
        https://api.slack.com/reference/surfaces/formatting
        """
        super().__init__(url="!channel", text="channel")


class HereLink(Link):
    def __init__(self):
        """Represents an @here link, which notifies all online users of this channel.
        https://api.slack.com/reference/surfaces/formatting
        """
        super().__init__(url="!here", text="here")


class EveryoneLink(Link):
    def __init__(self):
        """Represents an @everyone link, which notifies all users of this workspace.
        https://api.slack.com/reference/surfaces/formatting
        """
        super().__init__(url="!everyone", text="everyone")


class TextObject(JsonObject):
    attributes = {"text", "type", "emoji"}
    logger = logging.getLogger(__name__)

    @staticmethod
    def _subtype_warning():
        warnings.warn(
            "subtype is deprecated since slackclient 2.6.0, use type instead",
            DeprecationWarning,
        )

    @property
    def subtype(self) -> Optional[str]:
        return self.type

    @classmethod
    def parse(
        cls, text: Union[str, dict, "TextObject"], default_type: str = "mrkdwn"
    ) -> Optional["TextObject"]:
        if not text:  # skipcq: PYL-R1705
            return None
        elif isinstance(text, str):
            if default_type == PlainTextObject.type:  # skipcq: PYL-R1705
                return PlainTextObject.from_str(text)
            else:
                return MarkdownTextObject.from_str(text)
        elif isinstance(text, dict):
            d = copy.copy(text)
            t = d.pop("type")
            if t == PlainTextObject.type:  # skipcq: PYL-R1705
                return PlainTextObject(**d)
            else:
                return MarkdownTextObject(**d)
        elif isinstance(text, TextObject):
            return text
        else:
            cls.logger.warning(
                f"Unknown type ({type(text)}) detected when parsing a TextObject"
            )
            return None

    def __init__(
        self,
        text: str,
        type: Optional[str] = None,  # skipcq: PYL-W0622
        subtype: Optional[str] = None,
        emoji: Optional[bool] = None,
        **kwargs,
    ):
        """Super class for new text "objects" used in Block kit"""
        if subtype:
            self._subtype_warning()

        self.text = text
        self.type = type if type else subtype
        self.emoji = emoji


class PlainTextObject(TextObject):
    type = "plain_text"

    @property
    def attributes(self) -> Set[str]:
        return super().attributes.union({"emoji"})

    def __init__(self, *, text: str, emoji: Optional[bool] = None):
        """A plain text object, meaning markdown characters will not be parsed as
        formatting information.
        https://api.slack.com/reference/block-kit/composition-objects#text
        """
        super().__init__(text=text, type=self.type)
        self.emoji = emoji

    @staticmethod
    def from_str(text: str) -> "PlainTextObject":
        return PlainTextObject(text=text, emoji=True)

    @staticmethod
    def direct_from_string(text: str) -> dict:
        """Transforms a string into the required object shape to act as a PlainTextObject"""
        return PlainTextObject.from_str(text).to_dict()


class MarkdownTextObject(TextObject):
    type = "mrkdwn"

    @property
    def attributes(self) -> Set[str]:
        return super().attributes.union({"verbatim"})

    def __init__(self, *, text: str, verbatim: Optional[bool] = None):
        """A Markdown text object, meaning markdown characters will be parsed as
        formatting information.
        https://api.slack.com/reference/block-kit/composition-objects#text
        """
        super().__init__(text=text, type=self.type)
        self.verbatim = verbatim

    @staticmethod
    def from_str(text: str) -> "MarkdownTextObject":
        """Transforms a string into the required object shape to act as a MarkdownTextObject"""
        return MarkdownTextObject(text=text)

    @staticmethod
    def direct_from_string(text: str) -> dict:
        """Transforms a string into the required object shape to act as a MarkdownTextObject"""
        return MarkdownTextObject.from_str(text).to_dict()

    @staticmethod
    def from_link(link: Link, title: str = "") -> "MarkdownTextObject":
        """Transform a Link object directly into the required object shape to act as a MarkdownTextObject"""
        if title:
            title = f": {title}"
        return MarkdownTextObject(text=f"{link}{title}")

    @staticmethod
    def direct_from_link(link: Link, title: str = "") -> dict:
        """Transform a Link object directly into the required object shape to act as a MarkdownTextObject"""
        return MarkdownTextObject.from_link(link, title).to_dict()


class ConfirmObject(JsonObject):
    attributes = {}  # no attributes because to_dict has unique implementations

    title_max_length = 100
    text_max_length = 300
    confirm_max_length = 30
    deny_max_length = 30

    @classmethod
    def parse(cls, confirm: Union["ConfirmObject", dict]):  # skipcq: PYL-R1710
        if confirm:
            if isinstance(confirm, ConfirmObject):  # skipcq: PYL-R1705
                return confirm
            elif isinstance(confirm, dict):
                return ConfirmObject(**confirm)
            else:
                return None

    def __init__(
        self,
        *,
        title: Union[str, dict, PlainTextObject],
        text: Union[str, dict, TextObject],
        confirm: Union[str, dict, PlainTextObject] = "Yes",
        deny: Union[str, dict, PlainTextObject] = "No",
        style: str = None,
    ):
        """
        An object that defines a dialog that provides a confirmation step to any
        interactive element. This dialog will ask the user to confirm their action by
        offering a confirm and deny button.
        https://api.slack.com/reference/block-kit/composition-objects#confirm
        """
        self._title = TextObject.parse(title, default_type=PlainTextObject.type)
        self._text = TextObject.parse(text, default_type=MarkdownTextObject.type)
        self._confirm = TextObject.parse(confirm, default_type=PlainTextObject.type)
        self._deny = TextObject.parse(deny, default_type=PlainTextObject.type)
        self._style = style

        # for backward-compatibility with version 2.0-2.5, the following fields return str values
        self.title = self._title.text if self._title else None
        self.text = self._text.text if self._text else None
        self.confirm = self._confirm.text if self._confirm else None
        self.deny = self._deny.text if self._deny else None
        self.style = self._style

    @JsonValidator(f"title attribute cannot exceed {title_max_length} characters")
    def title_length(self):
        return self._title is None or len(self._title.text) <= self.title_max_length

    @JsonValidator(f"text attribute cannot exceed {text_max_length} characters")
    def text_length(self):
        return self._text is None or len(self._text.text) <= self.text_max_length

    @JsonValidator(f"confirm attribute cannot exceed {confirm_max_length} characters")
    def confirm_length(self):
        return (
            self._confirm is None or len(self._confirm.text) <= self.confirm_max_length
        )

    @JsonValidator(f"deny attribute cannot exceed {deny_max_length} characters")
    def deny_length(self):
        return self._deny is None or len(self._deny.text) <= self.deny_max_length

    @JsonValidator('style for confirm must be either "primary" or "danger"')
    def _validate_confirm_style(self):
        return self._style is None or self._style in ["primary", "danger"]

    def to_dict(self, option_type: str = "block") -> dict:  # skipcq: PYL-W0221
        if option_type == "action":  # skipcq: PYL-R1705
            # deliberately skipping JSON validators here - can't find documentation
            # on actual limits here
            json = {
                "ok_text": self._confirm.text
                if self._confirm and self._confirm.text != "Yes"
                else "Okay",
                "dismiss_text": self._deny.text
                if self._deny and self._deny.text != "No"
                else "Cancel",
            }
            if self._title:
                json["title"] = self._title.text
            if self._text:
                json["text"] = self._text.text
            return json

        else:
            self.validate_json()
            json = {}
            if self._title:
                json["title"] = self._title.to_dict()
            if self._text:
                json["text"] = self._text.to_dict()
            if self._confirm:
                json["confirm"] = self._confirm.to_dict()
            if self._deny:
                json["deny"] = self._deny.to_dict()
            if self._style:
                json["style"] = self._style
            return json


class Option(JsonObject):
    """Option object used in dialogs, legacy message actions, and blocks
    JSON must be retrieved with an explicit option_type - the Slack API has
    different required formats in different situations
    """

    attributes = {}  # no attributes because to_dict has unique implementations
    logger = logging.getLogger(__name__)

    label_max_length = 75
    value_max_length = 75

    def __init__(
        self,
        *,
        value: str,
        label: Optional[str] = None,
        text: Optional[Union[str, dict, TextObject]] = None,  # Block Kit
        description: Optional[str] = None,
        url: Optional[str] = None,
        **others: dict,
    ):
        """
        An object that represents a single selectable item in a block element (
        SelectElement, OverflowMenuElement) or dialog element
        (StaticDialogSelectElement)

        Blocks:
        https://api.slack.com/reference/messaging/composition-objects#option

        Dialogs:
        https://api.slack.com/dialogs#select_elements

        Legacy interactive attachments:
        https://api.slack.com/docs/interactive-message-field-guide#option_fields

        Args:
            label: A short, user-facing string to label this option to users.
                Cannot exceed 75 characters.
            value: A short string that identifies this particular option to your
                application. It will be part of the payload when this option is selected
                . Cannot exceed 75 characters.
            description: A user-facing string that provides more details about
                this option. Only supported in legacy message actions, not in blocks or
                dialogs.
        """
        if text:
            self._text: Optional[TextObject] = TextObject.parse(text)
            self._label: Optional[str] = None
        else:
            self._text: Optional[TextObject] = None
            self._label: Optional[str] = label

        # for backward-compatibility with version 2.0-2.5, the following fields return str values
        self.text: Optional[str] = self._text.text if self._text else None
        self.label: Optional[str] = self._label

        self.value: str = value
        self.description: Optional[str] = description
        # A URL to load in the user's browser when the option is clicked.
        # The url attribute is only available in overflow menus.
        # Maximum length for this field is 3000 characters.
        # If you're using url, you'll still receive an interaction payload
        # and will need to send an acknowledgement response.
        self.url: Optional[str] = url
        show_unknown_key_warning(self, others)

    @JsonValidator(f"label attribute cannot exceed {label_max_length} characters")
    def _validate_label_length(self):
        return self._label is None or len(self._label) <= self.label_max_length

    @JsonValidator(f"text attribute cannot exceed {label_max_length} characters")
    def _validate_text_length(self):
        return (
            self._text is None
            or self._text.text is None
            or len(self._text.text) <= self.label_max_length
        )

    @JsonValidator(f"value attribute cannot exceed {value_max_length} characters")
    def _validate_value_length(self):
        return len(self.value) <= self.value_max_length

    @classmethod
    def parse_all(
        cls, options: Optional[List[Union[dict, "Option"]]]
    ) -> List["Option"]:
        if options is None:
            return None
        option_objects: List[Option] = []
        for o in options:
            if isinstance(o, dict):
                d = copy.copy(o)
                option_objects.append(Option(**d))
            elif isinstance(o, Option):
                option_objects.append(o)
            else:
                cls.logger.warning(f"Unknown option object detected and skipped ({o})")
        return option_objects

    def to_dict(self, option_type: str = "block") -> dict:  # skipcq: PYL-W0221
        """
        Different parent classes must call this with a valid value from OptionTypes -
        either "dialog", "action", or "block", so that JSON is returned in the
        correct shape.
        """
        self.validate_json()
        if option_type == "dialog":  # skipcq: PYL-R1705
            return {"label": self.label, "value": self.value}
        elif option_type == "action":
            json = {"text": self.label, "value": self.value}
            if self.description is not None:
                json["description"] = self.description
            return json
        else:  # if option_type == "block"; this should be the most common case
            text: TextObject = self._text or PlainTextObject.from_str(self.label)
            json: dict = {
                "text": text.to_dict(),
                "value": self.value,
            }
            if self.description:
                json["description"] = self.description
            if self.url:
                json["url"] = self.url
            return json

    @staticmethod
    def from_single_value(value_and_label: str):
        """Creates a simple Option instance with the same value and label"""
        return Option(value=value_and_label, label=value_and_label)


class OptionGroup(JsonObject):
    """
    JSON must be retrieved with an explicit option_type - the Slack API has
    different required formats in different situations
    """

    attributes = {}  # no attributes because to_dict has unique implementations
    label_max_length = 75
    options_max_length = 100
    logger = logging.getLogger(__name__)

    def __init__(
        self,
        *,
        label: Optional[Union[str, dict, TextObject]] = None,
        options: List[Union[dict, Option]],
        **others: dict,
    ):
        """
        Create a group of Option objects - pass in a label (that will be part of the
        UI) and a list of Option objects.

        Blocks:
        https://api.slack.com/reference/messaging/composition-objects#option-group

        Dialogs:
        https://api.slack.com/dialogs#select_elements

        Legacy interactive attachments:
        https://api.slack.com/docs/interactive-message-field-guide#option_groups_to_place_within_message_menu_actions

        Args:
            label: Text to display at the top of this group of options.
            options: A list of no more than 100 Option objects.
        """  # noqa prevent flake8 blowing up on the long URL
        # default_type=PlainTextObject.type is for backward-compatibility
        self._label: Optional[TextObject] = TextObject.parse(
            label, default_type=PlainTextObject.type
        )
        self.label: Optional[str] = self._label.text if self._label else None
        self.options = Option.parse_all(options)  # compatible with version 2.5
        show_unknown_key_warning(self, others)

    @JsonValidator(f"label attribute cannot exceed {label_max_length} characters")
    def _validate_label_length(self):
        return self.label is None or len(self.label) <= self.label_max_length

    @JsonValidator(f"options attribute cannot exceed {options_max_length} elements")
    def _validate_options_length(self):
        return self.options is None or len(self.options) <= self.options_max_length

    @classmethod
    def parse_all(
        cls, option_groups: Optional[List[Union[dict, "OptionGroup"]]]
    ) -> Optional[List["OptionGroup"]]:
        if option_groups is None:
            return None
        option_group_objects = []
        for o in option_groups:
            if isinstance(o, dict):
                d = copy.copy(o)
                option_group_objects.append(OptionGroup(**d))
            elif isinstance(o, OptionGroup):
                option_group_objects.append(o)
            else:
                cls.logger.warning(
                    f"Unknown option group object detected and skipped ({o})"
                )
        return option_group_objects

    def to_dict(self, option_type: str = "block") -> dict:  # skipcq: PYL-W0221
        self.validate_json()
        dict_options = [o.to_dict(option_type) for o in self.options]
        if option_type == "dialog":  # skipcq: PYL-R1705
            return {
                "label": self.label,
                "options": dict_options,
            }
        elif option_type == "action":
            return {
                "text": self.label,
                "options": dict_options,
            }
        else:  # if option_type == "block"; this should be the most common case
            dict_label: dict = self._label.to_dict()
            return {
                "label": dict_label,
                "options": dict_options,
            }
