from dataclasses import dataclass
import tkinter as tk
from tkinter import ttk
from tkinter import filedialog
from .configuration import config as cfg
import logging
import re

logger = logging.getLogger(cfg.appName())


class configDialog:
    """A dialog box to set and change application parameters."""

    def __init__(self, parent, icon=None, updated_cb=None):
        """
        parent :
            A tkinter widget
        icon :
            An icon suitable for tkinter's window top bar icon
        updated_cb :
            A callback function that is called whenever the configuration
            is updated from the dialog
        """

        self.top = tk.Toplevel(parent)
        self.top.title("Config")
        self.updated_cb = updated_cb
        self.label_width = 20  # [characters]

        ttk.Style().configure('select_dir.TButton', font=('Arial', 10))

        if icon:
            self.top.iconphoto(False, icon)

        config_frame = ttk.Frame(self.top)

        # The contents of the dialog are a vertical list of config values. The contents
        # of each row are given as a list of Param instances.  
      
        @dataclass
        class Param:
            label: str  # the user visibl text for the parameter
            name: str  # the config name
            type: str  # 'float', 'int', 'boolean', 'horizline', 'label'
            unit: str = ''  # the unit for the parameter
            special: str = None  #  'filechooser', or '' for a normal parameter
            vmin:float = None  # the minimum allowed value for the parameter
            vmax:float = None  # the maximum allowed value for the parameter


        self.params = [
            Param('Sonar data directory', 'watchDir', 'str', '', 'filechooser'),
            Param('Use live data', 'liveData', 'boolean'),
            Param('Replay ping interval', 'replayPingInterval', 'float', 's', '', 0.1),
            Param('', '', 'horizline'),
            Param('Calibration sphere TS', 'sphereTS', 'float', 'dB re 1 m²'),
            Param('', '', 'horizline'),
            Param('x-axis size', 'numPings', 'int', 'pings', '', 10, 500),
            Param('Echogram range', 'maxRange', 'float', 'm', '', 1.0, 300.0),
            Param('', '', 'horizline'),
            Param('y-axis minimum on Δ plot', 'diffPlotYMin', 'float', 'dB', '', None, -1.0),
            Param('Maximum Sv colour', 'sliderHighestSv', 'float', 'dB re 1 m⁻¹'),
            Param('Minimum Sv colour', 'sliderLowestSv', 'float', 'dB re 1 m⁻¹')
        ]

        self.vars = {}  # mapping for name to tkinter Var

        # Create a row in the dialog bos for each item in self.params
        for p in self.params:
            if p.type == 'horizline':
                ttk.Separator(self.top, orient=tk.HORIZONTAL).pack(fill=tk.X, padx=10, pady=5)
                continue
            if p.type == 'label':
                ttk.Label(self.top, text=p.label).pack(side=tk.TOP, fill=tk.BOTH,
                                                       expand=tk.TRUE, pady=10)
                continue

            v = getattr(cfg, p.name)()  # get value of current config parameter
            match p.type:
                case 'int':
                    self.vars[p.name] = tk.IntVar(value=v)
                case 'float':
                    self.vars[p.name] = tk.DoubleVar(value=v)
                case 'boolean':
                    self.vars[p.name] = tk.BooleanVar(value=v)
                case 'str':
                    self.vars[p.name] = tk.StringVar(value=v)

            if p.special == 'filechooser':
                self.create_dir_chooser_row(p.label, self.vars[p.name])
            else:
                self.create_config_row(p.label, self.vars[p.name], p.type,
                                       p.unit, p.vmin, p.vmax)

        # The dialog has Close and Apply buttons
        btn_frame = ttk.Frame(self.top)
        ttk.Button(btn_frame, text="Close", command=self.close_dialog).pack(side=tk.RIGHT)

        self.apply_btn = ttk.Button(btn_frame, text="Apply", command=self.apply)
        self.apply_btn.pack(side=tk.RIGHT)
        self.apply_btn.state(['disabled'])  # only enabled when a config parameter has changed
        
        config_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=tk.TRUE)
        btn_frame.pack(side=tk.TOP, fill=tk.BOTH)

    def create_dir_chooser_row(self, label: str, variable):
        """Create a directory chooser config row.
        
        Parameters
        ----------
        label : 
            The text to use for the row label.
            
        variable: 
            The tkinter Var to associate with the Text widget
        """

        container = ttk.Frame(self.top)
        container.pack(fill=tk.X, expand=tk.YES, pady=5)

        subcon = ttk.Frame(container)
        lbl = ttk.Label(master=subcon, text=label, width=self.label_width)
        lbl.pack(side=tk.TOP, padx=5, fill=tk.X, expand=tk.NO)

        btn = ttk.Button(subcon, text='Select directory', style='select_dir.TButton',
                         command=lambda: _dir_chooser(variable))
        btn.pack(side=tk.TOP, padx=5, expand=tk.NO, anchor=tk.W)
        subcon.pack(side=tk.LEFT)

        ent = tk.Text(container, wrap=tk.CHAR, width=35, height=4)
        ent.insert('1.0', variable.get())
        ent.pack(side=tk.TOP, padx=5, fill=tk.X, expand=tk.NO)
        ent.config(state=tk.DISABLED)

        variable.trace_add('write', callback=self.update_apply_state)

        def _dir_chooser(variable):
            """Uses a filedialog to get a directory."""
            d = filedialog.askdirectory(parent=container, title='Select data directory',
                                        initialdir=variable.get())
            if d:
                ent.config(state=tk.NORMAL)  # allows us to change the contents
                ent.delete('1.0', tk.END)
                ent.insert('1.0', d)
                variable.set(d)
                ent.config(state=tk.DISABLED)


    def create_config_row(self, label: str, variable, var_type: str, unit: str = '',
                          vmin: float|int|None = None,
                          vmax: float|int|None = None):
        """Create a row in the dialog for a config parameter.

        Parameters
        ----------
        label :
            The text to descrive the config parameter. Visible to the user.
        variable:
            A tkinter Var to associate with the config entry widget
        var_type:
            The type of the config parameter
        unit:
            A string containing the units for the config parameter (if any)
        vmin:
            The minimum allowed value for the config parameter (if any)
        vmax:
            The maximum allowed value for the config parameter (if any)
        """
        
        container = ttk.Frame(self.top)
        container.pack(fill=tk.X, expand=tk.YES, pady=5)

        lbl = ttk.Label(master=container, text=label, width=self.label_width)
        lbl.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=tk.NO)

        match var_type:
            case 'boolean':
                entry = ttk.Checkbutton(container, variable=variable)
            case 'float' | 'int':
                entry = validated_entry(master=container, textvariable=variable,
                                        justify='right', width=10,
                                        min=vmin, max=vmax, vtype=var_type)
            case _:
                entry = ttk.Entry(master=container, textvariable=variable,
                                  justify='right', width=10)

        unit = ttk.Label(master=container, text=unit, width=10)
        bounds = ttk.Label(master=container, text=self.bounds_to_str(vmin, vmax))

        entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=tk.NO)
        unit.pack(side=tk.LEFT, padx=5)
        bounds.pack(side=tk.RIGHT, padx=10)

        variable.trace_add('write', callback=self.update_apply_state)

    @staticmethod
    def bounds_to_str(vmin, vmax):
        """Convert the min/max limits into a str for display to the user."""
        if vmin is None and vmax is None:
            return ''
        
        vmin_str = f'{vmin} ≤' if vmin else ''
        vmax_str = f'≤ {vmax}' if vmax else ''

        return vmin_str + ' x ' + vmax_str


    def changed_values(self) -> list:
        """Return the dialog parameter names that have changed compared to the config object."""
        changed = []
        for p in self.params:
            if p.name:
                # Work out which settings have been changed
                dialog_value = self.vars[p.name].get()
                cfg_value = getattr(cfg, p.name)()
                if dialog_value != cfg_value:
                    changed.append(p.name)
        return changed


    def update_apply_state(self, var, index, mode):
        """Activate the Apply button if all config values are valid and at least one has changed."""
        if self.all_valid() and self.changed_values():
            self.apply_btn.state(['!disabled'])
        else:
            self.apply_btn.state(['disabled'])


    def all_valid(self):
        """Are values in the dialog valid (only for those with vmin or vmax set)"""
        for p in self.params:
            if p.name:
                try:
                    dialog_value = self.vars[p.name].get()
                except Exception:
                    return False

                if p.vmin and dialog_value < p.vmin:
                    return False

                if p.vmax and dialog_value > p.vmax:
                    return False

        return True


    def apply(self):
        """Apply the dialog values to the config object."""
        changed = self.changed_values()

        for name in changed:
            dialog_value = self.vars[name].get()
            # update the settings in the cfg object
            logger.info('Config parameter "%s" changing to "%s"', name, str(dialog_value))
            getattr(cfg, name)(dialog_value)
            if getattr(cfg, name)() != dialog_value:
                logger.error('Failed to set config parameter "%s" to "%s"',
                             name, str(dialog_value))

        cfg.save_config() 
        self.apply_btn.state(['disabled'])
   
        if self.updated_cb:
            # tell others that we've updated
            self.updated_cb(changed)


    def reopen(self):
        self.top.deiconify()


    def close_dialog(self):
        self.top.withdraw()


