# ##### BEGIN GPL LICENSE BLOCK #####
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License
#  as published by the Free Software Foundation; either version 2
#  of the License, or (at your option) any later version.
#
#  This program 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software Foundation,
#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

# <pep8 compliant>
import bpy
from bpy.types import (
    Menu,
)

__all__ = (
    "ToolDef",
    "ToolSelectPanelHelper",
    "activate_by_id",
    "activate_by_id_or_cycle",
    "description_from_id",
    "keymap_from_id",
)

# Support reloading icons.
if "_icon_cache" in locals():
    release = bpy.app.icons.release
    for icon_value in _icon_cache.values():
        if icon_value != 0:
            release(icon_value)
    del release


# (filename -> icon_value) map
_icon_cache = {}


def _keymap_fn_from_seq(keymap_data):

    def keymap_fn(km):
        if keymap_fn.keymap_data:
            from bl_keymap_utils.io import keymap_init_from_data
            keymap_init_from_data(km, keymap_fn.keymap_data)
    keymap_fn.keymap_data = keymap_data
    return keymap_fn


def _item_is_fn(item):
    return (not (type(item) is ToolDef) and callable(item))


from collections import namedtuple
ToolDef = namedtuple(
    "ToolDef",
    (
        # Unique tool name (withing space & mode context).
        "idname",
        # The name to display in the interface.
        "label",
        # Description (for tooltip), when not set, use the description of 'operator',
        # may be a string or a 'function(context, item, keymap) -> string'.
        "description",
        # The name of the icon to use (found in ``release/datafiles/icons``) or None for no icon.
        "icon",
        # An optional cursor to use when this tool is active.
        "cursor",
        # An optional gizmo group to activate when the tool is set or None for no gizmo.
        "widget",
        # Optional keymap for tool, either:
        # - A function that populates a keymaps passed in as an argument.
        # - A tuple filled with triple's of:
        #   ``(operator_id, operator_properties, keymap_item_args)``.
        #
        # Warning: currently 'from_dict' this is a list of one item,
        # so internally we can swap the keymap function for the keymap it's self.
        # This isn't very nice and may change, tool definitions shouldn't care about this.
        "keymap",
        # Optional data-block assosiated with this tool.
        # (Typically brush name, usage depends on mode, we could use for non-brush ID's in other modes).
        "data_block",
        # Optional primary operator (for introspection only).
        "operator",
        # Optional draw settings (operator options, tool_settings).
        "draw_settings",
        # Optional draw cursor.
        "draw_cursor",
    )
)
del namedtuple


def from_dict(kw_args):
    """
    Use so each tool can avoid defining all members of the named tuple.
    Also convert the keymap from a tuple into a function
    (since keymap is a callback).
    """
    kw = {
        "description": None,
        "icon": None,
        "cursor": None,
        "widget": None,
        "keymap": None,
        "data_block": None,
        "operator": None,
        "draw_settings": None,
        "draw_cursor": None,
    }
    kw.update(kw_args)

    keymap = kw["keymap"]
    if keymap is None:
        pass
    elif type(keymap) is tuple:
        keymap = [_keymap_fn_from_seq(keymap)]
    else:
        keymap = [keymap]
    kw["keymap"] = keymap
    return ToolDef(**kw)


def from_fn(fn):
    """
    Use as decorator so we can define functions.
    """
    return ToolDef.from_dict(fn())


def with_args(**kw):
    def from_fn(fn):
        return ToolDef.from_dict(fn(**kw))
    return from_fn


from_fn.with_args = with_args
ToolDef.from_dict = from_dict
ToolDef.from_fn = from_fn
del from_dict, from_fn, with_args


