# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)

"""
Spyder API menu widgets.
"""

# Standard library imports
import sys
from typing import Optional, Union, TypeVar

# Third party imports
from qtpy.QtWidgets import QAction, QMenu

# Local imports
from spyder.api.exceptions import SpyderAPIError
from spyder.utils.qthelpers import add_actions, SpyderAction


# --- Constants
# ----------------------------------------------------------------------------
MENU_SEPARATOR = None


# Generic type annotations
T = TypeVar('T', bound='SpyderMenu')


class OptionsMenuSections:
    Top = 'top_section'
    Bottom = 'bottom_section'


class PluginMainWidgetMenus:
    Context = 'context_menu'
    Options = 'options_menu'


# --- Widgets
# ----------------------------------------------------------------------------
class SpyderMenu(QMenu):
    """
    A QMenu subclass to implement additional functionality for Spyder.
    """
    MENUS = []

    def __init__(self, parent=None, title=None, dynamic=True,
                 menu_id=None):
        self._parent = parent
        self._title = title
        self._sections = []
        self._actions = []
        self._actions_map = {}
        self.unintroduced_actions = {}
        self.unintroduced_sections = []
        self._dirty = False
        self.menu_id = menu_id

        if title is None:
            super().__init__(parent)
        else:
            super().__init__(title, parent)

        self.MENUS.append((parent, title, self))
        if sys.platform == 'darwin' and dynamic:
            # Needed to enable the dynamic population of actions in menus
            # in the aboutToShow signal
            # See spyder-ide/spyder#14612
            self.addAction(QAction(self))
        self.aboutToShow.connect(self._render)

    def clear_actions(self):
        """
        Remove actions from the menu (including custom references)

        Returns
        -------
        None.
        """
        self.clear()
        self._sections = []
        self._actions = []
        self._actions_map = {}
        self.unintroduced_actions = {}
        self.unintroduced_sections = []

    def add_action(self: T,
                   action: Union[SpyderAction, T],
                   section: Optional[str] = None,
                   before: Optional[str] = None,
                   before_section: Optional[str] = None,
                   check_before: bool = True,
                   omit_id: bool = False):
        """
        Add action to a given menu section.

        Parameters
        ----------
        action: SpyderAction
            The action to add.
        section: str or None
            The section id in which to insert the `action`.
        before: str
            Make the action appear before the given action identifier.
        before_section: str or None
            Make the item section (if provided) appear before another
            given section.
        check_before: bool
            Check if the `before` action is part of the menu. This is
            necessary to avoid an infinite recursion when adding
            unintroduced actions with this method again.
        omit_id: bool
            If True, then the menu will check if the item to add declares an
            id, False otherwise. This flag exists only for items added on
            Spyder 4 plugins. Default: False
        """
        item_id = None
        if isinstance(action, SpyderAction) or hasattr(action, 'action_id'):
            item_id = action.action_id
        elif isinstance(action, SpyderMenu) or hasattr(action, 'menu_id'):
            item_id = action.menu_id

        if not omit_id and item_id is None and action is not None:
            raise AttributeError(f'Item {action} must declare an id.')

        if before is None:
            self._actions.append((section, action))
        else:
            new_actions = []
            added = False
            before_item = self._actions_map.get(before, None)

            for sec, act in self._actions:
                if before_item is not None and act == before_item:
                    added = True
                    new_actions.append((section, action))

                new_actions.append((sec, act))

            # Actions can't be added to the menu if the `before` action is
            # not part of it yet. That's why we need to save them in the
            # `unintroduced_actions` dict, so we can add them again when
            # the menu is rendered.
            if not added and check_before:
                before_actions = self.unintroduced_actions.get(before, [])
                before_actions.append((section, action))
                self.unintroduced_actions[before] = before_actions

            self._actions = new_actions

        if before_section is not None:
            if before_section in self._sections:
                self._update_sections(section, before_section)
            else:
                # If `before_section` has not been introduced yet to the menu,
                # we save `section` to introduce it when the menu is rendered.
                if (section, before_section) not in self.unintroduced_sections:
                    self.unintroduced_sections.append(
                        (section, before_section)
                    )
        elif section not in self._sections:
            self._sections.append(section)

        # Track state of menu to avoid re-rendering if menu has not changed
        self._dirty = True
        self._actions_map[item_id] = action

    def remove_action(self, item_id: str):
        if item_id in self._actions_map:
            action = self._actions_map.pop(item_id)
            position = None

            for i, (_, act) in enumerate(self._actions):
                if act == action:
                    position = i
                    break

            if position is not None:
                self._actions.pop(position)
                self._dirty = True

    def get_title(self):
        """
        Return the title for menu.
        """
        return self._title

    def get_actions(self):
        """
        Return a parsed list of menu actions.

        Includes MENU_SEPARATOR taking into account the sections defined.
        """
        actions = []
        for section in self._sections:
            for (sec, action) in self._actions:
                if sec == section:
                    actions.append(action)

            actions.append(MENU_SEPARATOR)
        return actions

    def get_sections(self):
        """
        Return a tuple of menu sections.
        """
        return tuple(self._sections)

    def _render(self):
        """
        Create the menu prior to showing it. This takes into account sections
        and location of menus.
        """
        if self._dirty:
            self.clear()

            # Iterate over unintroduced sections until all of them have been
            # introduced.
            try:
                iter_sections = iter(self.unintroduced_sections)
                while len(self.unintroduced_sections) > 0:
                    section, before_section = next(iter_sections)
                    self._update_sections(section, before_section)

                    # If section was introduced, remove it from the list and
                    # update iterator.
                    if section in self._sections:
                        self.unintroduced_sections.remove(
                            (section, before_section)
                        )
                        iter_sections = iter(self.unintroduced_sections)
            except StopIteration:
                # Internally, this should only happen in the Tools menu because
                # the External section can be empty if Kite is not available.
                # Fixes spyder-ide/spyder#16287
                # Note: We can't use the ToolsMenuSections enum here to prevent
                # a circular import.
                left_sections = [('tools_section', 'external_section')]

                if self.unintroduced_sections == left_sections:
                    self._update_sections('tools_section', 'extras_section')
                else:
                    raise SpyderAPIError(
                        f"You're trying to introduce some sections before "
                        f"others that don't have any actions. This is the "
                        f"list of (section, before_section) that's failing to "
                        f"be added:\n\n{self.unintroduced_sections}"
                    )

            # Update actions with those that were not introduced because
            # a `before` action they required was not part of the menu yet.
            for before, actions in self.unintroduced_actions.items():
                for section, action in actions:
                    self.add_action(action, section=section,
                                    before=before, check_before=False)

            actions = self.get_actions()
            add_actions(self, actions)
            self._dirty = False

    def _update_sections(self, section, before_section):
        """Update sections ordering."""
        new_sections = []
        for sec in self._sections:
            if sec == before_section:
                new_sections.append(section)
            if sec != section:
                new_sections.append(sec)
        self._sections = new_sections


class MainWidgetMenu(SpyderMenu):
    """
    This menu fixes the bottom section of the options menu.
    """

    def _render(self):
        """
        Create the menu prior to showing it. This takes into account sections
        and location of menus. It also hides consecutive separators if found.
        """
        if self._dirty:
            self.clear()
            bottom = OptionsMenuSections.Bottom
            actions = []
            for section in self._sections:
                for (sec, action) in self._actions:
                    if sec == section and sec != bottom:
                        actions.append(action)

                actions.append(MENU_SEPARATOR)

            # Add bottom actions
            for (sec, action) in self._actions:
                if sec == bottom:
                    actions.append(action)

            add_actions(self, actions)
            self._dirty = False
