#!/usr/local/bin/python3.13
# License: GPLv3 Copyright: 2010, Kovid Goyal <kovid at kovidgoyal.net>


import textwrap
from collections import OrderedDict
from functools import partial
from operator import attrgetter

from qt.core import (
    QAbstractItemView,
    QAbstractListModel,
    QApplication,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFont,
    QGridLayout,
    QGroupBox,
    QIcon,
    QItemSelectionModel,
    QLabel,
    QListView,
    QMenu,
    QModelIndex,
    QPlainTextEdit,
    QPushButton,
    QSizePolicy,
    QSplitter,
    Qt,
    QVBoxLayout,
    QWidget,
    pyqtSignal,
)

from calibre import isbytestring, prepare_string_for_xml
from calibre.gui2 import error_dialog, info_dialog
from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget
from calibre.gui2.search_box import SearchBox2
from calibre.gui2.widgets import PythonHighlighter
from calibre.utils.config_base import default_tweaks_raw, exec_tweaks, normalize_tweak, read_custom_tweaks, write_custom_tweaks
from calibre.utils.icu import lower
from calibre.utils.search_query_parser import ParseException, SearchQueryParser
from polyglot.builtins import iteritems

ROOT = QModelIndex()


def format_doc(doc):
    current_indent = default_indent = None
    lines = ['']
    for line in doc.splitlines():
        if not line.strip():
            lines.append('')
            continue
        line = line[1:]
        indent = len(line) - len(line.lstrip())
        if indent != current_indent:
            lines.append('')
        if default_indent is None:
            default_indent = indent
        current_indent = indent
        if indent == default_indent:
            if lines and lines[-1]:
                lines[-1] += ' ' + line
            else:
                lines.append(line)
        else:
            lines.append('    ' + line.strip())
    return '\n'.join(lines).lstrip()


class AdaptSQP(SearchQueryParser):

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


class Tweak:  # {{{

    def __init__(self, name, doc, var_names, defaults, custom):
        translate = _
        self.name = translate(name)
        self.doc = doc.strip()
        self.doc = ' ' + self.doc
        self.var_names = var_names
        if self.var_names:
            self.doc = '{}: {}\n\n{}'.format(_('ID'), self.var_names[0], format_doc(self.doc))
        self.default_values = OrderedDict()
        for x in var_names:
            self.default_values[x] = defaults[x]
        self.custom_values = OrderedDict()
        for x in var_names:
            if x in custom:
                self.custom_values[x] = custom[x]

    def __str__(self):
        ans = ['#: ' + self.name]
        for line in self.doc.splitlines():
            if line:
                ans.append('# ' + line)
        for key, val in iteritems(self.default_values):
            val = self.custom_values.get(key, val)
            ans.append(f'{key} = {val!r}')
        ans = '\n'.join(ans)
        return ans

    @property
    def sort_key(self):
        return 0 if self.is_customized else 1

    @property
    def is_customized(self):
        for x, val in iteritems(self.default_values):
            cval = self.custom_values.get(x, val)
            if normalize_tweak(cval) != normalize_tweak(val):
                return True
        return False

    @property
    def edit_text(self):
        from pprint import pformat
        ans = [f'# {self.name}']
        for x, val in iteritems(self.default_values):
            val = self.custom_values.get(x, val)
            if isinstance(val, (list, tuple, dict, set, frozenset)):
                ans.append(f'{x} = {pformat(val)}')
            else:
                ans.append(f'{x} = {val!r}')
        return '\n\n'.join(ans)

    def restore_to_default(self):
        self.custom_values.clear()

    def update(self, varmap):
        self.custom_values.update(varmap)

# }}}


