#!/usr/bin/env python
# License: GPLv3 Copyright: 2008, Kovid Goyal kovid@kovidgoyal.net

import json
import os
import re
import sys
import traceback
from functools import partial

from qt.core import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QColor,
    QComboBox,
    QCursor,
    QDialog,
    QDialogButtonBox,
    QFont,
    QFontDatabase,
    QFontInfo,
    QFontMetrics,
    QHBoxLayout,
    QIcon,
    QLineEdit,
    QPalette,
    QSize,
    QSyntaxHighlighter,
    Qt,
    QTableWidget,
    QTableWidgetItem,
    QTextCharFormat,
    QTextOption,
    QTimer,
    QToolButton,
    QVBoxLayout,
    pyqtSignal,
)

from calibre import sanitize_file_name
from calibre.constants import config_dir, iswindows
from calibre.ebooks.metadata.book.base import get_model_metadata_instance
from calibre.ebooks.metadata.book.formatter import SafeFormat
from calibre.gui2 import choose_files, choose_save_file, error_dialog, gprefs, info_dialog, pixmap_to_data, qapplication_or_fail, question_dialog, safe_open_url
from calibre.gui2.dialogs.template_dialog_ui import Ui_TemplateDialog
from calibre.gui2.dialogs.template_general_info import GeneralInformationDialog
from calibre.gui2.widgets2 import Dialog, HTMLDisplay
from calibre.library.coloring import color_row_key, displayable_columns
from calibre.utils.config_base import tweaks
from calibre.utils.ffml_processor import MARKUP_ERROR, FFMLProcessor
from calibre.utils.formatter import PythonTemplateContext, StopException
from calibre.utils.formatter_functions import StoredObjectType, formatter_functions
from calibre.utils.icu import lower as icu_lower
from calibre.utils.icu import sort_key
from calibre.utils.localization import _, localize_user_manual_link
from calibre.utils.resources import get_path as P


class DocViewer(Dialog):
    def __init__(self, ffml, builtins, function_type_string_method, parent=None):
        self.ffml = ffml
        self.builtins = builtins
        self.function_type_string = function_type_string_method
        self.last_operation = None
        self.last_function = None
        self.back_stack = []
        super().__init__(
            title=_('Template function documentation'),
            name='template_editor_doc_viewer_dialog',
            default_buttons=QDialogButtonBox.StandardButton.Close,
            parent=parent,
        )

    def sizeHint(self):
        return QSize(800, 600)

    def set_html(self, html):
        self.doc_viewer_widget.setHtml(html)

    def setup_ui(self):
        l = QVBoxLayout(self)
        e = self.doc_viewer_widget = HTMLDisplay(self)
        if iswindows:
            e.setDefaultStyleSheet('pre { font-family: "Segoe UI Mono", "Consolas", monospace; }')
        e.anchor_clicked.connect(self.url_clicked)
        l.addWidget(e)
        bl = QHBoxLayout()
        l.addLayout(bl)
        self.english_cb = cb = QCheckBox(_('Show documentation as original &English'))
        cb.setChecked(gprefs.get('template_editor_docs_in_english', False))
        cb.stateChanged.connect(self.english_cb_state_changed)
        bl.addWidget(cb)
        bl.addWidget(self.bb)

        b = self.back_button = self.bb.addButton(_('&Back'), QDialogButtonBox.ButtonRole.ActionRole)
        assert b is not None
        b.clicked.connect(self.back)
        b.setToolTip(_('Displays the previously viewed function'))
        b.setEnabled(False)

        b = self.bb.addButton(_('Show &all functions'), QDialogButtonBox.ButtonRole.ActionRole)
        assert b is not None
        b.clicked.connect(self.show_all_functions_button_clicked)
        b.setToolTip(_('Shows a list of all built-in functions in alphabetic order'))

    def back(self):
        if not self.back_stack:
            info_dialog(self, _('Go back'), _('No function to go back to'), show=True)
        else:
            place = self.back_stack.pop()
            back_button = self.back_button
            assert back_button is not None
            back_button.setEnabled(bool(self.back_stack))
            if isinstance(place, int):
                self.show_all_functions()

                # For reasons known only to Qt, I can't set the scroll bar position
                # until some time has passed.
                def _do_scroll(val=place):
                    vsb = self.doc_viewer_widget.verticalScrollBar()
                    assert vsb is not None
                    vsb.setValue(val)

                QTimer.singleShot(10, _do_scroll)
            else:
                self._show_function(place)

    def add_to_back_stack(self):
        if self.last_function is not None:
            self.back_stack.append(self.last_function)
        elif self.last_operation is not None:
            vsb = self.doc_viewer_widget.verticalScrollBar()
            assert vsb is not None
            self.back_stack.append(vsb.value())
        back_button = self.back_button
        assert back_button is not None
        back_button.setEnabled(bool(self.back_stack))

    def url_clicked(self, qurl):
        if qurl.scheme().startswith('http'):
            safe_open_url(qurl)
        else:
            self.show_function(qurl.path())

    def english_cb_state_changed(self):
        if self.last_operation is not None:
            self.last_operation()
        gprefs['template_editor_docs_in_english'] = self.english_cb.isChecked()

    def header_line(self, name):
        return f'\n<h3>{name} ({self.function_type_string(name, longform=False)})</h3>\n'

    def get_doc(self, func):
        doc = func.doc if hasattr(func, 'doc') else ''
        return getattr(doc, 'formatted_english', doc) if self.english_cb.isChecked() else doc

    def no_doc_string(self):
        if self.english_cb.isChecked():
            return 'No documentation provided'
        return _('No documentation provided')

    def show_function(self, fname):
        if fname in self.builtins and fname != self.last_function:
            self.add_to_back_stack()
            self._show_function(fname)

    def _show_function(self, fname):
        self.last_operation = partial(self._show_function, fname)
        bif = self.builtins[fname]
        if fname not in self.builtins or not bif.doc:
            self.set_html(self.header_line(fname) + self.no_doc_string())
        else:
            self.last_function = fname
            self.set_html(self.header_line(fname) + self.ffml.document_to_html(self.get_doc(bif), fname))

    def show_all_functions_button_clicked(self):
        self.add_to_back_stack()
        self.show_all_functions()

    def show_all_functions(self):
        self.last_function = None
        self.last_operation = self.show_all_functions
        result = []
        a = result.append
        for name in sorted(self.builtins, key=sort_key):
            a(self.header_line(name))
            try:
                doc = self.get_doc(self.builtins[name])
                if not doc:
                    a(self.no_doc_string())
                else:
                    html = self.ffml.document_to_html(doc, name)
                    if MARKUP_ERROR not in html:
                        name_pos = html.find(name + '(')
                        if name_pos < 0:
                            rest_of_doc = ' -- ' + html
                        else:
                            rest_of_doc = html[name_pos + len(name) :]
                        html = f'<a href="ffdoc:{name}">{name}</a>{rest_of_doc}'
                    a(html)
            except Exception:
                print('Exception in', name)
                raise
        self.doc_viewer_widget.setHtml(''.join(result))


