#!/usr/pkg/bin/python3.13


__license__   = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'

import textwrap, os
from collections import OrderedDict

from qt.core import (Qt, QMenu, QModelIndex, QAbstractItemModel, QIcon,
        QBrush, QDialog, QItemSelectionModel, QAbstractItemView)

from calibre.gui2.preferences import ConfigWidgetBase, test_widget
from calibre.gui2.preferences.plugins_ui import Ui_Form
from calibre.customize import PluginInstallationType
from calibre.customize.ui import (initialized_plugins, is_disabled, enable_plugin,
                                 disable_plugin, plugin_customization, add_plugin,
                                 remove_plugin, NameConflict)
from calibre.gui2 import (error_dialog, info_dialog, choose_files,
        question_dialog, gprefs)
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.utils.search_query_parser import SearchQueryParser
from calibre.utils.icu import lower
from calibre.constants import iswindows
from polyglot.builtins import iteritems, itervalues


class AdaptSQP(SearchQueryParser):

    def __init__(self, *args, **kwargs):
        pass


class PluginModel(QAbstractItemModel, AdaptSQP):  # {{{

    def __init__(self, show_only_user_plugins=False):
        QAbstractItemModel.__init__(self)
        SearchQueryParser.__init__(self, ['all'])
        self.show_only_user_plugins = show_only_user_plugins
        self.icon = QIcon(I('plugins.png'))
        p = QIcon(self.icon).pixmap(64, 64, QIcon.Mode.Disabled, QIcon.State.On)
        self.disabled_icon = QIcon(p)
        self._p = p
        self.populate()

    def toggle_shown_plugins(self, show_only_user_plugins):
        self.show_only_user_plugins = show_only_user_plugins
        self.beginResetModel()
        self.populate()
        self.endResetModel()

    def populate(self):
        self._data = {}
        for plugin in initialized_plugins():
            if (getattr(plugin, 'installation_type', None) is not PluginInstallationType.EXTERNAL and self.show_only_user_plugins):
                continue
            if plugin.type not in self._data:
                self._data[plugin.type] = [plugin]
            else:
                self._data[plugin.type].append(plugin)
        self.categories = sorted(self._data.keys())

        for plugins in self._data.values():
            plugins.sort(key=lambda x: x.name.lower())

    def universal_set(self):
        ans = set()
        for c, category in enumerate(self.categories):
            ans.add((c, -1))
            for p, plugin in enumerate(self._data[category]):
                ans.add((c, p))
        return ans

    def get_matches(self, location, query, candidates=None):
        if candidates is None:
            candidates = self.universal_set()
        ans = set()
        if not query:
            return ans
        query = lower(query)
        for c, p in candidates:
            if p < 0:
                if query in lower(self.categories[c]):
                    ans.add((c, p))
                continue
            else:
                try:
                    plugin = self._data[self.categories[c]][p]
                except:
                    continue
            if query in lower(plugin.name) or query in lower(plugin.author) or \
                    query in lower(plugin.description):
                ans.add((c, p))
        return ans

    def find(self, query):
        query = query.strip()
        if not query:
            return QModelIndex()
        matches = self.parse(query)
        if not matches:
            return QModelIndex()
        matches = list(sorted(matches))
        c, p = matches[0]
        cat_idx = self.index(c, 0, QModelIndex())
        if p == -1:
            return cat_idx
        return self.index(p, 0, cat_idx)

    def find_next(self, idx, query, backwards=False):
        query = query.strip()
        if not query:
            return idx
        matches = self.parse(query)
        if not matches:
            return idx
        if idx.parent().isValid():
            loc = (idx.parent().row(), idx.row())
        else:
            loc = (idx.row(), -1)
        if loc not in matches:
            return self.find(query)
        if len(matches) == 1:
            return QModelIndex()
        matches = list(sorted(matches))
        i = matches.index(loc)
        if backwards:
            ans = i - 1 if i - 1 >= 0 else len(matches)-1
        else:
            ans = i + 1 if i + 1 < len(matches) else 0

        ans = matches[ans]

        return self.index(ans[0], 0, QModelIndex()) if ans[1] < 0 else \
                self.index(ans[1], 0, self.index(ans[0], 0, QModelIndex()))

    def index(self, row, column, parent=QModelIndex()):
        if not self.hasIndex(row, column, parent):
            return QModelIndex()

        if parent.isValid():
            return self.createIndex(row, column, 1+parent.row())
        else:
            return self.createIndex(row, column, 0)

    def parent(self, index):
        if not index.isValid() or index.internalId() == 0:
            return QModelIndex()
        return self.createIndex(index.internalId()-1, 0, 0)

    def rowCount(self, parent):
        if not parent.isValid():
            return len(self.categories)
        if parent.internalId() == 0:
            category = self.categories[parent.row()]
            return len(self._data[category])
        return 0

    def columnCount(self, parent):
        return 1

    def index_to_plugin(self, index):
        category = self.categories[index.parent().row()]
        return self._data[category][index.row()]

    def plugin_to_index(self, plugin):
        for i, category in enumerate(self.categories):
            parent = self.index(i, 0, QModelIndex())
            for j, p in enumerate(self._data[category]):
                if plugin == p:
                    return self.index(j, 0, parent)
        return QModelIndex()

    def plugin_to_index_by_properties(self, plugin):
        for i, category in enumerate(self.categories):
            parent = self.index(i, 0, QModelIndex())
            for j, p in enumerate(self._data[category]):
                if plugin.name == p.name and plugin.type == p.type and \
                        plugin.author == p.author and plugin.version == p.version:
                    return self.index(j, 0, parent)
        return QModelIndex()

    def refresh_plugin(self, plugin, rescan=False):
        if rescan:
            self.populate()
        idx = self.plugin_to_index(plugin)
        self.dataChanged.emit(idx, idx)

    def flags(self, index):
        if not index.isValid():
            return 0
        flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
        return flags

    def data(self, index, role):
        if not index.isValid():
            return None
        if index.internalId() == 0:
            if role == Qt.ItemDataRole.DisplayRole:
                return self.categories[index.row()]
        else:
            plugin = self.index_to_plugin(index)
            disabled = is_disabled(plugin)
            if role == Qt.ItemDataRole.DisplayRole:
                ver = '.'.join(map(str, plugin.version))
                desc = '\n'.join(textwrap.wrap(plugin.description, 100))
                ans='%s (%s) %s %s\n%s'%(plugin.name, ver, _('by'), plugin.author, desc)
                c = plugin_customization(plugin)
                if c and not disabled:
                    ans += _('\nCustomization: ')+c
                if disabled:
                    ans += _('\n\nThis plugin has been disabled')
                if plugin.installation_type is PluginInstallationType.SYSTEM:
                    ans += _('\n\nThis plugin is installed system-wide and can not be managed from within calibre')
                return (ans)
            if role == Qt.ItemDataRole.DecorationRole:
                return self.disabled_icon if disabled else self.icon
            if role == Qt.ItemDataRole.ForegroundRole and disabled:
                return (QBrush(Qt.GlobalColor.gray))
            if role == Qt.ItemDataRole.UserRole:
                return plugin
        return None