class Tweaks(QAbstractListModel, AdaptSQP):  # {{{

    def __init__(self, parent=None):
        QAbstractListModel.__init__(self, parent)
        SearchQueryParser.__init__(self, ['all'])
        self.parse_tweaks()

    def rowCount(self, *args):
        return len(self.tweaks)

    def data(self, index, role):
        row = index.row()
        try:
            tweak = self.tweaks[row]
        except:
            return None
        if role == Qt.ItemDataRole.DisplayRole:
            return tweak.name
        if role == Qt.ItemDataRole.FontRole and tweak.is_customized:
            ans = QFont()
            ans.setBold(True)
            return ans
        if role == Qt.ItemDataRole.ToolTipRole:
            tt = _('This tweak has its default value')
            if tweak.is_customized:
                tt = '<p>'+_('This tweak has been customized')
                tt += '<pre>'
                for varn, val in iteritems(tweak.custom_values):
                    tt += f'{varn} = {val!r}\n\n'
            return textwrap.fill(tt)
        if role == Qt.ItemDataRole.UserRole:
            return tweak
        return None

    def parse_tweaks(self):
        try:
            custom_tweaks = read_custom_tweaks()
        except:
            print('Failed to load custom tweaks file')
            import traceback
            traceback.print_exc()
            custom_tweaks = {}
        default_tweaks = exec_tweaks(default_tweaks_raw())
        defaults = default_tweaks_raw().decode('utf-8')
        lines = defaults.splitlines()
        pos = 0
        self.tweaks = []
        while pos < len(lines):
            line = lines[pos]
            if line.startswith('#:'):
                pos = self.read_tweak(lines, pos, default_tweaks, custom_tweaks)
            pos += 1

        self.tweaks.sort(key=attrgetter('sort_key'))
        default_keys = set(default_tweaks)
        custom_keys = set(custom_tweaks)

        self.plugin_tweaks = {}
        for key in custom_keys - default_keys:
            self.plugin_tweaks[key] = custom_tweaks[key]

    def read_tweak(self, lines, pos, defaults, custom):
        name = lines[pos][2:].strip()
        doc, stripped_doc, leading, var_names = [], [], [], []
        while True:
            pos += 1
            line = lines[pos]
            if not line.startswith('#'):
                break
            line = line[1:]
            doc.append(line.rstrip())
            stripped_doc.append(line.strip())
            leading.append(line[:len(line) - len(line.lstrip())])
        translate = _
        stripped_doc = translate('\n'.join(stripped_doc).strip())
        final_doc = []
        for prefix, line in zip(leading, stripped_doc.splitlines()):
            final_doc.append(prefix + line)
        doc = '\n'.join(final_doc)
        while True:
            try:
                line = lines[pos]
            except IndexError:
                break
            if not line.strip():
                break
            spidx1 = line.find(' ')
            spidx2 = line.find('=')
            spidx = spidx1 if spidx1 > 0 and (spidx2 == 0 or spidx2 > spidx1) else spidx2
            if spidx > 0:
                var = line[:spidx]
                if var not in defaults:
                    raise ValueError(f'{var!r} not in default tweaks dict')
                var_names.append(var)
            pos += 1
        if not var_names:
            raise ValueError(f'Failed to find any variables for {name!r}')
        self.tweaks.append(Tweak(name, doc, var_names, defaults, custom))
        return pos

    def restore_to_default(self, idx):
        tweak = self.data(idx, Qt.ItemDataRole.UserRole)
        if tweak is not None:
            tweak.restore_to_default()
            self.dataChanged.emit(idx, idx)

    def restore_to_defaults(self):
        for r in range(self.rowCount()):
            self.restore_to_default(self.index(r))
        self.plugin_tweaks = {}

    def update_tweak(self, idx, varmap):
        tweak = self.data(idx, Qt.ItemDataRole.UserRole)
        if tweak is not None:
            tweak.update(varmap)
            self.dataChanged.emit(idx, idx)

    def to_string(self):
        ans = ['#!/usr/bin/env python',
               '# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai', '',
               '# This file was automatically generated by calibre, do not'
               ' edit it unless you know what you are doing.', '',
            ]
        for tweak in self.tweaks:
            ans.extend(['', str(tweak), ''])

        if self.plugin_tweaks:
            ans.extend(['', '',
                '# The following are tweaks for installed plugins', ''])
            for key, val in iteritems(self.plugin_tweaks):
                ans.extend([f'{key} = {val!r}', '', ''])
        return '\n'.join(ans)

    @property
    def plugin_tweaks_string(self):
        ans = []
        for key, val in iteritems(self.plugin_tweaks):
            ans.extend([f'{key} = {val!r}', '', ''])
        ans = '\n'.join(ans)
        if isbytestring(ans):
            ans = ans.decode('utf-8')
        return ans

    def set_plugin_tweaks(self, d):
        self.plugin_tweaks = d

    def universal_set(self):
        return set(range(self.rowCount()))

    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 r in candidates:
            dat = self.data(self.index(r), Qt.ItemDataRole.UserRole)
            var_names = ' '.join(dat.default_values)
            if query in lower(dat.name) or query in lower(var_names):
                ans.add(r)
        return ans

    def find(self, query):
        query = query.strip()
        if not query:
            return ROOT
        matches = self.parse(query)
        if not matches:
            return ROOT
        matches = list(sorted(matches))
        return self.index(matches[0])

    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
        loc = idx.row()
        if loc not in matches:
            return self.find(query)
        if len(matches) == 1:
            return ROOT
        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)

# }}}