class ParenPosition:
    def __init__(self, block, pos, paren):
        self.block = block
        self.pos = pos
        self.paren = paren
        self.highlight = False

    def set_highlight(self, to_what):
        self.highlight = to_what


class TemplateHighlighter(QSyntaxHighlighter):
    # Code in this class is liberally borrowed from gui2.widgets.PythonHighlighter

    BN_FACTOR = 1000

    KEYWORDS_GPM = [
        'if',
        'then',
        'else',
        'elif',
        'fi',
        'for',
        'rof',
        'separator',
        'break',
        'continue',
        'return',
        'in',
        'inlist',
        'inlist_field',
        'def',
        'fed',
        'limit',
        'with',
        'htiw',
    ]

    KEYWORDS_PYTHON = [
        'and',
        'as',
        'assert',
        'break',
        'class',
        'continue',
        'def',
        'del',
        'elif',
        'else',
        'except',
        'exec',
        'finally',
        'for',
        'from',
        'global',
        'if',
        'import',
        'in',
        'is',
        'lambda',
        'not',
        'or',
        'pass',
        'print',
        'raise',
        'return',
        'try',
        'while',
        'with',
        'yield',
    ]

    BUILTINS_PYTHON = [
        'abs',
        'all',
        'any',
        'basestring',
        'bool',
        'callable',
        'chr',
        'classmethod',
        'cmp',
        'compile',
        'complex',
        'delattr',
        'dict',
        'dir',
        'divmod',
        'enumerate',
        'eval',
        'execfile',
        'exit',
        'file',
        'filter',
        'float',
        'frozenset',
        'getattr',
        'globals',
        'hasattr',
        'hex',
        'id',
        'int',
        'isinstance',
        'issubclass',
        'iter',
        'len',
        'list',
        'locals',
        'long',
        'map',
        'max',
        'min',
        'object',
        'oct',
        'open',
        'ord',
        'pow',
        'property',
        'range',
        'reduce',
        'repr',
        'reversed',
        'round',
        'set',
        'setattr',
        'slice',
        'sorted',
        'staticmethod',
        'str',
        'sum',
        'super',
        'tuple',
        'type',
        'unichr',
        'unicode',
        'vars',
        'xrange',
        'zip',
    ]

    CONSTANTS_PYTHON = ['False', 'True', 'None', 'NotImplemented', 'Ellipsis']

    def __init__(self, parent=None, builtin_functions=None):
        super().__init__(parent)
        self.initialize_formats()
        self.initialize_rules(builtin_functions, for_python=False)
        self.regenerate_paren_positions()
        self.highlighted_paren = False

    def initialize_rules(self, builtin_functions, for_python=False):
        self.for_python = for_python
        r = []

        def a(a, b):
            r.append((re.compile(a), b))

        if not for_python:
            a(
                r'\b[a-zA-Z]\w*\b(?!\(|\s+\()'
                r'|\$+#?[a-zA-Z]\w*',
                'identifier',
            )
            a(r'^program:', 'keymode')
            a('|'.join([rf'\b{keyword}\b' for keyword in self.KEYWORDS_GPM]), 'keyword')
            a(
                '|'.join([rf'\b{builtin}\b' for builtin in (builtin_functions or formatter_functions().get_builtins())]),
                'builtin',
            )
            a(r'''(?<!:)'[^']*'|"[^"]*\"''', 'string')
        else:
            a(r'^python:', 'keymode')
            a('|'.join([rf'\b{keyword}\b' for keyword in self.KEYWORDS_PYTHON]), 'keyword')
            a('|'.join([rf'\b{builtin}\b' for builtin in self.BUILTINS_PYTHON]), 'builtin')
            a('|'.join([rf'\b{constant}\b' for constant in self.CONSTANTS_PYTHON]), 'constant')
            a(r'\bPyQt6\b|\bqt.core\b|\bQt?[A-Z][a-z]\w+\b', 'pyqt')
            a(r'@\w+(\.\w+)?\b', 'decorator')

            stringRe = r'''(["'])(?:(?!\1)[^\\]|\\.)*\1'''
            a(stringRe, 'string')
            self.stringRe = re.compile(stringRe)
            self.checkTripleInStringRe = re.compile(r'''((?:"|'){3}).*?\1''')
            self.tripleSingleRe = re.compile(r"""'''(?!")""")
            self.tripleDoubleRe = re.compile(r'''"""(?!')''')
        a(
            r'\b[+-]?[0-9]+[lL]?\b'
            r'|\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'
            r'|\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b',
            'number',
        )

        a(r'\(', 'lparen')
        a(r'\)', 'rparen')
        self.Rules = tuple(r)

    def initialize_formats(self):
        font_name = gprefs.get('gpm_template_editor_font', None)
        size = gprefs['gpm_template_editor_font_size']
        if font_name is None:
            font = QFont()
            font.setFixedPitch(True)
            font.setPointSize(size)
            font_name = font.family()
        config = self.Config = {}
        config['fontfamily'] = font_name
        app_palette = qapplication_or_fail().palette()
        is_dark = qapplication_or_fail().is_dark_theme

        all_formats = (
            # name, color, bold, italic
            ('normal', None, False, False),
            ('keyword', app_palette.color(QPalette.ColorRole.Link).name(), True, False),
            ('builtin', app_palette.color(QPalette.ColorRole.Link).name(), False, False),
            ('constant', app_palette.color(QPalette.ColorRole.Link).name(), False, False),
            ('identifier', None, False, True),
            ('comment', '#00c700' if is_dark else '#007F00', False, True),
            ('string', '#b6b600' if is_dark else '#808000', False, False),
            ('number', '#d96d00' if is_dark else '#924900', False, False),
            ('decorator', '#FF8000', False, True),
            ('pyqt', None, False, False),
            ('lparen', None, True, True),
            ('rparen', None, True, True),
        )

        for name, color, bold, italic in all_formats:
            config[f'{name}fontcolor'] = color
            config[f'{name}fontbold'] = bold
            config[f'{name}fontitalic'] = italic
        base_format = QTextCharFormat()
        base_format.setFontFamilies([config['fontfamily']])
        config['fontsize'] = size
        base_format.setFontPointSize(config['fontsize'])

        self.Formats = {}
        for name, color, bold, italic in all_formats:
            format_ = QTextCharFormat(base_format)
            color = config[f'{name}fontcolor']
            if color:
                format_.setForeground(QColor(color))
            if config[f'{name}fontbold']:
                format_.setFontWeight(QFont.Weight.Bold)
            format_.setFontItalic(config[f'{name}fontitalic'])
            self.Formats[name] = format_

    def find_paren(self, bn, pos):
        dex = bn * self.BN_FACTOR + pos
        return self.paren_pos_map.get(dex, None)

    def replace_strings_with_dash(self, mo):
        found = mo.group(0)
        return '-' * len(found)

    def highlightBlock(self, text):
        NORMAL, TRIPLESINGLE, TRIPLEDOUBLE = range(3)

        bn = self.currentBlock().blockNumber()
        textLength = len(text)

        self.setFormat(0, textLength, self.Formats['normal'])

        if not text:
            pass
        elif re.match(r'[ \t]*#', text):
            # Line with only a comment possibly preceded with spaces or tabs
            # This works in both GPM and python
            self.setFormat(0, textLength, self.Formats['comment'])
            return

        for regex, format_ in self.Rules:
            for m in regex.finditer(text):
                i, length = m.start(), m.end() - m.start()
                if format_ in ['lparen', 'rparen']:
                    pp = self.find_paren(bn, i)
                    if pp and pp.highlight:
                        self.setFormat(i, length, self.Formats[format_])
                elif format_ == 'keymode':
                    if bn > 0 and i == 0:
                        continue
                    self.setFormat(i, length, self.Formats['keyword'])
                else:
                    self.setFormat(i, length, self.Formats[format_])

        # Deal with comments not at the beginning of the line.
        if self.for_python and '#' in text:
            # Remove any strings from the text before we check for '#'. This way
            # we avoid thinking a # inside a string starts a comment.
            t = re.sub(self.stringRe, self.replace_strings_with_dash, text)
            sharp_pos = t.find('#')
            if sharp_pos >= 0:  # Do we still have a #?
                self.setFormat(sharp_pos, len(text), self.Formats['comment'])

        self.setCurrentBlockState(NORMAL)

        if self.for_python and self.checkTripleInStringRe.search(text) is None:
            # This is fooled by triple quotes inside single quoted strings
            for m, state in (
                (self.tripleSingleRe.search(text), TRIPLESINGLE),
                (self.tripleDoubleRe.search(text), TRIPLEDOUBLE),
            ):
                i = -1 if m is None else m.start()
                if self.previousBlockState() == state:
                    if i == -1:
                        i = len(text)
                        self.setCurrentBlockState(state)
                    self.setFormat(0, i + 3, self.Formats['string'])
                elif i > -1:
                    self.setCurrentBlockState(state)
                    self.setFormat(i, len(text), self.Formats['string'])

        if self.generate_paren_positions:
            t = str(text)
            i = 0
            found_quote = False
            while i < len(t):
                c = t[i]
                if c == ':':
                    # Deal with the funky syntax of template program mode.
                    # This won't work if there are more than one template
                    # expression in the document.
                    if not found_quote and i + 1 < len(t) and t[i + 1] == "'":
                        i += 2
                elif c in ["'", '"']:
                    found_quote = True
                    i += 1
                    j = t[i:].find(c)
                    if j < 0:
                        i = len(t)
                    else:
                        i = i + j
                elif c in ('(', ')'):
                    pp = ParenPosition(bn, i, c)
                    self.paren_positions.append(pp)
                    self.paren_pos_map[bn * self.BN_FACTOR + i] = pp
                i += 1

    def rehighlight(self):
        QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
        super().rehighlight()
        QApplication.restoreOverrideCursor()

    def check_cursor_pos(self, chr_, block, pos_in_block):
        paren_pos = -1
        for i, pp in enumerate(self.paren_positions):
            pp.set_highlight(False)
            if pp.block == block and pp.pos == pos_in_block:
                paren_pos = i

        if chr_ not in ('(', ')'):
            if self.highlighted_paren:
                self.rehighlight()
                self.highlighted_paren = False
            return

        if paren_pos >= 0:
            stack = 0
            if chr_ == '(':
                list_ = self.paren_positions[paren_pos + 1 :]
            else:
                list_ = reversed(self.paren_positions[0:paren_pos])
            for pp in list_:
                if pp.paren == chr_:
                    stack += 1
                elif stack:
                    stack -= 1
                else:
                    pp.set_highlight(True)
                    self.paren_positions[paren_pos].set_highlight(True)
                    break
        self.highlighted_paren = True
        self.rehighlight()

    def regenerate_paren_positions(self):
        self.generate_paren_positions = True
        self.paren_positions = []
        self.paren_pos_map = {}
        self.rehighlight()
        self.generate_paren_positions = False


