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

import errno
import json
import numbers
import os
import sys
import textwrap
import time

from qt.core import (
    QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout,
    QFrame, QHBoxLayout, QIcon, QLabel, QLineEdit, QListWidget, QPlainTextEdit, QLayout,
    QPushButton, QScrollArea, QSize, QSizePolicy, QSpinBox, Qt, QTabWidget, QTimer,
    QToolButton, QUrl, QVBoxLayout, QWidget, pyqtSignal, sip
)

from calibre import as_unicode
from calibre.constants import isportable, iswindows
from calibre.gui2 import (
    choose_files, choose_save_file, config, error_dialog, gprefs, info_dialog,
    open_url, warning_dialog
)
from calibre.gui2.preferences import AbortCommit, ConfigWidgetBase, test_widget
from calibre.gui2.widgets import HistoryLineEdit
from calibre.srv.code import custom_list_template as default_custom_list_template
from calibre.srv.embedded import custom_list_template, search_the_net_urls
from calibre.srv.loop import parse_trusted_ips
from calibre.srv.library_broker import load_gui_libraries
from calibre.srv.opts import change_settings, options, server_config
from calibre.srv.users import (
    UserManager, create_user_data, validate_password, validate_username
)
from calibre.utils.icu import primary_sort_key
from calibre.utils.shared_file import share_open
from polyglot.builtins import as_bytes


if iswindows and not isportable:
    from calibre_extensions import winutil

    def get_exe():
        exe_base = os.path.abspath(os.path.dirname(sys.executable))
        exe = os.path.join(exe_base, 'calibre.exe')
        if isinstance(exe, bytes):
            exe = os.fsdecode(exe)
        return exe

    def startup_shortcut_path():
        startup_path = winutil.special_folder_path(winutil.CSIDL_STARTUP)
        return os.path.join(startup_path, "calibre.lnk")

    def create_shortcut(shortcut_path, target, description, *args):
        quoted_args = None
        if args:
            quoted_args = []
            for arg in args:
                quoted_args.append(f'"{arg}"')
            quoted_args = ' '.join(quoted_args)
        winutil.manage_shortcut(shortcut_path, target, description, quoted_args)

    def shortcut_exists_at(shortcut_path, target):
        if not os.access(shortcut_path, os.R_OK):
            return False
        name = winutil.manage_shortcut(shortcut_path, None, None, None)
        if name is None:
            return False
        return os.path.normcase(os.path.abspath(name)) == os.path.normcase(os.path.abspath(target))

    def set_run_at_startup(run_at_startup=True):
        if run_at_startup:
            create_shortcut(startup_shortcut_path(), get_exe(), 'calibre - E-book management', '--start-in-tray')
        else:
            shortcut_path = startup_shortcut_path()
            if os.path.exists(shortcut_path):
                os.remove(shortcut_path)

    def is_set_to_run_at_startup():
        try:
            return shortcut_exists_at(startup_shortcut_path(), get_exe())
        except Exception:
            import traceback
            traceback.print_exc()

else:
    set_run_at_startup = is_set_to_run_at_startup = None


# Advanced {{{


def init_opt(widget, opt, layout):
    widget.name, widget.default_val = opt.name, opt.default
    if opt.longdoc:
        widget.setWhatsThis(opt.longdoc)
        widget.setStatusTip(opt.longdoc)
        widget.setToolTip(textwrap.fill(opt.longdoc))
    layout.addRow(opt.shortdoc + ':', widget)


class Bool(QCheckBox):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        opt = options[name]
        QCheckBox.__init__(self)
        self.stateChanged.connect(self.changed_signal.emit)
        init_opt(self, opt, layout)

    def get(self):
        return self.isChecked()

    def set(self, val):
        self.setChecked(bool(val))


class Int(QSpinBox):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        QSpinBox.__init__(self)
        self.setRange(0, 20000)
        opt = options[name]
        self.valueChanged.connect(self.changed_signal.emit)
        init_opt(self, opt, layout)

    def get(self):
        return self.value()

    def set(self, val):
        self.setValue(int(val))


class Float(QDoubleSpinBox):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        QDoubleSpinBox.__init__(self)
        self.setRange(0, 20000)
        self.setDecimals(1)
        opt = options[name]
        self.valueChanged.connect(self.changed_signal.emit)
        init_opt(self, opt, layout)

    def get(self):
        return self.value()

    def set(self, val):
        self.setValue(float(val))


class Text(QLineEdit):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        QLineEdit.__init__(self)
        self.setClearButtonEnabled(True)
        opt = options[name]
        self.textChanged.connect(self.changed_signal.emit)
        init_opt(self, opt, layout)

    def get(self):
        return self.text().strip() or None

    def set(self, val):
        self.setText(str(val or ''))