class PluginTweaks(QDialog):  # {{{

    def __init__(self, raw, parent=None):
        QDialog.__init__(self, parent)
        self.setWindowTitle(_('Plugin tweaks'))
        self.edit = QPlainTextEdit(self)
        self.highlighter = PythonHighlighter(self.edit.document())
        self.l = QVBoxLayout()
        self.setLayout(self.l)
        self.msg = QLabel(
            _('Add/edit tweaks for any custom plugins you have installed. '
                'Documentation for these tweaks should be available '
                'on the website from where you downloaded the plugins.'))
        self.msg.setWordWrap(True)
        self.l.addWidget(self.msg)
        self.l.addWidget(self.edit)
        self.edit.setPlainText(raw)
        self.bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok|QDialogButtonBox.StandardButton.Cancel,
                Qt.Orientation.Horizontal, self)
        self.bb.accepted.connect(self.accept)
        self.bb.rejected.connect(self.reject)
        self.l.addWidget(self.bb)
        self.resize(550, 300)

# }}}


class TweaksView(QListView):

    current_changed = pyqtSignal(object, object)

    def __init__(self, parent=None):
        QListView.__init__(self, parent)
        self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
        self.setAlternatingRowColors(True)
        self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
        self.setMinimumWidth(300)
        self.setStyleSheet('QListView::item { padding-top: 0.75ex; padding-bottom: 0.75ex; }')
        self.setWordWrap(True)

    def currentChanged(self, cur, prev):
        QListView.currentChanged(self, cur, prev)
        self.current_changed.emit(cur, prev)