translate_table = str.maketrans({
    '\n': '\\n',
    '\r': '\\r',
    '\t': '\\t',
    '\\': '\\\\',
})


class TemplateDialog(QDialog, Ui_TemplateDialog):
    tester_closed = pyqtSignal(object, object)

    def setWindowTitle(self, a0, dialog_number=None):
        if dialog_number is None:
            a0 = _('{a0} (only one template dialog allowed)').format(a0=a0)
        else:
            a0 = _('{a0} dialog number {number} (multiple template dialogs allowed)').format(a0=a0, number=dialog_number)
        super().setWindowTitle(a0)

    def __init__(
        self,
        parent,
        text,
        mi=None,
        fm=None,
        color_field=None,
        icon_field_key=None,
        icon_rule_kind=None,
        doing_emblem=False,
        text_is_placeholder=False,
        dialog_is_st_editor=False,
        global_vars=None,
        all_functions=None,
        builtin_functions=None,
        python_context_object=None,
        dialog_number=None,
        kwargs=None,
        formatter=SafeFormat,
        icon_dir='cc_icons',
    ):
        # If dialog_number isn't None then we want separate non-modal windows
        # that don't stay on top of the main dialog. This lets Alt-Tab work to
        # switch between them. dialog_number must be set only by the template
        # tester, not the rules dialogs etc that depend on modality.

        # doing_emblem is also used for tag browser value icon rules in order to
        # show the icon selection widgets.

        if dialog_number is None:
            QDialog.__init__(self, parent, flags=Qt.WindowType.Dialog)
        else:
            QDialog.__init__(self, None, flags=Qt.WindowType.Window)
            self.raise_and_focus()  # Not needed on windows but here just in case
        Ui_TemplateDialog.__init__(self)
        self.setupUi(self)
        self.setWindowIcon(self.windowIcon())

        self.formatter = formatter
        self.icon_dir = icon_dir
        self.ffml = FFMLProcessor()
        self.dialog_number = dialog_number
        self.coloring = color_field is not None
        self.iconing = icon_field_key is not None
        self.embleming = doing_emblem
        self.required_txt = self.coloring or self.iconing or self.embleming
        self.dialog_is_st_editor = dialog_is_st_editor
        self.global_vars = global_vars or {}
        self.python_context_object = python_context_object or PythonTemplateContext()
        self.kwargs = kwargs

        cols = []
        self.fm = fm
        if fm is not None:
            for key in sorted(displayable_columns(fm), key=lambda k: sort_key(fm[k]['name'] if k != color_row_key else 0)):
                if key == color_row_key and not self.coloring:
                    continue
                from calibre.gui2.preferences.coloring import all_columns_string

                name = all_columns_string if key == color_row_key else fm[key]['name']
                if name:
                    cols.append((name, key))

        self.color_layout.setVisible(False)
        self.icon_layout.setVisible(False)

        if self.coloring:
            self.color_layout.setVisible(True)
            for n1, k1 in cols:
                self.colored_field.addItem(n1 + (' (' + k1 + ')' if k1 != color_row_key else ''), k1)
            self.colored_field.setCurrentIndex(self.colored_field.findData(color_field))
        elif self.iconing or self.embleming:
            self.icon_layout.setVisible(True)
            self.icon_select_layout.setContentsMargins(0, 0, 0, 0)
            if self.embleming:
                self.icon_kind_label.setVisible(False)
                self.icon_kind.setVisible(False)
                self.icon_chooser_label.setVisible(False)
                self.icon_field.setVisible(False)

            for n1, k1 in cols:
                self.icon_field.addItem(f'{n1} ({k1})', k1)
            self.icon_file_names = []
            d = os.path.join(config_dir, icon_dir)
            if os.path.exists(d):
                for icon_file in os.listdir(d):
                    icon_file = icu_lower(icon_file)
                    if os.path.exists(os.path.join(d, icon_file)):
                        if icon_file.endswith('.png'):
                            self.icon_file_names.append(icon_file)
            self.icon_file_names.sort(key=sort_key)
            self.update_filename_box()

            if self.iconing:
                dex = 0
                from calibre.gui2.preferences.coloring import icon_rule_kinds

                for i, tup in enumerate(icon_rule_kinds):
                    txt, val = tup
                    self.icon_kind.addItem(txt, userData=(val))
                    if val == icon_rule_kind:
                        dex = i
                self.icon_kind.setCurrentIndex(dex)
                self.icon_field.setCurrentIndex(self.icon_field.findData(icon_field_key))

        self.setup_saved_template_editor(not dialog_is_st_editor, dialog_is_st_editor)

        self.all_functions = all_functions or formatter_functions().get_functions()
        self.builtins = builtin_functions or formatter_functions().get_builtins_and_aliases()

        # Set up the breakpoint bar
        run_as_you_type = gprefs.get('template_editor_run_as_you_type')
        self.run_as_you_type_box.setChecked(run_as_you_type)
        self.go_button.setEnabled(not run_as_you_type)
        self.break_box.setEnabled(not run_as_you_type)
        s = gprefs.get('template_editor_enable_breakpoints', False)
        self.remove_all_button.setEnabled(s)
        self.set_all_button.setEnabled(s)
        self.toggle_button.setEnabled(s)
        self.breakpoint_line_box.setEnabled(s)
        self.breakpoint_line_box_label.setEnabled(s)
        self.break_box.setChecked(s)
        self.break_box.stateChanged.connect(self.break_box_changed)
        self.go_button.clicked.connect(self.go_button_pressed)
        self.show_all_selected_books.clicked.connect(self.show_all_selected_books_changed)
        self.run_as_you_type_box.stateChanged.connect(self.run_as_you_type_box_changed)
        self.show_all_selected_books.setChecked(gprefs.get('template_editor_show_all_selected_books'))
        self.show_all_selected_books.clicked.connect(self.show_all_selected_books_changed)

        # Set up the display table
        self.table_column_widths = None
        try:
            self.table_column_widths = gprefs.get(self.geometry_string('template_editor_table_widths'), None)
        except Exception:
            pass
        self.set_mi(mi, fm)

        self.last_text = ''
        self.highlighting_gpm = True
        self.highlighter = TemplateHighlighter(self.textbox.document(), builtin_functions=self.builtins)
        self.textbox.cursorPositionChanged.connect(self.text_cursor_changed)
        self.textbox.textChanged.connect(self.textbox_changed)
        self.set_editor_font()

        self.doc_viewer = None
        self.current_function_name = None
        self.documentation.setReadOnly(True)
        self.documentation.setOpenLinks(False)
        self.documentation.anchorClicked.connect(self.url_clicked)
        self.source_code.setReadOnly(True)
        self.source_code.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.source_code.customContextMenuRequested.connect(self.show_code_context_menu)
        self.doc_button.clicked.connect(self.open_documentation_viewer)
        self.general_info_button.clicked.connect(self.open_general_info_dialog)

        if text is not None:
            if text_is_placeholder:
                self.textbox.setPlaceholderText(text)
                self.textbox.clear()
                text = ''
            else:
                self.textbox.setPlainText(text)
        else:
            text = ''
        self.original_text = text

        ok_btn = self.buttonBox.button(QDialogButtonBox.StandardButton.Ok)
        assert ok_btn is not None
        ok_btn.setText(_('&OK'))
        cancel_btn = self.buttonBox.button(QDialogButtonBox.StandardButton.Cancel)
        assert cancel_btn is not None
        cancel_btn.setText(_('&Cancel'))

        self.color_copy_button.clicked.connect(self.color_to_clipboard)
        self.filename_button.clicked.connect(self.filename_button_clicked)
        self.icon_copy_button.clicked.connect(self.icon_to_clipboard)

        try:
            with open(P('template-functions.json'), 'rb') as f:
                self.builtin_source_dict = json.load(f, encoding='utf-8')
        except Exception:
            self.builtin_source_dict = {}

        self.function_names = func_names = sorted(self.all_functions)
        self.function.clear()
        self.function.addItem('')
        for f in func_names:
            self.function.addItem(f'{f}  --  {self.function_type_string(f, longform=False)}', f)
        self.function.setCurrentIndex(0)
        self.function.currentIndexChanged.connect(self.function_changed)
        self.rule: tuple[str | None, str] | tuple[str | None, str, str] = (None, '')

        tt = _('Template language tutorial')
        self.template_tutorial.setText('<a href="{}">{}</a>'.format(localize_user_manual_link('https://manual.calibre-ebook.com/template_lang.html'), tt))
        tt = _('Template function reference')
        self.tf_ref.setText('<a href="{}">{}</a>'.format(localize_user_manual_link('https://manual.calibre-ebook.com/generated/en/template_ref.html'), tt))

        self.textbox.setFocus()
        self.set_up_font_boxes()
        self.toggle_button.clicked.connect(self.toggle_button_pressed)
        self.remove_all_button.clicked.connect(self.remove_all_button_pressed)
        self.set_all_button.clicked.connect(self.set_all_button_pressed)

        self.load_button.clicked.connect(self.load_template_from_file)
        self.save_button.clicked.connect(self.save_template)

        self.set_word_wrap(gprefs.get('gpm_template_editor_word_wrap_mode', True))
        self.textbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.textbox.customContextMenuRequested.connect(self.show_context_menu)
        # Now geometry
        self.restore_geometry(gprefs, self.geometry_string('template_editor_dialog_geometry'))

    def url_clicked(self, qurl):
        if qurl.scheme().startswith('http'):
            safe_open_url(qurl)
        elif qurl.scheme() == 'ffdoc':
            name = qurl.path()
            if name in self.function_names:
                dex = self.function_names.index(name)
                self.function.setCurrentIndex(dex + 1)

    def open_documentation_viewer(self):
        if self.doc_viewer is None:
            dv = self.doc_viewer = DocViewer(self.ffml, self.all_functions, self.function_type_string, parent=self)
            dv.finished.connect(self.doc_viewer_finished)
            dv.show()
        if self.current_function_name is not None:
            self.doc_viewer.show_function(self.current_function_name)
        else:
            self.doc_viewer.show_all_functions()

    def doc_viewer_finished(self):
        self.doc_viewer = None

    def open_general_info_dialog(self):
        GeneralInformationDialog(include_general_doc=True, include_ffml_doc=True).exec()

    def geometry_string(self, txt):
        if self.dialog_number is None or self.dialog_number == 0:
            return txt
        return txt + '_' + str(self.dialog_number)

    def setup_saved_template_editor(self, show_buttonbox, show_doc_and_name):
        self.buttonBox.setVisible(show_buttonbox)
        self.new_doc_label.setVisible(show_doc_and_name)
        self.new_doc.setVisible(show_doc_and_name)
        self.template_name_label.setVisible(show_doc_and_name)
        self.template_name.setVisible(show_doc_and_name)

    def set_mi(self, mi, fm):
        """
        This sets the metadata for the test result books table. It doesn't reset
        the contents of the field selectors for editing rules.
        """
        self.fm = fm
        if mi:
            if not isinstance(mi, (tuple, list)):
                mi = (mi,)
        else:
            mi = (get_model_metadata_instance(),)
        self.mi = mi
        self.setup_result_display_table()

    def setup_result_display_table(self):
        tv = self.template_value
        mi = self.mi
        row_count = len(mi) if gprefs.get('template_editor_show_all_selected_books') else 1
        tv.clear()
        tv.setColumnCount(3)
        tv.setHorizontalHeaderLabels((_('Book title'), '', _('Template value')))
        hh = tv.horizontalHeader()
        assert hh is not None
        hh.setStretchLastSection(True)
        hh.sectionResized.connect(self.table_column_resized)
        tv.setRowCount(len(mi))
        # Set the height of the table
        h = tv.rowHeight(0) * min(row_count, 5)
        h += 2 * tv.frameWidth() + hh.height()
        tv.setMinimumHeight(h)
        tv.setMaximumHeight(h)
        # Set the size of the title column
        if self.table_column_widths:
            tv.setColumnWidth(0, self.table_column_widths[0])
        else:
            tv.setColumnWidth(0, tv.fontMetrics().averageCharWidth() * 10)
        tv.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        tv.setRowCount(row_count)
        # Use our own widget to get rid of elision. setTextElideMode() doesn't work
        for r in range(row_count):
            w = QLineEdit(tv)
            w.setReadOnly(True)
            w.setText(mi[r].get('title', _('No title provided')))
            tv.setCellWidget(r, 0, w)
            tb = QToolButton()
            tb.setContentsMargins(0, 0, 0, 0)
            tb.setIcon(QIcon.ic('edit_input.png'))
            tb.setToolTip(_('Open Edit metadata on this book'))
            tb.clicked.connect(partial(self.metadata_button_clicked, r))
            tb.setEnabled(int(mi[r].get('id', -1)) >= 0)
            tv.setCellWidget(r, 1, tb)
            w = QLineEdit(tv)
            w.setReadOnly(True)
            tv.setCellWidget(r, 2, w)
        tv.resizeColumnToContents(1)
        self.set_waiting_message()

    def metadata_button_clicked(self, row):
        # Get the booklist row number for the book
        mi = self.mi[row]
        id_ = mi.get('id', -1)
        if id_ > 0:
            from calibre.gui2.ui import get_gui

            db = get_gui(fail_if_absent=True).current_db
            try:
                idx = db.data.id_to_index(id_)
                em = get_gui(fail_if_absent=True).iactions['Edit Metadata']
                from calibre.gui2.library.views import PreserveViewState

                with PreserveViewState(get_gui(fail_if_absent=True).current_view(), require_selected_ids=False):
                    with em.different_parent(self):
                        em.edit_metadata_for([idx], [id_], bulk=False)
            except Exception:
                pass
            new_mi = []
            for mi in self.mi:
                try:
                    pmi = db.new_api.get_proxy_metadata(mi.get('id'))
                except Exception:
                    pmi = None
                new_mi.append(pmi)
            self.set_mi(new_mi, self.fm)
            if not self.run_as_you_type_box.isChecked():
                self.display_values(str(self.textbox.toPlainText()))

    def set_waiting_message(self):
        if not self.run_as_you_type_box.isChecked():
            for i in range(self.template_value.rowCount()):
                cw = self.template_value.cellWidget(i, 2)
                assert isinstance(cw, QLineEdit)
                cw.setText('')
            cw0 = self.template_value.cellWidget(0, 2)
            assert isinstance(cw0, QLineEdit)
            cw0.setText(_("*** Waiting for the 'Go' button to be pressed"))

    def show_code_context_menu(self, point):
        m = self.source_code.createStandardContextMenu()
        assert m is not None
        name = self.current_function_name
        if name and self.all_functions[name].object_type in (
            StoredObjectType.StoredPythonTemplate,
            StoredObjectType.StoredGPMTemplate,
        ):
            m.addSeparator()
            ca = m.addAction(_('Copy stored template source to editor'))
            assert ca is not None
            ca.triggered.connect(self.copy_source_code_to_editor)
        m.exec(self.source_code.mapToGlobal(point))

    def copy_source_code_to_editor(self):
        if self.textbox.toPlainText():
            r = question_dialog(self, _('Discard existing text?'), _('The editor contains text. Do you want to overwrite that text?'))
            if not r:
                return
        self.textbox.setPlainText(self.source_code.toPlainText())

    def show_context_menu(self, point):
        m = self.textbox.createStandardContextMenu()
        assert m is not None
        m.addSeparator()
        word_wrapping = gprefs['gpm_template_editor_word_wrap_mode']
        if word_wrapping:
            ca = m.addAction(_('Disable word wrap'))
            assert ca is not None
            ca.setIcon(QIcon.ic('list_remove.png'))
        else:
            ca = m.addAction(_('Enable word wrap'))
            assert ca is not None
            ca.setIcon(QIcon.ic('ok.png'))
        ca.triggered.connect(partial(self.set_word_wrap, not word_wrapping))
        m.addSeparator()
        ca = m.addAction(_('Add Python template definition text'))
        assert ca is not None
        ca.triggered.connect(self.add_python_template_header_text)
        m.addSeparator()
        ca = m.addAction(_('Load template from the Template tester'))
        assert ca is not None
        m.addSeparator()
        ca.triggered.connect(self.load_last_template_text)
        ca = m.addAction(_('Load template from file'))
        assert ca is not None
        ca.setIcon(QIcon.ic('document_open.png'))
        ca.triggered.connect(self.load_template_from_file)
        ca = m.addAction(_('Save template to file'))
        assert ca is not None
        ca.setIcon(QIcon.ic('save.png'))
        ca.triggered.connect(self.save_template)
        m.exec(self.textbox.mapToGlobal(point))

    def add_python_template_header_text(self):
        self.textbox.setPlainText('''python:
def evaluate(book, context):
\t# book is a calibre metadata object
\t# context is an instance of calibre.utils.formatter.PythonTemplateContext,
\t# which currently contains the following attributes:
\t# db: a calibre legacy database object.
\t# globals: the template global variable dictionary.
\t# arguments: is a list of arguments if the template is called by a GPM template, otherwise None.
\t# funcs: used to call Built-in/User functions and Stored GPM/Python templates.
\t# Example: context.funcs.list_re_group()

\t# your Python code goes here
\treturn 'a string'
''')

    def set_word_wrap(self, to_what):
        gprefs['gpm_template_editor_word_wrap_mode'] = to_what
        self.textbox.setWordWrapMode(QTextOption.WrapMode.WordWrap if to_what else QTextOption.WrapMode.NoWrap)

    def load_last_template_text(self):
        from calibre.customize.ui import find_plugin

        tt = find_plugin('Template Tester')
        if tt and tt.actual_plugin_:
            self.textbox.setPlainText(tt.actual_plugin_.last_template_text())
        else:
            # I don't think we can get here, but just in case ...
            self.textbox.setPlainText(_('No Template tester text is available'))

    def load_template_from_file(self):
        filename = choose_files(
            self,
            'template_dialog_save_templates',
            _('Load template from file'),
            filters=[(_('Template file'), ['txt'])],
            select_only_single_file=True,
        )
        if filename:
            with open(filename[0]) as f:
                self.textbox.setPlainText(f.read())

    def save_template(self):
        filename = choose_save_file(self, 'template_dialog_save_templates', _('Save template to file'), filters=[(_('Template file'), ['txt'])])
        if filename:
            with open(filename, 'w') as f:
                f.write(str(self.textbox.toPlainText()))

    def get_current_font(self):
        font_name = gprefs.get('gpm_template_editor_font', None)
        size = gprefs['gpm_template_editor_font_size']
        if font_name is None:
            font = QFont()
            font.setFixedPitch(True)
            font.setPointSize(size)
        else:
            font = QFont(font_name, pointSize=size)
        return font

    def set_editor_font(self):
        font = self.get_current_font()
        fm = QFontMetrics(font)
        chars = tweaks['template_editor_tab_stop_width']
        w = fm.averageCharWidth() * chars
        self.textbox.setTabStopDistance(w)
        self.source_code.setTabStopDistance(w)
        self.textbox.setFont(font)
        self.highlighter.initialize_formats()
        self.highlighter.rehighlight()

    def set_up_font_boxes(self):
        font = self.get_current_font()
        self.font_box.setWritingSystem(QFontDatabase.WritingSystem.Latin)
        self.font_box.setCurrentFont(font)
        self.font_box.setEditable(False)
        gprefs['gpm_template_editor_font'] = str(font.family())
        self.font_size_box.setValue(font.pointSize())
        self.font_box.currentFontChanged.connect(self.font_changed)
        self.font_size_box.valueChanged.connect(self.font_size_changed)

    def font_changed(self, font):
        fi = QFontInfo(font)
        gprefs['gpm_template_editor_font'] = str(fi.family())
        self.set_editor_font()

    def font_size_changed(self, toWhat):
        gprefs['gpm_template_editor_font_size'] = toWhat
        self.set_editor_font()

    def run_as_you_type_box_changed(self, new_state):
        gprefs['template_editor_run_as_you_type'] = new_state != 0
        self.go_button.setEnabled(new_state == 0)
        if new_state == 0:
            self.set_waiting_message()
            self.break_box.setEnabled(True)
            enable_break_boxes = self.break_box.isChecked()
        else:
            self.break_box.setEnabled(False)
            enable_break_boxes = False
            self.display_values(str(self.textbox.toPlainText()))

        self.remove_all_button.setEnabled(enable_break_boxes)
        self.set_all_button.setEnabled(enable_break_boxes)
        self.toggle_button.setEnabled(enable_break_boxes)
        self.breakpoint_line_box.setEnabled(enable_break_boxes)
        self.breakpoint_line_box_label.setEnabled(enable_break_boxes)

    def break_box_changed(self, new_state):
        gprefs['template_editor_enable_breakpoints'] = new_state != 0
        self.remove_all_button.setEnabled(new_state != 0)
        self.set_all_button.setEnabled(new_state != 0)
        self.toggle_button.setEnabled(new_state != 0)
        self.breakpoint_line_box.setEnabled(new_state != 0)
        self.breakpoint_line_box_label.setEnabled(new_state != 0)
        if gprefs['template_editor_run_as_you_type']:
            self.display_values(str(self.textbox.toPlainText()))
        else:
            self.set_waiting_message()

    def show_all_selected_books_changed(self, new_state):
        gprefs['template_editor_show_all_selected_books'] = new_state != 0
        self.setup_result_display_table()
        if gprefs['template_editor_run_as_you_type']:
            self.display_values(str(self.textbox.toPlainText()))
        else:
            self.set_waiting_message()

    def go_button_pressed(self):
        self.display_values(str(self.textbox.toPlainText()))

    def remove_all_button_pressed(self):
        self.textbox.set_clicked_line_numbers(set())

    def set_all_button_pressed(self):
        self.textbox.set_clicked_line_numbers(set(range(1, self.textbox.blockCount() + 1)))

    def toggle_button_pressed(self):
        ln = self.breakpoint_line_box.value()
        if ln > self.textbox.blockCount():
            return
        cln = self.textbox.clicked_line_numbers
        if ln:
            if ln in self.textbox.clicked_line_numbers:
                cln.discard(ln)
            else:
                cln.add(ln)
            self.textbox.set_clicked_line_numbers(cln)

    def break_reporter(self, txt, val, locals_={}, line_number=0):
        sel_model = self.template_value.selectionModel()
        assert sel_model is not None
        l = sel_model.selectedRows()
        mi_to_use = self.mi[0 if len(l) == 0 else l[0].row()]
        if self.break_box.isChecked():
            if line_number is None or line_number not in self.textbox.clicked_line_numbers:
                return
            self.break_reporter_dialog = BreakReporter(self, mi_to_use, txt, val, locals_, line_number)
            if not self.break_reporter_dialog.exec():
                raise StopException()

    def filename_button_clicked(self):
        try:
            path = choose_files(
                self,
                'choose_category_icon',
                _('Select icon'),
                filters=[('Images', ['png', 'gif', 'jpg', 'jpeg'])],
                all_files=False,
                select_only_single_file=True,
            )
            if path:
                icon_path = path[0]
                icon_name = sanitize_file_name(os.path.splitext(os.path.basename(icon_path))[0] + '.png')
                if icon_name not in self.icon_file_names:
                    self.icon_file_names.append(icon_name)
                    self.update_filename_box()
                    try:
                        p = QIcon(icon_path).pixmap(QSize(128, 128))
                        d = os.path.join(config_dir, self.icon_dir)
                        if not os.path.exists(os.path.join(d, icon_name)):
                            if not os.path.exists(d):
                                os.makedirs(d)
                            with open(os.path.join(d, icon_name), 'wb') as f:
                                f.write(pixmap_to_data(p, format='PNG'))
                    except Exception:
                        traceback.print_exc()
                self.icon_files.setCurrentIndex(self.icon_files.findText(icon_name))
                self.icon_files.adjustSize()
        except Exception:
            traceback.print_exc()

    def update_filename_box(self):
        self.icon_files.clear()
        self.icon_file_names.sort(key=sort_key)
        self.icon_files.addItem('')
        self.icon_files.addItems(self.icon_file_names)
        for i, filename in enumerate(self.icon_file_names):
            icon = QIcon(os.path.join(config_dir, self.icon_dir, filename))
            self.icon_files.setItemIcon(i + 1, icon)

    def color_to_clipboard(self):
        app = qapplication_or_fail()
        c = app.clipboard()
        assert c is not None
        c.setText(str(self.color_name.color))

    def icon_to_clipboard(self):
        app = qapplication_or_fail()
        c = app.clipboard()
        assert c is not None
        c.setText(str(self.icon_files.currentText()))

    @property
    def is_python(self):
        return self.textbox.toPlainText().startswith('python:')

    def textbox_changed(self):
        cur_text = str(self.textbox.toPlainText())
        if self.is_python:
            if self.highlighting_gpm is True:
                self.highlighter.initialize_rules(self.builtins, True)
                self.highlighting_gpm = False
                self.break_box.setEnabled(True)
        elif not self.highlighting_gpm:
            self.highlighter.initialize_rules(self.builtins, False)
            self.highlighting_gpm = True
            self.break_box.setEnabled(True)
        if self.last_text != cur_text:
            self.last_text = cur_text
            self.highlighter.regenerate_paren_positions()
            self.text_cursor_changed()
            if self.run_as_you_type_box.isChecked():
                self.display_values(cur_text)
            else:
                self.set_waiting_message()

    def trace_lines(self, frame, event, arg):
        if event != 'line':
            return
        # Only respond to events in the "string" which is the template
        if frame.f_code.co_filename != '<string>':
            return
        # Check that there is a breakpoint at the line
        if frame.f_lineno not in self.textbox.clicked_line_numbers:
            return
        sel_model = self.template_value.selectionModel()
        assert sel_model is not None
        l = sel_model.selectedRows()
        mi_to_use = self.mi[0 if len(l) == 0 else l[0].row()]
        self.break_reporter_dialog = PythonBreakReporter(self, mi_to_use, frame)
        if not self.break_reporter_dialog.exec():
            raise StopException()

    def trace_calls(self, frame, event, arg):
        if event != 'call':
            return
        # If this is the "string" file (the template), return the trace_lines function
        if frame.f_code.co_filename == '<string>':
            return self.trace_lines
        return None

    def display_values(self, txt):
        tv = self.template_value
        sel_model = self.template_value.selectionModel()
        assert sel_model is not None
        l = sel_model.selectedRows()
        break_on_mi = 0 if len(l) == 0 else l[0].row()
        from calibre.gui2.ui import get_gui

        db = get_gui(fail_if_absent=True).current_db
        for r, mi in enumerate(self.mi):
            w = tv.cellWidget(r, 0)
            assert isinstance(w, QLineEdit)
            w.setText(mi.get('title', _('No title provided')))
            w.setCursorPosition(0)
            if self.break_box.isChecked() and r == break_on_mi and self.is_python:
                sys.settrace(self.trace_calls)
            else:
                sys.settrace(None)
            try:
                v = self.formatter().safe_format(
                    txt,
                    self.kwargs[r] if self.kwargs is not None else mi,
                    _('EXCEPTION:'),
                    mi,
                    global_vars=self.global_vars,
                    template_functions=self.all_functions,
                    break_reporter=self.break_reporter if r == break_on_mi else None,
                    python_context_object=self.python_context_object,
                    database=db,
                )
                w2 = tv.cellWidget(r, 2)
                assert isinstance(w2, QLineEdit)
                w2.setText(v.translate(translate_table))
                w2.setCursorPosition(0)
            finally:
                sys.settrace(None)
            if not gprefs.get('template_editor_show_all_selected_books', True):
                break

    def text_cursor_changed(self):
        cursor = self.textbox.textCursor()
        position = cursor.position()
        t = str(self.textbox.toPlainText())
        if position > 0 and position <= len(t):
            block_number = cursor.blockNumber()
            pos_in_block = cursor.positionInBlock() - 1
            self.highlighter.check_cursor_pos(t[position - 1], block_number, pos_in_block)

    def function_type_string(self, name, longform=True):
        if self.all_functions[name].object_type is StoredObjectType.PythonFunction:
            if name in self.builtins:
                return _('Built-in template function') if longform else _('Built-in function')
            return _('User defined Python template function') if longform else _('User function')
        elif self.all_functions[name].object_type is StoredObjectType.StoredPythonTemplate:
            return _('Stored user defined Python template') if longform else _('Stored template')
        return _('Stored user defined GPM template') if longform else _('Stored template')

    def function_changed(self, toWhat):
        self.current_function_name = name = str(self.function.itemData(toWhat))
        self.source_code.clear()
        self.documentation.clear()
        self.func_type.clear()
        if name in self.all_functions:
            doc = self.all_functions[name].doc
            self.documentation.setHtml(self.ffml.document_to_html(doc, name))
            if self.doc_viewer is not None:
                self.doc_viewer.show_function(name)
            if name in self.builtins and name in self.builtin_source_dict:
                self.source_code.setPlainText(self.builtin_source_dict[name])
            else:
                self.source_code.setPlainText(self.all_functions[name].program_text)
            self.func_type.setText(self.function_type_string(name, longform=True))

    def table_column_resized(self, col, old, new):
        self.table_column_widths = []
        for c in range(self.template_value.columnCount()):
            self.table_column_widths.append(self.template_value.columnWidth(c))

    def save_geometry(self, prefs=None, name=None):
        gprefs[self.geometry_string('template_editor_table_widths')] = self.table_column_widths
        super().save_geometry(gprefs, self.geometry_string('template_editor_dialog_geometry'))

    def keyPressEvent(self, a0):
        if a0.key() == Qt.Key.Key_Escape:
            # Check about ESC to avoid killing the dialog by mistake
            if self.textbox.toPlainText() != self.original_text:
                r = question_dialog(self, _('Discard changes?'), _('Do you really want to close this dialog, discarding any changes?'))
                if not r:
                    return
        QDialog.keyPressEvent(self, a0)

    def accept(self):
        txt = str(self.textbox.toPlainText()).rstrip()
        if (self.required_txt) and not txt:
            error_dialog(self, _('No template provided'), _('The template box cannot be empty'), show=True)
            return
        if self.coloring:
            if self.colored_field.currentIndex() == -1:
                error_dialog(self, _('No column chosen'), _('You must specify a column to be colored'), show=True)
                return
            self.rule = (str(self.colored_field.itemData(self.colored_field.currentIndex()) or ''), txt)
        elif self.iconing:
            if self.icon_field.currentIndex() == -1:
                error_dialog(self, _('No column chosen'), _('You must specify the column where the icons are applied'), show=True)
                return
            rt = str(self.icon_kind.itemData(self.icon_kind.currentIndex()) or '')
            self.rule = (rt, str(self.icon_field.itemData(self.icon_field.currentIndex()) or ''), txt)
        elif self.embleming:
            self.rule = ('icon', 'title', txt)
        else:
            self.rule = ('', txt)
        self.save_geometry()
        QDialog.accept(self)
        if self.dialog_number is not None:
            self.tester_closed.emit(txt, self.dialog_number)
        if self.doc_viewer is not None:
            self.doc_viewer.close()

    @property
    def template_rule_text(self) -> str:
        return self.rule[-1]

    def reject(self):
        self.save_geometry()
        QDialog.reject(self)
        if self.dialog_is_st_editor:
            parent = self.parent()
            while True:
                if parent is not None and hasattr(parent, 'reject'):
                    parent.reject()  # type: ignore
                    break
                assert parent is not None
                parent = parent.parent()
                if parent is None:
                    break
        if self.dialog_number is not None:
            self.tester_closed.emit(None, self.dialog_number)
        if self.doc_viewer is not None:
            self.doc_viewer.close()