class validated_entry(ttk.Entry):
    """Validated ttk Entry widget for ints and floats."""

    def __init__(self, master=None, vtype=None, min=None, max=None, **kwargs):
        """
        Parameters
        ----------
        master:
            The parent tk widget for this Entry widget
        vtype:
            Type of variable ('int', 'float', or '')
        min: 
            The minumum allowed value for the value in the widget
        max:
            The maxumum allowed value for the value in the widget
        """

        super().__init__(master, **kwargs)
        
        if vtype == 'float':
            self.chars_regex = r'[^0-9eE+\-\.]'
            self.regex = r'^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d*)?$'
            self.convert = float
        elif vtype == 'int':
            self.chars_regex = r'[^0-9+\-]'
            self.regex = r'[-+]?\d+'
            self.convert = int
        else:
            self.regex = r'.*'
            self.convert = str
        
        # Invalid entries are highlighted with this colour
        self.invalid_colour = 'orange red'
        style = ttk.Style()
        self.foreground_color = style.lookup(self.winfo_class(), "foreground")
            
        self.min = min
        self.max = max
        vcmd = self.register(self._validate)
        self.configure(validate='all', validatecommand=(vcmd, '%P'))


    def _validate(self, proposed_value):
        """Validate the proposed new entry value."""

        if proposed_value == '':
            self.state(['invalid'])
            return True
        
        # We're only interested in characters that could be used in ints and floats
        if re.findall(self.chars_regex, proposed_value):
            return False

        if re.fullmatch(self.regex, proposed_value):
            try:
                v = self.convert(proposed_value)
            except ValueError:
                self.state(['invalid'])
                return True

            if self.min and v < self.min:
                self.config(foreground=self.invalid_colour)
                self.state(['invalid'])
            elif self.max and v > self.max:
                self.config(foreground=self.invalid_colour)
                self.state(['invalid'])
            else:
                self.config(foreground=self.foreground_color)
                self.state(['!invalid'])
        else:
            self.config(foreground=self.invalid_colour)
            self.state(['invalid'])

        return True
