#!/usr/bin/env python


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

import os
import textwrap
from collections import OrderedDict

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

from calibre.constants import iswindows
from calibre.customize import PluginInstallationType
from calibre.customize.ui import NameConflict, add_plugin, disable_plugin, enable_plugin, initialized_plugins, is_disabled, plugin_customization, remove_plugin
from calibre.gui2 import choose_files, error_dialog, gprefs, info_dialog, question_dialog
from calibre.gui2.dialogs.confirm_delete import confirm
from calibre.gui2.preferences import ConfigWidgetBase, test_widget
from calibre.gui2.preferences.plugins_ui import Ui_Form
from calibre.utils.icu import lower
from calibre.utils.search_query_parser import SearchQueryParser
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.ic('plugins.png')
        p = 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 Qt.ItemFlag.NoItemFlags
        return Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled

    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)
            from calibre.gui2.dialogs.plugin_updater import notify_on_successful_install
            do_restart = notify_on_successful_install(self, plugin)
            idx = self._plugin_model.plugin_to_index_by_properties(plugin)
            if idx.isValid():
                self.highlight_index(idx)
            if do_restart:
                self.restart_now.emit()
        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 FILTER_NOT_INSTALLED, FILTER_UPDATE_AVAILABLE, PluginUpdaterDialog
        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.customize import EditBookToolPlugin, InterfaceActionBase
        from calibre.gui2.preferences.toolbar import ConfigWidget

        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 calibre.gui2 import Application
    app = Application([])
    test_widget('Advanced', 'Plugins')