class BreakReporterItem(QTableWidgetItem):
    def __init__(self, txt):
        super().__init__(txt.translate(translate_table) if txt else txt)
        self.setFlags(self.flags() & ~(Qt.ItemFlag.ItemIsEditable))


class BreakReporterBase(QDialog):
    def setup_ui(self, mi, line_number, locals_, leading_rows):
        self.mi = mi
        self.leading_rows = leading_rows
        self.setModal(True)
        l = QVBoxLayout(self)
        t = self.table = QTableWidget(self)
        t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        t.setColumnCount(2)
        t.setHorizontalHeaderLabels((_('Name'), _('Value')))
        t.setRowCount(leading_rows)
        l.addWidget(t)

        self.table_column_widths = None
        try:
            self.table_column_widths = gprefs.get('template_editor_break_table_widths', None)
            t.setColumnWidth(0, self.table_column_widths[0])
        except Exception:
            t.setColumnWidth(0, t.fontMetrics().averageCharWidth() * 20)
        hh = t.horizontalHeader()
        assert hh is not None
        hh.sectionResized.connect(self.table_column_resized)
        hh.setStretchLastSection(True)

        bb = QDialogButtonBox()
        b = bb.addButton(_('&Continue'), QDialogButtonBox.ButtonRole.AcceptRole)
        assert b is not None
        b.setIcon(QIcon.ic('sync-right.png'))
        b.setToolTip(_('Continue running the template'))
        b.setDefault(True)
        l.addWidget(bb)
        b = bb.addButton(_('&Stop'), QDialogButtonBox.ButtonRole.RejectRole)
        assert b is not None
        b.setIcon(QIcon.ic('list_remove.png'))
        b.setToolTip(_('Stop running the template'))
        l.addWidget(bb)
        bb.accepted.connect(self.accept)
        bb.rejected.connect(self.reject)
        self.setLayout(l)
        self.setWindowTitle(_('Break: line {0}, book {1}').format(line_number, self.mi.title))

        self.mi_combo = QComboBox()
        t.setCellWidget(leading_rows - 1, 0, self.mi_combo)
        self.mi_combo.addItems(self.get_field_keys())
        self.mi_combo.setToolTip('Choose a book metadata field to display')
        self.mi_combo.currentTextChanged.connect(self.get_field_value)
        self.mi_combo.setCurrentIndex(self.mi_combo.findText('title'))
        self.restore_geometry(gprefs, 'template_editor_break_geometry')
        self.setup_locals(locals_)

    def setup_locals(self, locals_):
        raise NotImplementedError

    def add_local_line(self, locals, row, key):
        itm = BreakReporterItem(key)
        itm.setToolTip(_('A variable in the template'))
        self.table.setItem(row, 0, itm)
        itm = BreakReporterItem(repr(locals[key]))
        itm.setToolTip(_('The value of the variable'))
        self.table.setItem(row, 1, itm)

    def get_field_value(self, field):
        val = self.displayable_field_value(self.mi, field)
        self.table.setItem(self.leading_rows - 1, 1, BreakReporterItem(val))

    def displayable_field_value(self, mi, field):
        raise NotImplementedError

    def table_column_resized(self, col, old, new):
        self.table_column_widths = []
        for c in range(self.table.columnCount()):
            self.table_column_widths.append(self.table.columnWidth(c))

    def get_field_keys(self):
        from calibre.gui2.ui import get_gui

        keys = set(get_gui(fail_if_absent=True).current_db.new_api.field_metadata.displayable_field_keys())
        keys.discard('sort')
        keys.discard('timestamp')
        keys.add('title_sort')
        keys.add('date')
        return sorted(keys)

    def save_geometry(self, prefs=None, name=None):
        super().save_geometry(gprefs, 'template_editor_break_geometry')
        gprefs['template_editor_break_table_widths'] = self.table_column_widths

    def reject(self):
        self.save_geometry()
        QDialog.reject(self)

    def accept(self):
        self.save_geometry()
        QDialog.accept(self)