# }}}

class ConfigWidget(ConfigWidgetBase, Ui_Form):

    supports_restoring_to_defaults = False

    def genesis(self, gui):
        self.gui = gui
        self._plugin_model = PluginModel(self.user_installed_plugins.isChecked())
        self.plugin_view.setModel(self._plugin_model)
        self.plugin_view.setStyleSheet(
                "QTreeView::item { padding-bottom: 10px;}")
        self.plugin_view.doubleClicked.connect(self.double_clicked)
        self.toggle_plugin_button.clicked.connect(self.toggle_plugin)
        self.customize_plugin_button.clicked.connect(self.customize_plugin)
        self.remove_plugin_button.clicked.connect(self.remove_plugin)
        self.button_plugin_add.clicked.connect(self.add_plugin)
        self.button_plugin_updates.clicked.connect(self.update_plugins)
        self.button_plugin_new.clicked.connect(self.get_plugins)
        self.search.initialize('plugin_search_history',
                help_text=_('Search for plugin'))
        self.search.search.connect(self.find)
        self.next_button.clicked.connect(self.find_next)
        self.previous_button.clicked.connect(self.find_previous)
        self.changed_signal.connect(self.reload_store_plugins)
        self.user_installed_plugins.stateChanged.connect(self.show_user_installed_plugins)
        self.plugin_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.plugin_view.customContextMenuRequested.connect(self.show_context_menu)

    def show_context_menu(self, pos):
        menu = QMenu(self)
        menu.addAction(QIcon.ic('plus.png'), _('Expand all'), self.plugin_view.expandAll)
        menu.addAction(QIcon.ic('minus.png'), _('Collapse all'), self.plugin_view.collapseAll)
        menu.exec(self.plugin_view.mapToGlobal(pos))

    def show_user_installed_plugins(self, state):
        self._plugin_model.toggle_shown_plugins(self.user_installed_plugins.isChecked())

    def find(self, query):
        idx = self._plugin_model.find(query)
        if not idx.isValid():
            return info_dialog(self, _('No matches'),
                    _('Could not find any matching plugins'), show=True,
                    show_copy_button=False)
        self.highlight_index(idx)

    def highlight_index(self, idx):
        self.plugin_view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect)
        self.plugin_view.setCurrentIndex(idx)
        self.plugin_view.setFocus(Qt.FocusReason.OtherFocusReason)
        self.plugin_view.scrollTo(idx, QAbstractItemView.ScrollHint.EnsureVisible)

    def find_next(self, *args):
        idx = self.plugin_view.currentIndex()
        if not idx.isValid():
            idx = self._plugin_model.index(0, 0)
        idx = self._plugin_model.find_next(idx,
                str(self.search.currentText()))
        self.highlight_index(idx)

    def find_previous(self, *args):
        idx = self.plugin_view.currentIndex()
        if not idx.isValid():
            idx = self._plugin_model.index(0, 0)
        idx = self._plugin_model.find_next(idx,
            str(self.search.currentText()), backwards=True)
        self.highlight_index(idx)

    def toggle_plugin(self, *args):
        self.modify_plugin(op='toggle')

    def double_clicked(self, index):
        if index.parent().isValid():
            self.modify_plugin(op='customize')

    def customize_plugin(self, *args):
        self.modify_plugin(op='customize')

    def remove_plugin(self, *args):
        self.modify_plugin(op='remove')

    def add_plugin(self):
        info = '' if iswindows else ' [.zip %s]'%_('files')
        path = choose_files(self, 'add a plugin dialog', _('Add plugin'),
                filters=[(_('Plugins') + info, ['zip'])], all_files=False,
                    select_only_single_file=True)
        if not path:
            return
        path = path[0]
        if path and os.access(path, os.R_OK) and path.lower().endswith('.zip'):
            if not question_dialog(self, _('Are you sure?'), '<p>' +
                    _('Installing plugins is a <b>security risk</b>. '
                    'Plugins can contain a virus/malware. '
                        'Only install it if you got it from a trusted source.'
                        ' Are you sure you want to proceed?'),
                    show_copy_button=False):
                return
            from calibre.customize.ui import config
            installed_plugins = frozenset(config['plugins'])
            try:
                plugin = add_plugin(path)
            except NameConflict as e:
                return error_dialog(self, _('Already exists'),
                        str(e), show=True)
            self._plugin_model.beginResetModel()
            self._plugin_model.populate()
            self._plugin_model.endResetModel()
            self.changed_signal.emit()
            self.check_for_add_to_toolbars(plugin, previously_installed=plugin.name in installed_plugins)
            info_dialog(self, _('Success'),
                    _('Plugin <b>{0}</b> successfully installed under <b>'
                        '{1}</b>. You may have to restart calibre '
                        'for the plugin to take effect.').format(plugin.name, plugin.type),
                    show=True, show_copy_button=False)
            idx = self._plugin_model.plugin_to_index_by_properties(plugin)
            if idx.isValid():
                self.highlight_index(idx)
        else:
            error_dialog(self, _('No valid plugin path'),
                         _('%s is not a valid plugin path')%path).exec()

    def modify_plugin(self, op=''):
        index = self.plugin_view.currentIndex()
        if index.isValid():
            if not index.parent().isValid():
                name = str(index.data() or '')
                return error_dialog(self, _('Error'), '<p>'+
                        _('Select an actual plugin under <b>%s</b> to customize')%name,
                        show=True, show_copy_button=False)

            plugin = self._plugin_model.index_to_plugin(index)
            if op == 'toggle':
                if not plugin.can_be_disabled:
                    info_dialog(self, _('Plugin cannot be disabled'),
                                 _('Disabling the plugin %s is not allowed')%plugin.name, show=True, show_copy_button=False)
                    return
                if is_disabled(plugin):
                    enable_plugin(plugin)
                else:
                    disable_plugin(plugin)
                self._plugin_model.refresh_plugin(plugin)
                self.changed_signal.emit()
            if op == 'customize':
                if not plugin.is_customizable():
                    info_dialog(self, _('Plugin not customizable'),
                        _('Plugin: %s does not need customization')%plugin.name).exec()
                    return
                self.changed_signal.emit()
                from calibre.customize import InterfaceActionBase
                if isinstance(plugin, InterfaceActionBase) and not getattr(plugin,
                        'actual_iaction_plugin_loaded', False):
                    return error_dialog(self, _('Must restart'),
                            _('You must restart calibre before you can'
                                ' configure the <b>%s</b> plugin')%plugin.name, show=True)
                if plugin.do_user_config(self.gui):
                    self._plugin_model.refresh_plugin(plugin)
            elif op == 'remove':
                if not confirm('<p>' +
                    _('Are you sure you want to remove the plugin: %s?')%
                    f'<b>{plugin.name}</b>',
                    'confirm_plugin_removal_msg', parent=self):
                    return

                msg = _('Plugin <b>{0}</b> successfully removed. You will have'
                        ' to restart calibre for it to be completely removed.').format(plugin.name)
                if remove_plugin(plugin):
                    self._plugin_model.beginResetModel()
                    self._plugin_model.populate()
                    self._plugin_model.endResetModel()
                    self.changed_signal.emit()
                    info_dialog(self, _('Success'), msg, show=True,
                            show_copy_button=False)
                else:
                    error_dialog(self, _('Cannot remove builtin plugin'),
                         plugin.name + _(' cannot be removed. It is a '
                         'builtin plugin. Try disabling it instead.')).exec()

    def get_plugins(self):
        self.update_plugins(not_installed=True)

    def update_plugins(self, not_installed=False):
        from calibre.gui2.dialogs.plugin_updater import (PluginUpdaterDialog,
                                FILTER_UPDATE_AVAILABLE, FILTER_NOT_INSTALLED)
        mode = FILTER_NOT_INSTALLED if not_installed else FILTER_UPDATE_AVAILABLE
        d = PluginUpdaterDialog(self.gui, initial_filter=mode)
        d.exec()
        self._plugin_model.beginResetModel()
        self._plugin_model.populate()
        self._plugin_model.endResetModel()
        self.changed_signal.emit()
        if d.do_restart:
            self.restart_now.emit()

    def reload_store_plugins(self):
        self.gui.load_store_plugins()
        if 'Store' in self.gui.iactions:
            self.gui.iactions['Store'].load_menu()

    def check_for_add_to_toolbars(self, plugin, previously_installed=True):
        from calibre.gui2.preferences.toolbar import ConfigWidget
        from calibre.customize import InterfaceActionBase, EditBookToolPlugin

        if isinstance(plugin, EditBookToolPlugin):
            return self.check_for_add_to_editor_toolbar(plugin, previously_installed)

        if not isinstance(plugin, InterfaceActionBase):
            return

        all_locations = OrderedDict(ConfigWidget.LOCATIONS)
        try:
            plugin_action = plugin.load_actual_plugin(self.gui)
        except:
            # Broken plugin, fails to initialize. Given that, it's probably
            # already configured, so we can just quit.
            return
        installed_actions = OrderedDict([
            (key, list(gprefs.get('action-layout-'+key, [])))
            for key in all_locations])

        # If this is an update, do nothing
        if previously_installed:
            return
        # If already installed in a GUI container, do nothing
        for action_names in itervalues(installed_actions):
            if plugin_action.name in action_names:
                return

        allowed_locations = [(key, text) for key, text in
                iteritems(all_locations) if key
                not in plugin_action.dont_add_to]
        if not allowed_locations:
            return  # This plugin doesn't want to live in the GUI

        from calibre.gui2.dialogs.choose_plugin_toolbars import ChoosePluginToolbarsDialog
        d = ChoosePluginToolbarsDialog(self, plugin_action, allowed_locations)
        if d.exec() == QDialog.DialogCode.Accepted:
            for key, text in d.selected_locations():
                installed_actions = list(gprefs.get('action-layout-'+key, []))
                installed_actions.append(plugin_action.name)
                gprefs['action-layout-'+key] = tuple(installed_actions)

    def check_for_add_to_editor_toolbar(self, plugin, previously_installed):
        if not previously_installed:
            from calibre.utils.config import JSONConfig
            prefs = JSONConfig('newly-installed-editor-plugins')
            pl = set(prefs.get('newly_installed_plugins', ()))
            pl.add(plugin.name)
            prefs['newly_installed_plugins'] = sorted(pl)


if __name__ == '__main__':
    from qt.core import QApplication
    app = QApplication([])
    test_widget('Advanced', 'Plugins')