class ToolSelectPanelHelper:
    """
    Generic Class, can be used for any toolbar.

    - keymap_prefix:
      The text prefix for each key-map for this spaces tools.
    - tools_all():
      Returns (context_mode, tools) tuple pair for all tools defined.
    - tools_from_context(context, mode=None):
      Returns tools available in this context.

    Each tool is a 'ToolDef' or None for a separator in the toolbar, use ``None``.
    """

    @staticmethod
    def _tool_class_from_space_type(space_type):
        return next(
            (cls for cls in ToolSelectPanelHelper.__subclasses__()
             if cls.bl_space_type == space_type),
            None
        )

    @staticmethod
    def _icon_value_from_icon_handle(icon_name):
        import os
        if icon_name is not None:
            assert(type(icon_name) is str)
            icon_value = _icon_cache.get(icon_name)
            if icon_value is None:
                dirname = bpy.utils.resource_path('LOCAL')
                if not os.path.exists(dirname):
                    # TODO(campbell): use a better way of finding datafiles.
                    dirname = bpy.utils.resource_path('SYSTEM')
                filename = os.path.join(dirname, "datafiles", "icons", icon_name + ".dat")
                try:
                    icon_value = bpy.app.icons.new_triangles_from_file(filename)
                except Exception as ex:
                    if not os.path.exists(filename):
                        print("Missing icons:", filename, ex)
                    else:
                        print("Corrupt icon:", filename, ex)
                    # Use none as a fallback (avoids layout issues).
                    if icon_name != "none":
                        icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle("none")
                    else:
                        icon_value = 0
                _icon_cache[icon_name] = icon_value
            return icon_value
        else:
            return 0

    @staticmethod
    def _tools_flatten(tools):
        """
        Flattens, skips None and calls generators.
        """
        for item in tools:
            if item is None:
                yield None
            elif type(item) is tuple:
                for sub_item in item:
                    if sub_item is None:
                        yield None
                    elif _item_is_fn(sub_item):
                        yield from sub_item(context)
                    else:
                        yield sub_item
            else:
                if _item_is_fn(item):
                    yield from item(context)
                else:
                    yield item

    @staticmethod
    def _tools_flatten_with_tool_index(tools):
        for item in tools:
            if item is None:
                yield None, -1
            elif type(item) is tuple:
                i = 0
                for sub_item in item:
                    if sub_item is None:
                        yield None, -1
                    elif _item_is_fn(sub_item):
                        for item_dyn in sub_item(context):
                            yield item_dyn, i
                            i += 1
                    else:
                        yield sub_item, i
                        i += 1
            else:
                if _item_is_fn(item):
                    for item_dyn in item(context):
                        yield item_dyn, -1
                else:
                    yield item, -1

    @staticmethod
    def _tool_get_active(context, space_type, mode, with_icon=False):
        """
        Return the active Python tool definition and icon name.
        """
        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        if cls is not None:
            tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)
            tool_active_id = getattr(tool_active, "idname", None)
            for item in ToolSelectPanelHelper._tools_flatten(cls.tools_from_context(context, mode)):
                if item is not None:
                    if item.idname == tool_active_id:
                        if with_icon:
                            icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
                        else:
                            icon_value = 0
                        return (item, tool_active, icon_value)
        return None, None, 0

    @staticmethod
    def _tool_get_by_id(context, space_type, idname):
        """
        Return the active Python tool definition and index (if in sub-group, else -1).
        """
        cls = ToolSelectPanelHelper._tool_class_from_space_type(space_type)
        if cls is not None:
            for item, index in ToolSelectPanelHelper._tools_flatten_with_tool_index(cls.tools_from_context(context)):
                if item is not None:
                    if item.idname == idname:
                        return (cls, item, index)
        return None, None, -1

    @staticmethod
    def _tool_active_from_context(context, space_type, mode=None, create=False):
        if space_type == 'VIEW_3D':
            if mode is None:
                mode = context.mode
            tool = context.workspace.tools.from_space_view3d_mode(mode, create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        elif space_type == 'IMAGE_EDITOR':
            space_data = context.space_data
            if mode is None:
                if space_data is None:
                    mode = 'VIEW'
                else:
                    mode = space_data.mode
            tool = context.workspace.tools.from_space_image_mode(mode, create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        elif space_type == 'NODE_EDITOR':
            space_data = context.space_data
            tool = context.workspace.tools.from_space_node(create=create)
            if tool is not None:
                tool.refresh_from_context()
                return tool
        return None

    @staticmethod
    def _tool_identifier_from_button(context):
        return context.button_operator.name

    @classmethod
    def _km_action_simple(cls, kc, context_descr, label, keymap_fn):
        km_idname = f"{cls.keymap_prefix:s} {context_descr:s}, {label:s}"
        km = kc.keymaps.get(km_idname)
        if km is None:
            km = kc.keymaps.new(km_idname, space_type=cls.bl_space_type, region_type='WINDOW', tool=True)
            keymap_fn[0](km)
        keymap_fn[0] = km.name

    # Special internal function, gives use items that contain keymaps.
    @staticmethod
    def _tools_flatten_with_keymap(tools):
        for item_parent in tools:
            if item_parent is None:
                continue
            for item in item_parent if (type(item_parent) is tuple) else (item_parent,):
                # skip None or generator function
                if item is None or _item_is_fn(item):
                    continue
                if item.keymap is not None:
                    yield item

    @classmethod
    def register(cls):
        wm = bpy.context.window_manager
        # Write into defaults, users may modify in preferences.
        kc = wm.keyconfigs.default

        # Track which tool-group was last used for non-active groups.
        # Blender stores the active tool-group index.
        #
        # {tool_name_first: index_in_group, ...}
        cls._tool_group_active = {}

        # ignore in background mode
        if kc is None:
            return

        for context_mode, tools in cls.tools_all():
            if context_mode is None:
                context_descr = "All"
            else:
                context_descr = context_mode.replace("_", " ").title()

            for item in cls._tools_flatten_with_keymap(tools):
                keymap_data = item.keymap
                if callable(keymap_data[0]):
                    cls._km_action_simple(kc, context_descr, item.label, keymap_data)

    @classmethod
    def keymap_ui_hierarchy(cls, context_mode):
        # See: bpy_extras.keyconfig_utils
        for context_mode_test, tools in cls.tools_all():
            if context_mode_test == context_mode:
                for item in cls._tools_flatten_with_keymap(tools):
                    km_name = item.keymap[0]
                    # print((km.name, cls.bl_space_type, 'WINDOW', []))
                    yield (km_name, cls.bl_space_type, 'WINDOW', [])

    # -------------------------------------------------------------------------
    # Layout Generators
    #
    # Meaning of recieved values:
    # - Bool: True for a separator, otherwise False for regular tools.
    # - None: Signal to finish (complete any final operations, e.g. add padding).

    @staticmethod
    def _layout_generator_single_column(layout, scale_y):
        col = layout.column(align=True)
        col.scale_y = scale_y
        is_sep = False
        while True:
            if is_sep is True:
                col = layout.column(align=True)
                col.scale_y = scale_y
            elif is_sep is None:
                yield None
                return
            is_sep = yield col

    @staticmethod
    def _layout_generator_multi_columns(layout, column_count, scale_y):
        scale_x = scale_y * 1.1
        column_last = column_count - 1

        col = layout.column(align=True)

        row = col.row(align=True)

        row.scale_x = scale_x
        row.scale_y = scale_y

        is_sep = False
        column_index = 0
        while True:
            if is_sep is True:
                if column_index != column_last:
                    row.label(text="")
                col = layout.column(align=True)
                row = col.row(align=True)
                row.scale_x = scale_x
                row.scale_y = scale_y
                column_index = 0

            is_sep = yield row
            if is_sep is None:
                if column_index == column_last:
                    row.label(text="")
                    yield None
                    return

            if column_index == column_count:
                column_index = 0
                row = col.row(align=True)
                row.scale_x = scale_x
                row.scale_y = scale_y
            column_index += 1

    @staticmethod
    def _layout_generator_detect_from_region(layout, region, scale_y):
        """
        Choose an appropriate layout for the toolbar.
        """
        # Currently this just checks the width,
        # we could have different layouts as preferences too.
        system = bpy.context.preferences.system
        view2d = region.view2d
        view2d_scale = (
            view2d.region_to_view(1.0, 0.0)[0] -
            view2d.region_to_view(0.0, 0.0)[0]
        )
        width_scale = region.width * view2d_scale / system.ui_scale

        if width_scale > 120.0:
            show_text = True
            column_count = 1
        else:
            show_text = False
            # 2 column layout, disabled
            if width_scale > 80.0:
                column_count = 2
            else:
                column_count = 1

        if column_count == 1:
            ui_gen = ToolSelectPanelHelper._layout_generator_single_column(
                layout, scale_y=scale_y,
            )
        else:
            ui_gen = ToolSelectPanelHelper._layout_generator_multi_columns(
                layout, column_count=column_count, scale_y=scale_y,
            )

        return ui_gen, show_text

    @classmethod
    def draw_cls(cls, layout, context, detect_layout=True, scale_y=1.75):
        # Use a classmethod so it can be called outside of a panel context.

        # XXX, this UI isn't very nice.
        # We might need to create new button types for this.
        # Since we probably want:
        # - tool-tips that include multiple key shortcuts.
        # - ability to click and hold to expose sub-tools.

        space_type = context.space_data.type
        tool_active_id = getattr(
            ToolSelectPanelHelper._tool_active_from_context(context, space_type),
            "idname", None,
        )

        if detect_layout:
            ui_gen, show_text = cls._layout_generator_detect_from_region(layout, context.region, scale_y)
        else:
            ui_gen = ToolSelectPanelHelper._layout_generator_single_column(layout, scale_y)
            show_text = True

        # Start iteration
        ui_gen.send(None)

        for item in cls.tools_from_context(context):
            if item is None:
                ui_gen.send(True)
                continue

            if type(item) is tuple:
                is_active = False
                i = 0
                for i, sub_item in enumerate(item):
                    if sub_item is None:
                        continue
                    is_active = (sub_item.idname == tool_active_id)
                    if is_active:
                        index = i
                        break
                del i, sub_item

                if is_active:
                    # not ideal, write this every time :S
                    cls._tool_group_active[item[0].idname] = index
                else:
                    index = cls._tool_group_active.get(item[0].idname, 0)

                item = item[index]
                use_menu = True
            else:
                index = -1
                use_menu = False

            is_active = (item.idname == tool_active_id)
            icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)

            sub = ui_gen.send(False)

            if use_menu:
                sub.operator_menu_hold(
                    "wm.tool_set_by_id",
                    text=item.label if show_text else "",
                    depress=is_active,
                    menu="WM_MT_toolsystem_submenu",
                    icon_value=icon_value,
                ).name = item.idname
            else:
                sub.operator(
                    "wm.tool_set_by_id",
                    text=item.label if show_text else "",
                    depress=is_active,
                    icon_value=icon_value,
                ).name = item.idname
        # Signal to finish any remaining layout edits.
        ui_gen.send(None)

    def draw(self, context):
        self.draw_cls(self.layout, context)

    @staticmethod
    def tool_active_from_context(context):
        # BAD DESIGN WARNING: last used tool
        workspace = context.workspace
        space_type = workspace.tools_space_type
        mode = workspace.tools_mode
        return ToolSelectPanelHelper._tool_active_from_context(context, space_type, mode)

    @staticmethod
    def draw_active_tool_header(
            context, layout,
            *,
            show_tool_name=False,
    ):
        # BAD DESIGN WARNING: last used tool
        workspace = context.workspace
        space_type = workspace.tools_space_type
        mode = workspace.tools_mode
        item, tool, icon_value = ToolSelectPanelHelper._tool_get_active(context, space_type, mode, with_icon=True)
        if item is None:
            return None
        # Note: we could show 'item.text' here but it makes the layout jitter when switching tools.
        # Add some spacing since the icon is currently assuming regular small icon size.
        layout.label(text="    " + item.label if show_tool_name else " ", icon_value=icon_value)
        if show_tool_name:
            layout.separator()
        draw_settings = item.draw_settings
        if draw_settings is not None:
            draw_settings(context, layout, tool)
        return tool


# The purpose of this menu is to be a generic popup to select between tools
# in cases when a single tool allows to select alternative tools.
class WM_MT_toolsystem_submenu(Menu):
    bl_label = ""

    @staticmethod
    def _tool_group_from_button(context):
        # Lookup the tool definitions based on the space-type.
        cls = ToolSelectPanelHelper._tool_class_from_space_type(context.space_data.type)
        if cls is not None:
            button_identifier = ToolSelectPanelHelper._tool_identifier_from_button(context)
            for item_group in cls.tools_from_context(context):
                if type(item_group) is tuple:
                    for sub_item in item_group:
                        if (sub_item is not None) and (sub_item.idname == button_identifier):
                            return cls, item_group
        return None, None

    def draw(self, context):
        layout = self.layout
        layout.scale_y = 2.0

        _cls, item_group = self._tool_group_from_button(context)
        if item_group is None:
            # Should never happen, just in case
            layout.label(text="Unable to find toolbar group")
            return

        for item in item_group:
            if item is None:
                layout.separator()
                continue
            icon_value = ToolSelectPanelHelper._icon_value_from_icon_handle(item.icon)
            layout.operator(
                "wm.tool_set_by_id",
                text=item.label,
                icon_value=icon_value,
            ).name = item.idname


def _activate_by_item(context, space_type, item, index):
    tool = ToolSelectPanelHelper._tool_active_from_context(context, space_type, create=True)
    tool.setup(
        idname=item.idname,
        keymap=item.keymap[0] if item.keymap is not None else "",
        cursor=item.cursor or 'DEFAULT',
        gizmo_group=item.widget or "",
        data_block=item.data_block or "",
        operator=item.operator or "",
        index=index,
    )

    WindowManager = bpy.types.WindowManager

    handle_map = _activate_by_item._cursor_draw_handle
    handle = handle_map.pop(space_type, None)
    if (handle is not None):
        WindowManager.draw_cursor_remove(handle)
    if item.draw_cursor is not None:
        def handle_fn(context, item, tool, xy):
            item.draw_cursor(context, tool, xy)
        handle = WindowManager.draw_cursor_add(handle_fn, (context, item, tool), space_type)
        handle_map[space_type] = handle


_activate_by_item._cursor_draw_handle = {}


def activate_by_id(context, space_type, text):
    _cls, item, index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, text)
    if item is None:
        return False
    _activate_by_item(context, space_type, item, index)
    return True