class ConfigWidget(ConfigWidgetBase):

    def setupUi(self, x):
        self.l = l = QVBoxLayout(self)
        self.la1 = la = QLabel(
            _('Values for the tweaks are shown below. Edit them to change the behavior of calibre.'
              ' Your changes will only take effect <b>after a restart</b> of calibre.'))
        l.addWidget(la), la.setWordWrap(True)
        self.splitter = s = QSplitter(self)
        s.setChildrenCollapsible(False)
        l.addWidget(s, 10)

        self.lv = lv = QWidget(self)
        lv.l = l2 = QVBoxLayout(lv)
        l2.setContentsMargins(0, 0, 0, 0)
        self.tweaks_view = tv = TweaksView(self)
        l2.addWidget(tv)
        self.plugin_tweaks_button = b = QPushButton(self)
        b.setToolTip(_('Edit tweaks for any custom plugins you have installed'))
        b.setText(_('&Plugin tweaks'))
        l2.addWidget(b)
        s.addWidget(lv)

        self.lv1 = lv = QWidget(self)
        s.addWidget(lv)
        lv.g = g = QGridLayout(lv)
        g.setContentsMargins(0, 0, 0, 0)

        self.search = sb = SearchBox2(self)
        sb.sizePolicy().setHorizontalStretch(10)
        sb.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
        sb.setMinimumContentsLength(10)
        g.setColumnStretch(0, 100)
        g.addWidget(self.search, 0, 0, 1, 1)
        self.next_button = b = QPushButton(self)
        b.setIcon(QIcon.ic('arrow-down.png'))
        b.setText(_('&Next'))
        g.addWidget(self.next_button, 0, 1, 1, 1)
        self.previous_button = b = QPushButton(self)
        b.setIcon(QIcon.ic('arrow-up.png'))
        b.setText(_('&Previous'))
        g.addWidget(self.previous_button, 0, 2, 1, 1)

        self.hb = hb = QGroupBox(self)
        hb.setTitle(_('Help'))
        hb.l = l2 = QVBoxLayout(hb)
        self.help = h = QPlainTextEdit(self)
        l2.addWidget(h)
        h.setReadOnly(True)
        g.addWidget(hb, 1, 0, 1, 3)

        self.eb = eb = QGroupBox(self)
        g.addWidget(eb, 2, 0, 1, 3)
        eb.setTitle(_('Edit tweak'))
        eb.g = ebg = QGridLayout(eb)
        self.edit_tweak = et = QPlainTextEdit(self)
        et.setMinimumWidth(400)
        et.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
        ebg.addWidget(et, 0, 0, 1, 2)
        self.restore_default_button = b = QPushButton(self)
        b.setToolTip(_('Restore this tweak to its default value'))
        b.setText(_('&Reset this tweak'))
        ebg.addWidget(b, 1, 0, 1, 1)
        self.apply_button = ab = QPushButton(self)
        ab.setToolTip(_('Apply any changes you made to this tweak'))
        ab.setText(_('&Apply changes to this tweak'))
        ebg.addWidget(ab, 1, 1, 1, 1)

    def genesis(self, gui):
        self.gui = gui
        self.tweaks_view.current_changed.connect(self.current_changed)
        self.view = self.tweaks_view
        self.highlighter = PythonHighlighter(self.edit_tweak.document())
        self.restore_default_button.clicked.connect(self.restore_to_default)
        self.apply_button.clicked.connect(self.apply_tweak)
        self.plugin_tweaks_button.clicked.connect(self.plugin_tweaks)
        self.splitter.setStretchFactor(0, 1)
        self.splitter.setStretchFactor(1, 100)
        self.next_button.clicked.connect(self.find_next)
        self.previous_button.clicked.connect(self.find_previous)
        self.search.initialize('tweaks_search_history', help_text=_('Search for tweak'))
        self.search.search.connect(self.find)
        self.view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.view.customContextMenuRequested.connect(self.show_context_menu)
        self.copy_icon = QIcon.ic('edit-copy.png')

    def show_context_menu(self, point):
        idx = self.tweaks_view.currentIndex()
        if not idx.isValid():
            return True
        tweak = self.tweaks.data(idx, Qt.ItemDataRole.UserRole)
        self.context_menu = QMenu(self)
        self.context_menu.addAction(self.copy_icon,
                            _('Copy to clipboard'),
                            partial(self.copy_item_to_clipboard,
                                    val='{} ({}: {})'.format(tweak.name,
                                                        _('ID'),
                                                        tweak.var_names[0])))
        self.context_menu.popup(self.mapToGlobal(point))
        return True

    def copy_item_to_clipboard(self, val):
        cb = QApplication.clipboard()
        cb.clear()
        cb.setText(val)

    def plugin_tweaks(self):
        raw = self.tweaks.plugin_tweaks_string
        d = PluginTweaks(raw, self)
        if d.exec() == QDialog.DialogCode.Accepted:
            g, l = {}, {}
            try:
                exec(str(d.edit.toPlainText()), g, l)
            except:
                import traceback
                return error_dialog(self, _('Failed'),
                    _('There was a syntax error in your tweak. Click '
                        'the "Show details" button for details.'), show=True,
                    det_msg=traceback.format_exc())
            self.tweaks.set_plugin_tweaks(l)
            self.changed()

    def current_changed(self, *a):
        current = self.tweaks_view.currentIndex()
        if current.isValid():
            self.tweaks_view.scrollTo(current)
            tweak = self.tweaks.data(current, Qt.ItemDataRole.UserRole)
            self.help.setPlainText(tweak.doc)
            self.edit_tweak.setPlainText(tweak.edit_text)

    def changed(self, *args):
        self.changed_signal.emit()

    def initialize(self):
        self.tweaks = self._model = Tweaks()
        self.tweaks_view.setModel(self.tweaks)
        self.tweaks_view.setCurrentIndex(self.tweaks_view.model().index(0))

    def restore_to_default(self, *args):
        idx = self.tweaks_view.currentIndex()
        if idx.isValid():
            self.tweaks.restore_to_default(idx)
            tweak = self.tweaks.data(idx, Qt.ItemDataRole.UserRole)
            self.edit_tweak.setPlainText(tweak.edit_text)
            self.changed()

    def restore_defaults(self):
        ConfigWidgetBase.restore_defaults(self)
        self.tweaks.restore_to_defaults()
        self.changed()

    def apply_tweak(self):
        idx = self.tweaks_view.currentIndex()
        if idx.isValid():
            l, g = {}, {}
            try:
                exec(str(self.edit_tweak.toPlainText()), g, l)
            except:
                import traceback
                error_dialog(self.gui, _('Failed'),
                        _('There was a syntax error in your tweak. Click '
                            'the "Show details" button for details.'),
                        det_msg=traceback.format_exc(), show=True)
                return
            self.tweaks.update_tweak(idx, l)
            self.changed()

    def commit(self):
        raw = self.tweaks.to_string()
        if not isinstance(raw, bytes):
            raw = raw.encode('utf-8')
        try:
            custom_tweaks = exec_tweaks(raw)
        except:
            import traceback
            error_dialog(self, _('Invalid tweaks'),
                    _('The tweaks you entered are invalid, try resetting the'
                        ' tweaks to default and changing them one by one until'
                        ' you find the invalid setting.'),
                    det_msg=traceback.format_exc(), show=True)
            raise AbortCommit('abort')
        write_custom_tweaks(custom_tweaks)
        ConfigWidgetBase.commit(self)
        return True

    def find(self, query):
        if not query:
            return
        try:
            idx = self._model.find(query)
        except ParseException:
            self.search.search_done(False)
            return
        self.search.search_done(True)
        if not idx.isValid():
            info_dialog(self, _('No matches'),
                    _('Could not find any tweaks matching <i>{}</i>').format(prepare_string_for_xml(query)),
                    show=True, show_copy_button=False)
            return
        self.highlight_index(idx)

    def highlight_index(self, idx):
        if not idx.isValid():
            return
        self.view.scrollTo(idx)
        self.view.selectionModel().select(idx, QItemSelectionModel.SelectionFlag.ClearAndSelect)
        self.view.setCurrentIndex(idx)

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

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


if __name__ == '__main__':
    from calibre.gui2 import Application
    app = Application([])
    # Tweaks()
    # test_widget
    test_widget('Advanced', 'Tweaks')