class Path(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        QWidget.__init__(self)
        self.dname = name
        opt = options[name]
        self.l = l = QHBoxLayout(self)
        l.setContentsMargins(0, 0, 0, 0)
        self.text = t = HistoryLineEdit(self)
        t.initialize(f'server-opts-{name}')
        t.setClearButtonEnabled(True)
        t.currentTextChanged.connect(self.changed_signal.emit)
        l.addWidget(t)

        self.b = b = QToolButton(self)
        l.addWidget(b)
        b.setIcon(QIcon(I('document_open.png')))
        b.setToolTip(_("Browse for the file"))
        b.clicked.connect(self.choose)
        init_opt(self, opt, layout)

    def get(self):
        return self.text.text().strip() or None

    def set(self, val):
        self.text.setText(str(val or ''))

    def choose(self):
        ans = choose_files(self, 'choose_path_srv_opts_' + self.dname, _('Choose a file'), select_only_single_file=True)
        if ans:
            self.set(ans[0])
            self.text.save_history()


class Choices(QComboBox):

    changed_signal = pyqtSignal()

    def __init__(self, name, layout):
        QComboBox.__init__(self)
        self.setEditable(False)
        opt = options[name]
        self.choices = opt.choices
        self.addItems(opt.choices)
        self.currentIndexChanged.connect(self.changed_signal.emit)
        init_opt(self, opt, layout)

    def get(self):
        return self.currentText()

    def set(self, val):
        if val in self.choices:
            self.setCurrentText(val)
        else:
            self.setCurrentIndex(0)


class AdvancedTab(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QFormLayout(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
        self.widgets = []
        self.widget_map = {}
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
        for name in sorted(options, key=lambda n: options[n].shortdoc.lower()):
            if name in ('auth', 'port', 'allow_socket_preallocation', 'userdb'):
                continue
            opt = options[name]
            if opt.choices:
                w = Choices
            elif isinstance(opt.default, bool):
                w = Bool
            elif isinstance(opt.default, numbers.Integral):
                w = Int
            elif isinstance(opt.default, numbers.Real):
                w = Float
            else:
                w = Text
                if name in ('ssl_certfile', 'ssl_keyfile'):
                    w = Path
            w = w(name, l)
            setattr(self, 'opt_' + name, w)
            self.widgets.append(w)
            self.widget_map[name] = w

    def genesis(self):
        opts = server_config()
        for w in self.widgets:
            w.set(getattr(opts, w.name))
            w.changed_signal.connect(self.changed_signal.emit)

    def restore_defaults(self):
        for w in self.widgets:
            w.set(w.default_val)

    def get(self, name):
        return self.widget_map[name].get()

    @property
    def settings(self):
        return {w.name: w.get() for w in self.widgets}

    @property
    def has_ssl(self):
        return bool(self.get('ssl_certfile')) and bool(self.get('ssl_keyfile'))

# }}}


class MainTab(QWidget):  # {{{

    changed_signal = pyqtSignal()
    start_server = pyqtSignal()
    stop_server = pyqtSignal()
    test_server = pyqtSignal()
    show_logs = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QVBoxLayout(self)
        self.la = la = QLabel(
            _(
                'calibre contains an internet server that allows you to'
                ' access your book collection using a browser from anywhere'
                ' in the world. Any changes to the settings will only take'
                ' effect after a server restart.'
            )
        )
        la.setWordWrap(True)
        l.addWidget(la)
        l.addSpacing(10)
        self.fl = fl = QFormLayout()
        l.addLayout(fl)
        self.opt_port = sb = QSpinBox(self)
        if options['port'].longdoc:
            sb.setToolTip(options['port'].longdoc)
        sb.setRange(1, 65535)
        sb.valueChanged.connect(self.changed_signal.emit)
        fl.addRow(options['port'].shortdoc + ':', sb)
        l.addSpacing(25)
        self.opt_auth = cb = QCheckBox(
            _('Require &username and password to access the Content server')
        )
        l.addWidget(cb)
        self.auth_desc = la = QLabel(self)
        la.setStyleSheet('QLabel { font-size: small; font-style: italic }')
        la.setWordWrap(True)
        l.addWidget(la)
        l.addSpacing(25)
        self.opt_autolaunch_server = al = QCheckBox(
            _('Run server &automatically when calibre starts')
        )
        l.addWidget(al)
        l.addSpacing(25)
        self.h = h = QHBoxLayout()
        l.addLayout(h)
        for text, name in [(_('&Start server'),
                            'start_server'), (_('St&op server'), 'stop_server'),
                           (_('&Test server'),
                            'test_server'), (_('Show server &logs'), 'show_logs')]:
            b = QPushButton(text)
            b.clicked.connect(getattr(self, name).emit)
            setattr(self, name + '_button', b)
            if name == 'show_logs':
                h.addStretch(10)
            h.addWidget(b)
        self.ip_info = QLabel(self)
        self.update_ip_info()
        from calibre.gui2.ui import get_gui
        gui = get_gui()
        if gui is not None:
            gui.iactions['Connect Share'].share_conn_menu.server_state_changed_signal.connect(self.update_ip_info)
        l.addSpacing(10)
        l.addWidget(self.ip_info)
        if set_run_at_startup is not None:
            self.run_at_start_button = b = QPushButton('', self)
            self.set_run_at_start_text()
            b.clicked.connect(self.toggle_run_at_startup)
            l.addSpacing(10)
            l.addWidget(b)
        l.addSpacing(10)

        l.addStretch(10)

    def set_run_at_start_text(self):
        is_autostarted = is_set_to_run_at_startup()
        self.run_at_start_button.setText(
            _('Do not start calibre automatically when computer is started') if is_autostarted else
            _('Start calibre when the computer is started')
        )
        self.run_at_start_button.setToolTip('<p>' + (
            _('''Currently calibre is set to run automatically when the
            computer starts.  Use this button to disable that.''') if is_autostarted else
            _('''Start calibre in the system tray automatically when the computer starts''')))

    def toggle_run_at_startup(self):
        set_run_at_startup(not is_set_to_run_at_startup())
        self.set_run_at_start_text()

    def update_ip_info(self):
        from calibre.gui2.ui import get_gui
        gui = get_gui()
        if gui is not None:
            t = get_gui().iactions['Connect Share'].share_conn_menu.ip_text
            t = t.strip().strip('[]')
            self.ip_info.setText(_('Content server listening at: %s') % t)

    def genesis(self):
        opts = server_config()
        self.opt_auth.setChecked(opts.auth)
        self.opt_auth.stateChanged.connect(self.auth_changed)
        self.opt_port.setValue(opts.port)
        self.change_auth_desc()
        self.update_button_state()

    def change_auth_desc(self):
        self.auth_desc.setText(
            _('Remember to create at least one user account in the "User accounts" tab')
            if self.opt_auth.isChecked() else _(
                'Requiring a username/password prevents unauthorized people from'
                ' accessing your calibre library. It is also needed for some features'
                ' such as making any changes to the library as well as'
                ' last read position/annotation syncing.'
            )
        )

    def auth_changed(self):
        self.changed_signal.emit()
        self.change_auth_desc()

    def restore_defaults(self):
        self.opt_auth.setChecked(options['auth'].default)
        self.opt_port.setValue(options['port'].default)

    def update_button_state(self):
        from calibre.gui2.ui import get_gui
        gui = get_gui()
        if gui is not None:
            is_running = gui.content_server is not None and gui.content_server.is_running
            self.ip_info.setVisible(is_running)
            self.update_ip_info()
            self.start_server_button.setEnabled(not is_running)
            self.stop_server_button.setEnabled(is_running)
            self.test_server_button.setEnabled(is_running)

    @property
    def settings(self):
        return {'auth': self.opt_auth.isChecked(), 'port': self.opt_port.value()}


# }}}

# Users {{{


class NewUser(QDialog):

    def __init__(self, user_data, parent=None, username=None):
        QDialog.__init__(self, parent)
        self.user_data = user_data
        self.setWindowTitle(
            _('Change password for {}').format(username)
            if username else _('Add new user')
        )
        self.l = l = QFormLayout(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
        self.uw = u = QLineEdit(self)
        l.addRow(_('&Username:'), u)
        if username:
            u.setText(username)
            u.setReadOnly(True)
        l.addRow(QLabel(_('Set the password for this user')))
        self.p1, self.p2 = p1, p2 = QLineEdit(self), QLineEdit(self)
        l.addRow(_('&Password:'), p1), l.addRow(_('&Repeat password:'), p2)
        for p in p1, p2:
            p.setEchoMode(QLineEdit.EchoMode.PasswordEchoOnEdit)
            p.setMinimumWidth(300)
            if username:
                p.setText(user_data[username]['pw'])
        self.showp = sp = QCheckBox(_('&Show password'))
        sp.stateChanged.connect(self.show_password)
        l.addRow(sp)
        self.bb = bb = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        l.addRow(bb)
        bb.accepted.connect(self.accept), bb.rejected.connect(self.reject)
        (self.uw if not username else self.p1).setFocus(Qt.FocusReason.OtherFocusReason)

    def show_password(self):
        for p in self.p1, self.p2:
            p.setEchoMode(
                QLineEdit.EchoMode.Normal
                if self.showp.isChecked() else QLineEdit.EchoMode.PasswordEchoOnEdit
            )

    @property
    def username(self):
        return self.uw.text().strip()

    @property
    def password(self):
        return self.p1.text()

    def accept(self):
        if not self.uw.isReadOnly():
            un = self.username
            if not un:
                return error_dialog(
                    self,
                    _('Empty username'),
                    _('You must enter a username'),
                    show=True
                )
            if un in self.user_data:
                return error_dialog(
                    self,
                    _('Username already exists'),
                    _(
                        'A user with the username {} already exists. Please choose a different username.'
                    ).format(un),
                    show=True
                )
            err = validate_username(un)
            if err:
                return error_dialog(self, _('Username is not valid'), err, show=True)
        p1, p2 = self.password, self.p2.text()
        if p1 != p2:
            return error_dialog(
                self,
                _('Password do not match'),
                _('The two passwords you entered do not match!'),
                show=True
            )
        if not p1:
            return error_dialog(
                self,
                _('Empty password'),
                _('You must enter a password for this user'),
                show=True
            )
        err = validate_password(p1)
        if err:
            return error_dialog(self, _('Invalid password'), err, show=True)
        return QDialog.accept(self)


class Library(QWidget):

    restriction_changed = pyqtSignal(object, object)

    def __init__(self, name, is_checked=False, path='', restriction='', parent=None, is_first=False, enable_on_checked=True):
        QWidget.__init__(self, parent)
        self.name = name
        self.enable_on_checked = enable_on_checked
        self.l = l = QVBoxLayout(self)
        l.setSizeConstraint(QLayout.SizeConstraint.SetMinAndMaxSize)
        if not is_first:
            self.border = b = QFrame(self)
            b.setFrameStyle(QFrame.Shape.HLine)
            l.addWidget(b)
        self.cw = cw = QCheckBox(name.replace('&', '&&'))
        cw.setStyleSheet('QCheckBox { font-weight: bold }')
        cw.setChecked(is_checked)
        cw.stateChanged.connect(self.state_changed)
        if path:
            cw.setToolTip(path)
        l.addWidget(cw)
        self.la = la = QLabel(_('Further &restrict access to books in this library that match:'))
        l.addWidget(la)
        self.rw = rw = QLineEdit(self)
        rw.setPlaceholderText(_('A search expression'))
        rw.setToolTip(textwrap.fill(_(
            'A search expression. If specified, access will be further restricted'
            ' to only those books that match this expression. For example:'
            ' tags:"=Share"')))
        rw.setText(restriction or '')
        rw.textChanged.connect(self.on_rchange)
        la.setBuddy(rw)
        l.addWidget(rw)
        self.state_changed()

    def state_changed(self):
        c = self.cw.isChecked()
        w = (self.enable_on_checked and c) or (not self.enable_on_checked and not c)
        for x in (self.la, self.rw):
            x.setEnabled(bool(w))

    def on_rchange(self):
        self.restriction_changed.emit(self.name, self.restriction)

    @property
    def is_checked(self):
        return self.cw.isChecked()

    @property
    def restriction(self):
        return self.rw.text().strip()


class ChangeRestriction(QDialog):

    def __init__(self, username, restriction, parent=None):
        QDialog.__init__(self, parent)
        self.setWindowTitle(_('Change library access permissions for {}').format(username))
        self.username = username
        self._items = []
        self.l = l = QFormLayout(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)

        self.libraries = t = QWidget(self)
        t.setObjectName('libraries')
        t.l = QVBoxLayout(self.libraries)
        self.atype = a = QComboBox(self)
        a.addItems([_('All libraries'), _('Only the specified libraries'), _('All except the specified libraries')])
        self.library_restrictions = restriction['library_restrictions'].copy()
        if restriction['allowed_library_names']:
            a.setCurrentIndex(1)
            self.items = restriction['allowed_library_names']
        elif restriction['blocked_library_names']:
            a.setCurrentIndex(2)
            self.items = restriction['blocked_library_names']
        else:
            a.setCurrentIndex(0)
        a.currentIndexChanged.connect(self.atype_changed)
        l.addRow(_('Allow access to:'), a)

        self.msg = la = QLabel(self)
        la.setWordWrap(True)
        l.addRow(la)
        self.la = la = QLabel(_('Specify the libraries below:'))
        la.setWordWrap(True)
        self.sa = sa = QScrollArea(self)
        sa.setWidget(t), sa.setWidgetResizable(True)
        l.addRow(la), l.addRow(sa)
        self.atype_changed()

        self.bb = bb = QDialogButtonBox(
            QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
        )
        bb.accepted.connect(self.accept), bb.rejected.connect(self.reject)
        l.addWidget(bb)
        self.items = self.items

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

    def __iter__(self):
        return iter(self._items)

    @property
    def items(self):
        return frozenset(item.name for item in self if item.is_checked)

    def clear(self):
        for c in self:
            self.libraries.l.removeWidget(c)
            c.setParent(None)
            c.restriction_changed.disconnect()
            sip.delete(c)
        self._items = []

    @items.setter
    def items(self, val):
        self.clear()
        checked_libraries = frozenset(val)
        library_paths = load_gui_libraries(gprefs)
        gui_libraries = {os.path.basename(l):l for l in library_paths}
        lchecked_libraries = {l.lower() for l in checked_libraries}
        seen = set()
        items = []
        for x in checked_libraries | set(gui_libraries):
            xl = x.lower()
            if xl not in seen:
                seen.add(xl)
                items.append((x, xl in lchecked_libraries))
        items.sort(key=lambda x: primary_sort_key(x[0]))
        enable_on_checked = self.atype.currentIndex() == 1
        for i, (l, checked) in enumerate(items):
            l = Library(
                l, checked, path=gui_libraries.get(l, ''),
                restriction=self.library_restrictions.get(l.lower(), ''),
                parent=self.libraries, is_first=i == 0,
                enable_on_checked=enable_on_checked
            )
            l.restriction_changed.connect(self.restriction_changed)
            self.libraries.l.addWidget(l)
            self._items.append(l)

    def restriction_changed(self, name, val):
        name = name.lower()
        self.library_restrictions[name] = val

    @property
    def restriction(self):
        ans = {'allowed_library_names': frozenset(), 'blocked_library_names': frozenset(), 'library_restrictions': {}}
        if self.atype.currentIndex() != 0:
            k = ['allowed_library_names', 'blocked_library_names'][self.atype.currentIndex() - 1]
            ans[k] = self.items
            ans['library_restrictions'] = self.library_restrictions
        return ans

    def accept(self):
        if self.atype.currentIndex() != 0 and not self.items:
            return error_dialog(self, _('No libraries specified'), _(
                'You have not specified any libraries'), show=True)
        return QDialog.accept(self)

    def atype_changed(self):
        ci = self.atype.currentIndex()
        sheet = ''
        if ci == 0:
            m = _('<b>{} is allowed access to all libraries')
            self.libraries.setEnabled(False), self.la.setEnabled(False)
        else:
            if ci == 1:
                m = _('{} is allowed access only to the libraries whose names'
                      ' <b>match</b> one of the names specified below.')
            else:
                m = _('{} is allowed access to all libraries, <b>except</b> those'
                      ' whose names match one of the names specified below.')
                sheet += 'QWidget#libraries { background-color: #FAE7B5}'
            self.libraries.setEnabled(True), self.la.setEnabled(True)
            self.items = self.items
        self.msg.setText(m.format(self.username))
        self.libraries.setStyleSheet(sheet)


class User(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
        self.l = l = QFormLayout(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
        self.username_label = la = QLabel('')
        l.addWidget(la)
        self.ro_text = _('Allow {} to make &changes (i.e. grant write access)')
        self.rw = rw = QCheckBox(self)
        rw.setToolTip(
            _(
                'If enabled, allows the user to make changes to the library.'
                ' Adding books/deleting books/editing metadata, etc.'
            )
        )
        rw.stateChanged.connect(self.readonly_changed)
        l.addWidget(rw)
        self.access_label = la = QLabel(self)
        l.addWidget(la), la.setWordWrap(True)
        self.cpb = b = QPushButton(_('Change &password'))
        l.addWidget(b)
        b.clicked.connect(self.change_password)
        self.restrict_button = b = QPushButton(self)
        b.clicked.connect(self.change_restriction)
        l.addWidget(b)

        self.show_user()

    def change_password(self):
        d = NewUser(self.user_data, self, self.username)
        if d.exec() == QDialog.DialogCode.Accepted:
            self.user_data[self.username]['pw'] = d.password
            self.changed_signal.emit()

    def readonly_changed(self):
        self.user_data[self.username]['readonly'] = not self.rw.isChecked()
        self.changed_signal.emit()

    def update_restriction(self):
        username, user_data = self.username, self.user_data
        r = user_data[username]['restriction']
        if r['allowed_library_names']:
            libs = r['allowed_library_names']
            m = ngettext(
                '{} is currently only allowed to access the library named: {}',
                '{} is currently only allowed to access the libraries named: {}',
                len(libs)
            ).format(username, ', '.join(libs))
            b = _('Change the allowed libraries')
        elif r['blocked_library_names']:
            libs = r['blocked_library_names']
            m = ngettext(
                '{} is currently not allowed to access the library named: {}',
                '{} is currently not allowed to access the libraries named: {}',
                len(libs)
            ).format(username, ', '.join(libs))
            b = _('Change the blocked libraries')
        else:
            m = _('{} is currently allowed access to all libraries')
            b = _('Restrict the &libraries {} can access').format(self.username)
        self.restrict_button.setText(b),
        self.access_label.setText(m.format(username))

    def show_user(self, username=None, user_data=None):
        self.username, self.user_data = username, user_data
        self.cpb.setVisible(username is not None)
        self.username_label.setText(('<h2>' + username) if username else '')
        if username:
            self.rw.setText(self.ro_text.format(username))
            self.rw.setVisible(True)
            self.rw.blockSignals(True), self.rw.setChecked(
                not user_data[username]['readonly']
            ), self.rw.blockSignals(False)
            self.access_label.setVisible(True)
            self.restrict_button.setVisible(True)
            self.update_restriction()
        else:
            self.rw.setVisible(False)
            self.access_label.setVisible(False)
            self.restrict_button.setVisible(False)

    def change_restriction(self):
        d = ChangeRestriction(
            self.username,
            self.user_data[self.username]['restriction'].copy(),
            parent=self
        )
        if d.exec() == QDialog.DialogCode.Accepted:
            self.user_data[self.username]['restriction'] = d.restriction
            self.update_restriction()
            self.changed_signal.emit()

    def sizeHint(self):
        ans = QWidget.sizeHint(self)
        ans.setWidth(400)
        return ans


class Users(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QHBoxLayout(self)
        self.lp = lp = QVBoxLayout()
        l.addLayout(lp)

        self.h = h = QHBoxLayout()
        lp.addLayout(h)
        self.add_button = b = QPushButton(QIcon(I('plus.png')), _('&Add user'), self)
        b.clicked.connect(self.add_user)
        h.addWidget(b)
        self.remove_button = b = QPushButton(
            QIcon(I('minus.png')), _('&Remove user'), self
        )
        b.clicked.connect(self.remove_user)
        h.addStretch(2), h.addWidget(b)

        self.user_list = w = QListWidget(self)
        w.setSpacing(1)
        w.doubleClicked.connect(self.current_user_activated)
        w.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
        lp.addWidget(w)

        self.user_display = u = User(self)
        u.changed_signal.connect(self.changed_signal.emit)
        l.addWidget(u)

    def genesis(self):
        self.user_data = UserManager().user_data
        self.user_list.addItems(sorted(self.user_data, key=primary_sort_key))
        self.user_list.setCurrentRow(0)
        self.user_list.currentItemChanged.connect(self.current_item_changed)
        self.current_item_changed()

    def current_user_activated(self):
        self.user_display.change_password()

    def current_item_changed(self):
        item = self.user_list.currentItem()
        if item is None:
            username = None
        else:
            username = item.text()
        if username not in self.user_data:
            username = None
        self.display_user_data(username)

    def add_user(self):
        d = NewUser(self.user_data, parent=self)
        if d.exec() == QDialog.DialogCode.Accepted:
            un, pw = d.username, d.password
            self.user_data[un] = create_user_data(pw)
            self.user_list.insertItem(0, un)
            self.user_list.setCurrentRow(0)
            self.display_user_data(un)
            self.changed_signal.emit()

    def remove_user(self):
        u = self.user_list.currentItem()
        if u is not None:
            self.user_list.takeItem(self.user_list.row(u))
            un = u.text()
            self.user_data.pop(un, None)
            self.changed_signal.emit()
            self.current_item_changed()

    def display_user_data(self, username=None):
        self.user_display.show_user(username, self.user_data)


# }}}


class CustomList(QWidget):  # {{{

    changed_signal = pyqtSignal()

    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.default_template = default_custom_list_template()
        self.l = l = QFormLayout(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
        self.la = la = QLabel('<p>' + _(
            'Here you can create a template to control what data is shown when'
            ' using the <i>Custom list</i> mode for the book list'))
        la.setWordWrap(True)
        l.addRow(la)
        self.thumbnail = t = QCheckBox(_('Show a cover &thumbnail'))
        self.thumbnail_height = th = QSpinBox(self)
        th.setSuffix(' px'), th.setRange(60, 600)
        self.entry_height = eh = QLineEdit(self)
        l.addRow(t), l.addRow(_('Thumbnail &height:'), th)
        l.addRow(_('Entry &height:'), eh)
        t.stateChanged.connect(self.changed_signal)
        th.valueChanged.connect(self.changed_signal)
        eh.textChanged.connect(self.changed_signal)
        eh.setToolTip(textwrap.fill(_(
            'The height for each entry. The special value "auto" causes a height to be calculated'
            ' based on the number of lines in the template. Otherwise, use a CSS length, such as'
            ' 100px or 15ex')))
        t.stateChanged.connect(self.thumbnail_state_changed)
        th.setVisible(False)

        self.comments_fields = cf = QLineEdit(self)
        l.addRow(_('&Long text fields:'), cf)
        cf.setToolTip(textwrap.fill(_(
            'A comma separated list of fields that will be added at the bottom of every entry.'
            ' These fields are interpreted as containing HTML, not plain text.')))
        cf.textChanged.connect(self.changed_signal)

        self.la1 = la = QLabel('<p>' + _(
            'The template below will be interpreted as HTML and all {{fields}} will be replaced'
            ' by the actual metadata, if available. For custom columns use the column lookup'
            ' name, for example: #mytags. You can use {0} as a separator'
            ' to split a line into multiple columns.').format('|||'))
        la.setWordWrap(True)
        l.addRow(la)
        self.template = t = QPlainTextEdit(self)
        l.addRow(t)
        t.textChanged.connect(self.changed_signal)
        self.imex = bb = QDialogButtonBox(self)
        b = bb.addButton(_('&Import template'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.import_template)
        b = bb.addButton(_('E&xport template'), QDialogButtonBox.ButtonRole.ActionRole)
        b.clicked.connect(self.export_template)
        l.addRow(bb)

    def import_template(self):
        paths = choose_files(self, 'custom-list-template', _('Choose template file'),
            filters=[(_('Template files'), ['json'])], all_files=False, select_only_single_file=True)
        if paths:
            with lopen(paths[0], 'rb') as f:
                raw = f.read()
            self.current_template = self.deserialize(raw)

    def export_template(self):
        path = choose_save_file(
            self, 'custom-list-template', _('Choose template file'),
            filters=[(_('Template files'), ['json'])], initial_filename='custom-list-template.json')
        if path:
            raw = self.serialize(self.current_template)
            with lopen(path, 'wb') as f:
                f.write(as_bytes(raw))

    def thumbnail_state_changed(self):
        is_enabled = bool(self.thumbnail.isChecked())
        for w, x in [(self.thumbnail_height, True), (self.entry_height, False)]:
            w.setVisible(is_enabled is x)
            self.layout().labelForField(w).setVisible(is_enabled is x)

    def genesis(self):
        self.current_template = custom_list_template() or self.default_template

    @property
    def current_template(self):
        return {
            'thumbnail': self.thumbnail.isChecked(),
            'thumbnail_height': self.thumbnail_height.value(),
            'height': self.entry_height.text().strip() or 'auto',
            'comments_fields': [x.strip() for x in self.comments_fields.text().split(',') if x.strip()],
            'lines': [x.strip() for x in self.template.toPlainText().splitlines()]
        }

    @current_template.setter
    def current_template(self, template):
        self.thumbnail.setChecked(bool(template.get('thumbnail')))
        try:
            th = int(template['thumbnail_height'])
        except Exception:
            th = self.default_template['thumbnail_height']
        self.thumbnail_height.setValue(th)
        self.entry_height.setText(template.get('height') or 'auto')
        self.comments_fields.setText(', '.join(template.get('comments_fields') or ()))
        self.template.setPlainText('\n'.join(template.get('lines') or ()))

    def serialize(self, template):
        return json.dumps(template, sort_keys=True, indent=4, separators=(',', ': '), ensure_ascii=True)

    def deserialize(self, raw):
        return json.loads(raw)

    def restore_defaults(self):
        self.current_template = self.default_template

    def commit(self):
        template = self.current_template
        if template == self.default_template:
            try:
                os.remove(custom_list_template.path)
            except OSError as err:
                if err.errno != errno.ENOENT:
                    raise
        else:
            raw = self.serialize(template)
            with lopen(custom_list_template.path, 'wb') as f:
                f.write(as_bytes(raw))
        return True

# }}}


# Search the internet {{{

class URLItem(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, as_dict, parent=None):
        QWidget.__init__(self, parent)
        self.changed_signal.connect(parent.changed_signal)
        self.l = l = QFormLayout(self)
        self.type_widget = t = QComboBox(self)
        l.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
        t.addItems([_('Book'), _('Author')])
        l.addRow(_('URL type:'), t)
        self.name_widget = n = QLineEdit(self)
        n.setClearButtonEnabled(True)
        l.addRow(_('Name:'), n)
        self.url_widget = w = QLineEdit(self)
        w.setClearButtonEnabled(True)
        l.addRow(_('URL:'), w)
        if as_dict:
            self.name = as_dict['name']
            self.url = as_dict['url']
            self.url_type = as_dict['type']
        self.type_widget.currentIndexChanged.connect(self.changed_signal)
        self.name_widget.textChanged.connect(self.changed_signal)
        self.url_widget.textChanged.connect(self.changed_signal)

    @property
    def is_empty(self):
        return not self.name or not self.url

    @property
    def url_type(self):
        return 'book' if self.type_widget.currentIndex() == 0 else 'author'

    @url_type.setter
    def url_type(self, val):
        self.type_widget.setCurrentIndex(1 if val == 'author' else 0)

    @property
    def name(self):
        return self.name_widget.text().strip()

    @name.setter
    def name(self, val):
        self.name_widget.setText((val or '').strip())

    @property
    def url(self):
        return self.url_widget.text().strip()

    @url.setter
    def url(self, val):
        self.url_widget.setText((val or '').strip())

    @property
    def as_dict(self):
        return {'name': self.name, 'url': self.url, 'type': self.url_type}

    def validate(self):
        if self.is_empty:
            return True
        if '{author}' not in self.url:
            error_dialog(self.parent(), _('Missing author placeholder'), _(
                'The URL {0} does not contain the {1} placeholder').format(self.url, '{author}'), show=True)
            return False
        if self.url_type == 'book' and '{title}' not in self.url:
            error_dialog(self.parent(), _('Missing title placeholder'), _(
                'The URL {0} does not contain the {1} placeholder').format(self.url, '{title}'), show=True)
            return False
        return True


class SearchTheInternet(QWidget):

    changed_signal = pyqtSignal()

    def __init__(self, parent):
        QWidget.__init__(self, parent)
        self.sa = QScrollArea(self)
        self.lw = QWidget(self)
        self.l = QVBoxLayout(self.lw)
        self.sa.setWidget(self.lw), self.sa.setWidgetResizable(True)
        self.gl = gl = QVBoxLayout(self)
        self.la = QLabel(_(
            'Add new locations to search for books or authors using the "Search the internet" feature'
            ' of the Content server. The URLs should contain {author} which will be'
            ' replaced by the author name and, for book URLs, {title} which will'
            ' be replaced by the book title.'))
        self.la.setWordWrap(True)
        gl.addWidget(self.la)

        self.h = QHBoxLayout()
        gl.addLayout(self.h)
        self.add_url_button = b = QPushButton(QIcon(I('plus.png')), _('&Add URL'))
        b.clicked.connect(self.add_url)
        self.h.addWidget(b)
        self.export_button = b = QPushButton(_('Export URLs'))
        b.clicked.connect(self.export_urls)
        self.h.addWidget(b)
        self.import_button = b = QPushButton(_('Import URLs'))
        b.clicked.connect(self.import_urls)
        self.h.addWidget(b)
        self.clear_button = b = QPushButton(_('Clear'))
        b.clicked.connect(self.clear)
        self.h.addWidget(b)

        self.h.addStretch(10)
        gl.addWidget(self.sa, stretch=10)
        self.items = []

    def genesis(self):
        self.current_urls = search_the_net_urls() or []

    @property
    def current_urls(self):
        return [item.as_dict for item in self.items if not item.is_empty]

    def append_item(self, item_as_dict):
        self.items.append(URLItem(item_as_dict, self))
        self.l.addWidget(self.items[-1])

    def clear(self):
        [(self.l.removeWidget(w), w.setParent(None), w.deleteLater()) for w in self.items]
        self.items = []
        self.changed_signal.emit()

    @current_urls.setter
    def current_urls(self, val):
        self.clear()
        for entry in val:
            self.append_item(entry)

    def add_url(self):
        self.items.append(URLItem(None, self))
        self.l.addWidget(self.items[-1])
        QTimer.singleShot(100, self.scroll_to_bottom)

    def scroll_to_bottom(self):
        sb = self.sa.verticalScrollBar()
        if sb:
            sb.setValue(sb.maximum())
        self.items[-1].name_widget.setFocus(Qt.FocusReason.OtherFocusReason)

    @property
    def serialized_urls(self):
        return json.dumps(self.current_urls, indent=2)

    def commit(self):
        for item in self.items:
            if not item.validate():
                return False
        cu = self.current_urls
        if cu:
            with lopen(search_the_net_urls.path, 'wb') as f:
                f.write(self.serialized_urls.encode('utf-8'))
        else:
            try:
                os.remove(search_the_net_urls.path)
            except OSError as err:
                if err.errno != errno.ENOENT:
                    raise
        return True

    def export_urls(self):
        path = choose_save_file(
            self, 'search-net-urls', _('Choose URLs file'),
            filters=[(_('URL files'), ['json'])], initial_filename='search-urls.json')
        if path:
            with lopen(path, 'wb') as f:
                f.write(self.serialized_urls.encode('utf-8'))

    def import_urls(self):
        paths = choose_files(self, 'search-net-urls', _('Choose URLs file'),
            filters=[(_('URL files'), ['json'])], all_files=False, select_only_single_file=True)
        if paths:
            with lopen(paths[0], 'rb') as f:
                items = json.loads(f.read())
                [self.append_item(x) for x in items]
                self.changed_signal.emit()

# }}}


class ConfigWidget(ConfigWidgetBase):

    def __init__(self, *args, **kw):
        ConfigWidgetBase.__init__(self, *args, **kw)
        self.l = l = QVBoxLayout(self)
        l.setContentsMargins(0, 0, 0, 0)
        self.tabs_widget = t = QTabWidget(self)
        l.addWidget(t)
        self.main_tab = m = MainTab(self)
        t.addTab(m, _('&Main'))
        m.start_server.connect(self.start_server)
        m.stop_server.connect(self.stop_server)
        m.test_server.connect(self.test_server)
        m.show_logs.connect(self.view_server_logs)
        self.opt_autolaunch_server = m.opt_autolaunch_server
        self.users_tab = ua = Users(self)
        t.addTab(ua, _('&User accounts'))
        self.advanced_tab = a = AdvancedTab(self)
        sa = QScrollArea(self)
        sa.setWidget(a), sa.setWidgetResizable(True)
        t.addTab(sa, _('&Advanced'))
        self.custom_list_tab = clt = CustomList(self)
        sa = QScrollArea(self)
        sa.setWidget(clt), sa.setWidgetResizable(True)
        t.addTab(sa, _('Book &list template'))
        self.search_net_tab = SearchTheInternet(self)
        t.addTab(self.search_net_tab, _('&Search the internet'))

        for tab in self.tabs:
            if hasattr(tab, 'changed_signal'):
                tab.changed_signal.connect(self.changed_signal.emit)

    @property
    def tabs(self):

        def w(x):
            if isinstance(x, QScrollArea):
                x = x.widget()
            return x

        return (
            w(self.tabs_widget.widget(i)) for i in range(self.tabs_widget.count())
        )

    @property
    def server(self):
        return self.gui.content_server

    def restore_defaults(self):
        ConfigWidgetBase.restore_defaults(self)
        for tab in self.tabs:
            if hasattr(tab, 'restore_defaults'):
                tab.restore_defaults()

    def genesis(self, gui):
        self.gui = gui
        for tab in self.tabs:
            tab.genesis()

        r = self.register
        r('autolaunch_server', config)

    def start_server(self):
        if not self.save_changes():
            return
        self.setCursor(Qt.CursorShape.BusyCursor)
        try:
            self.gui.start_content_server(check_started=False)
            while (not self.server.is_running and self.server.exception is None):
                time.sleep(0.1)
            if self.server.exception is not None:
                error_dialog(
                    self,
                    _('Failed to start Content server'),
                    as_unicode(self.gui.content_server.exception)
                ).exec()
                self.gui.content_server = None
                return
            self.main_tab.update_button_state()
        finally:
            self.unsetCursor()

    def stop_server(self):
        self.server.stop()
        self.stopping_msg = info_dialog(
            self,
            _('Stopping'),
            _('Stopping server, this could take up to a minute, please wait...'),
            show_copy_button=False
        )
        QTimer.singleShot(500, self.check_exited)
        self.stopping_msg.exec()

    def check_exited(self):
        if getattr(self.server, 'is_running', False):
            QTimer.singleShot(20, self.check_exited)
            return

        self.gui.content_server = None
        self.main_tab.update_button_state()
        self.stopping_msg.accept()

    def test_server(self):
        prefix = self.advanced_tab.get('url_prefix') or ''
        protocol = 'https' if self.advanced_tab.has_ssl else 'http'
        lo = self.advanced_tab.get('listen_on') or '0.0.0.0'
        lo = {'0.0.0.0': '127.0.0.1', '::':'::1'}.get(lo)
        url = '{protocol}://{interface}:{port}{prefix}'.format(
            protocol=protocol, interface=lo,
            port=self.main_tab.opt_port.value(), prefix=prefix)
        open_url(QUrl(url))

    def view_server_logs(self):
        from calibre.srv.embedded import log_paths
        log_error_file, log_access_file = log_paths()
        d = QDialog(self)
        d.resize(QSize(800, 600))
        layout = QVBoxLayout()
        d.setLayout(layout)
        layout.addWidget(QLabel(_('Error log:')))
        el = QPlainTextEdit(d)
        layout.addWidget(el)
        try:
            el.setPlainText(
                share_open(log_error_file, 'rb').read().decode('utf8', 'replace')
            )
        except OSError:
            el.setPlainText(_('No error log found'))
        layout.addWidget(QLabel(_('Access log:')))
        al = QPlainTextEdit(d)
        layout.addWidget(al)
        try:
            al.setPlainText(
                share_open(log_access_file, 'rb').read().decode('utf8', 'replace')
            )
        except OSError:
            al.setPlainText(_('No access log found'))
        loc = QLabel(_('The server log files are in: {}').format(os.path.dirname(log_error_file)))
        loc.setWordWrap(True)
        layout.addWidget(loc)
        bx = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
        layout.addWidget(bx)
        bx.accepted.connect(d.accept)
        b = bx.addButton(_('&Clear logs'), QDialogButtonBox.ButtonRole.ActionRole)

        def clear_logs():
            if getattr(self.server, 'is_running', False):
                return error_dialog(d, _('Server running'), _(
                    'Cannot clear logs while the server is running. First stop the server.'), show=True)
            if self.server:
                self.server.access_log.clear()
                self.server.log.clear()
            else:
                for x in (log_error_file, log_access_file):
                    try:
                        os.remove(x)
                    except OSError as err:
                        if err.errno != errno.ENOENT:
                            raise
            el.setPlainText(''), al.setPlainText('')

        b.clicked.connect(clear_logs)
        d.show()

    def save_changes(self):
        settings = {}
        for tab in self.tabs:
            settings.update(getattr(tab, 'settings', {}))
        users = self.users_tab.user_data
        if settings['auth']:
            if not users:
                error_dialog(
                    self,
                    _('No users specified'),
                    _(
                        'You have turned on the setting to require passwords to access'
                        ' the Content server, but you have not created any user accounts.'
                        ' Create at least one user account in the "User accounts" tab to proceed.'
                    ),
                    show=True
                )
                self.tabs_widget.setCurrentWidget(self.users_tab)
                return False
        if settings['trusted_ips']:
            try:
                tuple(parse_trusted_ips(settings['trusted_ips']))
            except Exception as e:
                error_dialog(
                    self, _('Invalid trusted IPs'), str(e), show=True)
                return False

        if not self.custom_list_tab.commit():
            return False
        if not self.search_net_tab.commit():
            return False
        ConfigWidgetBase.commit(self)
        change_settings(**settings)
        UserManager().user_data = users
        return True

    def commit(self):
        if not self.save_changes():
            raise AbortCommit()
        warning_dialog(
            self,
            _('Restart needed'),
            _('You need to restart the server for changes to'
              ' take effect'),
            show=True
        )
        return False

    def refresh_gui(self, gui):
        if self.server:
            self.server.user_manager.refresh()
            self.server.ctx.custom_list_template = custom_list_template()
            self.server.ctx.search_the_net_urls = search_the_net_urls()


if __name__ == '__main__':
    from calibre.gui2 import Application
    app = Application([])
    test_widget('Sharing', 'Server')