def activate_by_id_or_cycle(context, space_type, idname, offset=1):

    # Only cycle when the active tool is activated again.
    cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
    if item is None:
        return False

    tool_active = ToolSelectPanelHelper._tool_active_from_context(context, space_type)
    id_active = getattr(tool_active, "idname", None)

    id_current = ""
    for item_group in cls.tools_from_context(context):
        if type(item_group) is tuple:
            index_current = cls._tool_group_active.get(item_group[0].idname, 0)
            for sub_item in item_group:
                if sub_item.idname == idname:
                    id_current = item_group[index_current].idname
                    break
            if id_current:
                break

    if id_current == "":
        return activate_by_id(context, space_type, idname)
    if id_active != id_current:
        return activate_by_id(context, space_type, id_current)

    index_found = (tool_active.index + offset) % len(item_group)

    cls._tool_group_active[item_group[0].idname] = index_found

    item_found = item_group[index_found]
    _activate_by_item(context, space_type, item_found, index_found)
    return True


def description_from_id(context, space_type, idname, *, use_operator=True):
    # Used directly for tooltips.
    _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
    if item is None:
        return False

    # Custom description.
    description = item.description
    if description is not None:
        if callable(description):
            km = _keymap_from_item(context, item)
            return description(context, item, km)
        return description

    # Extract from the operator.
    if use_operator:
        operator = item.operator
        if operator is None:
            if item.keymap is not None:
                km = _keymap_from_item(context, item)
                if km is not None:
                    for kmi in km.keymap_items:
                        if kmi.active:
                            operator = kmi.idname
                            break

        if operator is not None:
            import _bpy
            return _bpy.ops.get_rna_type(operator).description
    return ""


def item_from_id(context, space_type, idname):
    # Used directly for tooltips.
    _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
    return item


def keymap_from_id(context, space_type, idname):
    # Used directly for tooltips.
    _cls, item, _index = ToolSelectPanelHelper._tool_get_by_id(context, space_type, idname)
    if item is None:
        return False

    keymap = item.keymap
    # List container of one.
    if keymap:
        return keymap[0]
    return ""


def _keymap_from_item(context, item):
    if item.keymap is not None:
        wm = context.window_manager
        keyconf = wm.keyconfigs.active
        return keyconf.keymaps.get(item.keymap[0])
    return None


classes = (
    WM_MT_toolsystem_submenu,
)

if __name__ == "__main__":  # only for live edit.
    from bpy.utils import register_class
    for cls in classes:
        register_class(cls)
