#!/usr/pkg/bin/python3.13
"""
Syncthing-GTK - EditorDialog

Base class and universal handler for all Syncthing settings and editing
"""


import logging
import os

from gi.repository import Gdk, GLib, GObject, Gtk

from syncthing_gtk.daemon import ConnectionRestarted
from syncthing_gtk.tools import _  # gettext function
from syncthing_gtk.tools import ints
from syncthing_gtk.uibuilder import UIBuilder

log = logging.getLogger("EditorDialog")


class EditorDialog(GObject.GObject):
    """
    Universal dialog handler for all Syncthing settings and editing

    Signals:
        close()
            emitted after dialog is closed
        loaded()
            Emitted after dialog loads and parses configuration data
    """

    __gsignals__ = {
        "close": (GObject.SIGNAL_RUN_FIRST, None, ()),
        "loaded": (GObject.SIGNAL_RUN_FIRST, None, ()),
    }

    # Should be overridden by subclass
    MESSAGES = {}
    SETTING_NEEDS_RESTART = []
    RESTART_NEEDED_WIDGET = "lblRestartNeeded"

    def __init__(self, app, gladefile, title):
        GObject.GObject.__init__(self)
        self.app = app
        self.config = None
        self._loading = False
        self.values = None
        self.checks = {}
        # Stores original label  value while error message is displayed.
        self.original_labels = {}
        # Used by get_widget_id
        self.widget_to_id = {}
        self.setup_widgets(gladefile, title)
        # Move entire dialog content to ScrolledWindow if screen height
        # is too small
        if Gdk.Screen.get_default().height() < 900:
            if not self["editor-content"] is None:
                parent = self["editor-content"].get_parent()
                if isinstance(parent, Gtk.Notebook):
                    order, labels = [], {}
                    for c in [] + parent.get_children():
                        labels[c] = parent.get_tab_label(c)
                        order.append(c)
                        parent.remove(c)
                    for c in order:
                        sw = Gtk.ScrolledWindow()
                        sw.add_with_viewport(c)
                        parent.append_page(sw, labels[c])
                else:
                    sw = Gtk.ScrolledWindow()
                    parent.remove(self["editor-content"])
                    sw.add_with_viewport(self["editor-content"])
                    parent.pack_start(sw, True, True, 0)
                self["editor"].resize(self["editor"].get_size()[0], Gdk.Screen.get_default().height() * 2 / 3)

    def load(self):
        """Loads configuration data and pre-fills values to fields"""
        self._loading = True
        self.load_data()

    def __getitem__(self, name):
        """Convience method that allows widgets to be accessed via self["widget"]"""
        return self.builder.get_object(name)

    def __contains__(self, name):
        """Returns true if there is such widget"""
        return self.builder.get_object(name) is not None

    def get_widget_id(self, w):
        """
        Returns glade file ID for specified widget or None, if widget
        is not known.
        """
        if w not in self.widget_to_id:
            return None
        return self.widget_to_id[w]

    def find_widget_by_id(self, id, parent=None):
        """Recursively searches for widget with specified ID"""
        if parent is None:
            if id in self:
                return self[id]  # Do things fast if possible
            parent = self["editor"]
        for c in parent.get_children():
            if hasattr(c, "get_id"):
                if c.get_id() == id:
                    return c
            if isinstance(c, Gtk.Container):
                r = self.find_widget_by_id(id, c)
                if r is not None:
                    return r
        return None

    def show(self, parent=None):
        if parent is not None:
            self["editor"].set_transient_for(parent)
        self["editor"].set_modal(True)
        self["editor"].show_all()

    def present(self, values=[]):
        self["editor"].present()
        for v in values:
            if not self[v] is None and self[v].get_sensitive():
                self[v].grab_focus()
                return

    def close(self):
        self.emit("close")
        self["editor"].hide()
        self["editor"].destroy()

    def setup_widgets(self, gladefile, title):
        # Load glade file
        self.builder = UIBuilder()
        self.builder.add_from_file(os.path.join(self.app.gladepath, gladefile))
        self.builder.connect_signals(self)
        self["editor"].set_title(title)
        # Disable everything until configuration is loaded
        self["editor"].set_sensitive(False)

    def get_burried_value(self, key, vals, default, convert=lambda a: a):
        """
        Returns value stored deeper in element tree.
        Method is called recursively for every tree level. If value is
        not found, default is returned.
        """
        if not isinstance(key, list):
            # Parse key, split by '/'
            return self.get_burried_value(key.split("/"), vals, default, convert)
        try:
            if len(key) > 1:
                tkey, key = key[0], key[1:]
                return self.get_burried_value(key, vals[tkey], default, convert)
            return convert(vals[key[0]])
        except Exception:
            return default

    def get_value(self, key):
        """
        Returns value from configuration.
        Usually returns self.values[key], but overriding methods can
        handle some special cases
        """
        if key in self.values:
            return self.values[key]
        else:
            log.warning("get_value: Value %s not found", key)
            raise ValueNotFoundError(key)

    def set_value(self, key, value):
        """
        Stores value to configuration, handling some special cases in
        overriding methods
        """
        if key in self.values:
            self.values[key] = value
        else:
            raise ValueNotFoundError(key)

    def create_dicts(self, parent, keys):
        """
        Creates structure of nested dicts, if they are not in place already.
        """
        if not isinstance(keys, list):
            keys = list(keys)
        if len(keys) == 0:
            return  # Done
        key, rest = keys[0], keys[1:]
        if key not in parent:
            parent[key] = {}
        if parent[key] in ("", None):
            parent[key] = {}
        self.create_dicts(parent[key], rest)

    def load_data(self):
        self.app.daemon.read_config(self.cb_data_loaded, self.cb_data_failed)

    def display_error_message(self, value_id):
        """Changes text on associated label to error message"""
        wid = "lbl%s" % (value_id,)  # widget id
        if value_id in self.original_labels:
            # Already done
            return
        if value_id not in self.MESSAGES:
            # Nothing to show
            return
        self.original_labels[value_id] = self[wid].get_label()
        self[wid].set_markup('<span color="red">%s</span>' % (self.MESSAGES[value_id],))

    def hide_error_message(self, value_id):
        """Changes text on associated label back to normal text"""
        wid = "lbl%s" % (value_id,)  # widget id
        if value_id in self.original_labels:
            self[wid].set_label(self.original_labels[value_id])
            del self.original_labels[value_id]

    def cb_data_loaded(self, config):
        """Used as handler in load_data"""
        self.config = config
        if self.on_data_loaded():
            self.update_special_widgets()
            # Enable dialog
            self["editor"].set_sensitive(True)
            self._loading = False
            # Brag
            self.emit("loaded")

    def on_data_loaded(self, config):
        """
        Called from cb_data_loaded, should be overridden by subclass.
        Should return True to indicate that everything is OK, false on
        error.
        """
        raise RuntimeError("Override this!")

    def display_values(self, values):
        """
        Iterates over all known configuration values and sets UI
        elements using unholy method.
        Returns True.
        """
        for key in values:
            widget = self.find_widget_by_id(key)
            self.widget_to_id[widget] = key
            if key is not None:
                try:
                    self.display_value(key, widget)
                except ValueNotFoundError:
                    # Value not found, probably old daemon version
                    log.warning("display_values: Value %s not found", key)
                    widget.set_sensitive(False)
        GLib.idle_add(self.present, values)
        return True

    def display_value(self, key, w):
        """
        Sets value on UI element for single key. May be overridden
        by subclass to handle special values.
        """
        if isinstance(w, Gtk.SpinButton):
            w.get_adjustment().set_value(ints(self.get_value(strip_v(key))))
        elif isinstance(w, Gtk.Entry):
            w.set_text(str(self.get_value(strip_v(key))))
        elif isinstance(w, Gtk.ComboBox):
            val = self.get_value(strip_v(key))
            m = w.get_model()
            for i in range(0, len(m)):
                if str(val) == str(m[i][0]).strip():
                    w.set_active(i)
                    break
            else:
                w.set_active(0)
        elif isinstance(w, Gtk.CheckButton):
            w.set_active(self.get_value(strip_v(key)))
        else:
            log.warning("display_value: %s class cannot handle widget %s, key %s", self.__class__.__name__, w, key)
            if w is not None:
                w.set_sensitive(False)

    def ui_value_changed(self, w, *a):
        """
        Handler for widget that controls state of other widgets
        """
        key = self.get_widget_id(w)
        if not self._loading:
            if key in self.SETTING_NEEDS_RESTART:
                self[self.RESTART_NEEDED_WIDGET].set_visible(True)
        if key is not None:
            if isinstance(w, Gtk.CheckButton):
                self.set_value(strip_v(key), w.get_active())
                self.update_special_widgets()
            if isinstance(w, Gtk.ComboBox):
                self.set_value(strip_v(key), str(w.get_model()[w.get_active()][0]).strip())
                self.update_special_widgets()

    def update_special_widgets(self, *a):
        """
        Enables/disables special widgets. Does nothing by default, but
        may be overridden by subclasses
        """
        if self.mode == "folder-edit":
            self["vID"].set_sensitive(self.id is None)
            v = self.get_value("Versioning")
            if v == "":
                if self["rvVersioning"].get_reveal_child():
                    self["rvVersioning"].set_reveal_child(False)
            else:
                self["bxVersioningSimple"].set_visible(self.get_value("Versioning") == "simple")
                self["bxVersioningStaggered"].set_visible(self.get_value("Versioning") == "staggered")
                if not self["rvVersioning"].get_reveal_child():
                    self["rvVersioning"].set_reveal_child(True)
        elif self.mode == "device-edit":
            self["vDeviceID"].set_sensitive(self.is_new)
            self["vAddresses"].set_sensitive(self.id != self.app.daemon.get_my_id())
        elif self.mode == "daemon-settings":
            self["vMaxSendKbps"].set_sensitive(self.get_value("MaxSendKbpsEnabled"))
            self["lblvLocalAnnPort"].set_sensitive(self.get_value("LocalAnnEnabled"))
            self["vLocalAnnPort"].set_sensitive(self.get_value("LocalAnnEnabled"))
            self["lblvGlobalAnnServer"].set_sensitive(self.get_value("GlobalAnnEnabled"))
            self["vGlobalAnnServer"].set_sensitive(self.get_value("GlobalAnnEnabled"))

    def cb_data_failed(self, exception, *a):
        """
        Failed to load configuration. This shouldn't happen unless daemon
        dies exactly when user clicks to edit menu.
        Handled by simple error message.
        """
        # All other errors are fatal for now. Error dialog is displayed and program exits.
        d = Gtk.MessageDialog(
            self["editor"],
            Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
            Gtk.MessageType.ERROR,
            Gtk.ButtonsType.CLOSE,
            "%s %s\n\n%s %s"
            % (_("Failed to load configuration from daemon."), _("Try again."), _("Error message:"), str(exception)),
        )
        d.run()
        self.close()

    def cb_btClose_clicked(self, *a):
        self.close()

    def cb_check_value(self, *a):
        self["btSave"].set_sensitive(True)
        for x in self.checks:
            value = self[x].get_text().strip()
            if len(value) == 0:
                # Empty value in field
                if self.checks[x](value):
                    # ... but empty value is OK
                    self.hide_error_message(x)
                else:
                    self["btSave"].set_sensitive(False)
                    self.hide_error_message(x)
            elif not self.checks[x](value):
                # Invalid value in any field
                self["btSave"].set_sensitive(False)
                self.display_error_message(x)
            else:
                self.hide_error_message(x)

    def cb_btSave_clicked(self, *a):
        """Calls on_save_requested to do actual work"""
        self.on_save_requested()

    def on_save_requested(self, config):
        """
        Should be overridden by subclass.
        Should return True to indicate that everything is OK, false on
        error.
        """
        raise RuntimeError("Override this!")

    def store_values(self, values):
        """
        'values' parameter should be same as display_values received.
        Iterates over values configuration values and puts stuff from
        UI back to self.values dict
        Returns True.
        """
        for key in values:
            widget = self.find_widget_by_id(key)
            if key is not None:
                try:
                    self.store_value(key, widget)
                except ValueNotFoundError:
                    pass
        return True

    def store_value(self, key, w):
        """
        Loads single value from UI element to self.values dict. May be
        overriden by subclass to handle special values.
        """
        if isinstance(w, Gtk.SpinButton):
            self.set_value(strip_v(key), int(w.get_adjustment().get_value()))
        elif isinstance(w, Gtk.Entry):
            self.set_value(strip_v(key), w.get_text())
        elif isinstance(w, Gtk.CheckButton):
            self.set_value(strip_v(key), w.get_active())
        elif isinstance(w, Gtk.ComboBox):
            self.set_value(strip_v(key), str(w.get_model()[w.get_active()][0]).strip())
        # else nothing, unknown widget class cannot be read

    def cb_format_value_s(self, spinner):
        """Formats spinner value"""
        spinner.get_buffer().set_text(_("%ss") % (int(spinner.get_adjustment().get_value()),), -1)
        return True

    def cb_format_value_s_or_disabed(self, spinner):
        """Formats spinner value"""
        val = int(spinner.get_adjustment().get_value())
        if val < 1:
            spinner.get_buffer().set_text(_("disabled"), -1)
        else:
            spinner.get_buffer().set_text(_("%ss") % (val,), -1)
        return True

    def cb_format_value_percent(self, spinner):
        """Formats spinner value"""
        val = int(spinner.get_adjustment().get_value())
        spinner.get_buffer().set_text(_("%s%%") % (val,), -1)
        return True

    def cb_format_value_kibps_or_no_limit(self, spinner):
        """Formats spinner value"""
        val = int(spinner.get_adjustment().get_value())
        if val < 1:
            spinner.get_buffer().set_text(_("no limit"), -1)
        else:
            spinner.get_buffer().set_text(_("%s KiB/s") % (val,), -1)
        return True

    def cb_format_value_days(self, spinner):
        """Formats spinner value"""
        v = int(spinner.get_adjustment().get_value())
        if v == 0:
            spinner.get_buffer().set_text(_("never delete"), -1)
        elif v == 1:
            spinner.get_buffer().set_text(_("%s day") % (v,), -1)
        else:
            spinner.get_buffer().set_text(_("%s days") % (v,), -1)
        return True

    def post_config(self):
        """Posts edited configuration back to daemon"""
        self["editor"].set_sensitive(False)
        self.app.daemon.write_config(self.config, self.syncthing_cb_post_config, self.syncthing_cb_post_error)

    def syncthing_cb_post_config(self, *a):
        # No return value for this call, let's hope for the best
        log.info("Configuration (probably) saved")
        # Close editor
        self["editor"].set_sensitive(True)
        self.on_saved()

    def on_saved(self):
        """
        Should be overridden by subclass.
        Called after post_config saves configuration.
        """
        raise RuntimeError("Override this!")

    def syncthing_cb_post_error(self, exception, *a):
        # TODO: Unified error message
        if isinstance(exception, ConnectionRestarted):
            # Should be ok, this restart is triggered
            # by App handler for 'config-saved' event.
            return self.syncthing_cb_post_config()
        message = "%s\n%s" % (_("Failed to save configuration."), str(exception))

        if hasattr(exception, "full_response"):
            try:
                fr = str(exception.full_response)[0:1024]
            except UnicodeError:
                # ... localized error strings on windows are usually
                # in anything but unicode :(
                fr = str(repr(exception.full_response))[0:1024]
            message += "\n\n" + fr

        d = Gtk.MessageDialog(
            self["editor"],
            Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT,
            Gtk.MessageType.INFO,
            Gtk.ButtonsType.CLOSE,
            message,
        )
        d.run()
        d.hide()
        d.destroy()
        self["editor"].set_sensitive(True)

    def call_after_loaded(self, callback, *data):
        """Calls callback when 'loaded' event is emitted"""
        self.connect(
            "loaded",
            # lambda below throws 'event_source' argument and
            # calls callback with rest of arguments
            lambda obj, callback, *a: callback(*a),
            callback,
            *data,
        )


def strip_v(x):
    """Strips 'v' prefix used in widget IDs"""
    return x[1:] if x.startswith("v") else x


class ValueNotFoundError(KeyError):
    pass