class BreakReporter(BreakReporterBase):
    def __init__(self, parent, mi, op_label, op_value, locals_, line_number):
        super().__init__(parent)
        self.setup_ui(mi, line_number, locals_, leading_rows=2)
        self.table.setItem(0, 0, BreakReporterItem(op_label))
        item_0_0 = self.table.item(0, 0)
        assert item_0_0 is not None
        item_0_0.setToolTip(_('The name of the template language operation'))
        self.table.setItem(0, 1, BreakReporterItem(op_value))

    def setup_locals(self, locals_):
        local_names = sorted(locals_.keys())
        rows = len(local_names)
        self.table.setRowCount(rows + 2)
        for i, k in enumerate(local_names, start=2):
            self.add_local_line(locals_, i, k)

    def displayable_field_value(self, mi, field):
        return self.mi.format_field('timestamp' if field == 'date' else field)[1]


class PythonBreakReporter(BreakReporterBase):
    def __init__(self, parent, mi, frame):
        super().__init__(parent)
        self.frame = frame
        line_number = frame.f_lineno
        locals = frame.f_locals
        self.setup_ui(mi, line_number, locals, leading_rows=1)

    def setup_locals(self, locals_):
        locals_ = self.frame.f_locals
        local_names = sorted(k for k in locals_.keys() if k not in ('book', 'context'))
        rows = len(local_names)
        self.table.setRowCount(rows + 1)

        for i, k in enumerate(local_names, start=1):
            if k in ('book', 'context'):
                continue
            self.add_local_line(locals_, i, k)

    def displayable_field_value(self, mi, field):
        return repr(self.mi.get('timestamp' if field == 'date' else field))


class EmbeddedTemplateDialog(TemplateDialog):
    def __init__(self, parent):
        TemplateDialog.__init__(self, parent, _('A General Program Mode Template'), text_is_placeholder=True, dialog_is_st_editor=True)
        self.setParent(parent)
        self.setWindowFlags(Qt.WindowType.Widget)


if __name__ == '__main__':
    from calibre.gui2 import Application

    app = Application([])
    from calibre.ebooks.metadata.book.base import field_metadata

    d = TemplateDialog(None, '{title}', fm=field_metadata)
    d.exec()
    del app
