""" 
DataClass UI: Controller
------------------------

This module provides classes to generate simple UI's for dataclasses,
which allow to edit the values of dataclasses in an easy way.

Use these controllers to create and steer the UI's.
"""
from __future__ import annotations

import dataclasses as dc
import logging
from dataclasses import Field, dataclass, fields
from functools import partial
from pathlib import Path

from qtpy import QtWidgets

from omc3_gui.ui_components import colors, file_dialogs
from omc3_gui.ui_components.dataclass_ui import view, model
from omc3_gui.ui_components.dataclass_ui.model import FieldUIDef, FilePath, DirectoryPath  # noqa: F401 paths need to be known by `get_dataclass_types`  
from omc3_gui.ui_components.widgets import HorizontalSeparator
from typing import get_type_hints, TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

LOGGER = logging.getLogger(__name__)


@dataclass()
class FieldUI:
    """ Controller for a single field in a dataclass, connecting it to a widget.
    
    This class is not meant to be used directly, but is rather automatically
    generated by and attached to :class:`omc3_gui.utils.dataclass_ui.controller.DataClassUI`.
    """
    widget: QtWidgets.QWidget  # edit-widget to edit field value
    label: QtWidgets.QLabel    # label-widget of the field
    get_value: Callable        # getter for the widget value, returns the value as appropriate type for the dataclass
    set_value: Callable        # setter for the widget value
    text_color: str | None = colors.TEXT_DARK  # default text-color for both widget and label
    modified: bool = False     # flag indicating if the widget-content has been modified by the user

    def __post_init__(self):
        """ Connects the widget to the label and sets the text-color. """
        for main_attribute in ["text", "value", "state"]:
            try:
                signal = getattr(self.widget, f"{main_attribute}Changed")
            except AttributeError:
                pass
            else:
                signal.connect(self.has_changed)
                break
        self.widget.setStyleSheet(f"color: {self.text_color};")
        self.label.setStyleSheet(f"QLabel {{color: {self.text_color}}};") 

    def has_changed(self):
        """ Triggered when the widget has been modified.
        Sets then the modified flag and changes label font.
         """
        self.modified = True
        font = self.label.font()
        font.setItalic(True)
        self.label.setFont(font)
    
    def reset(self):
        """ Reset the label font to normal and clear the modified flag. """
        self.modified = False
        font = self.label.font()
        font.setItalic(False)
        self.label.setFont(font)


class DataClassUI:
    """ Controller for the UI representation of a dataclass. 
    It contains a grid-layout that can be added to any QWidget/QLayout.   
    """

    def __init__(self, 
                field_definitions: Sequence[FieldUIDef], 
                dclass: type | object, 
                layout: QtWidgets.QLayout | None = None
        ):
        """ Builds a DataClassUI from a list of field definitions.

        NOTE: `dclass` is not automatically attached to the resulting class, unless it is an already an instance.
        This function works with classes and instances, but the attached object needs to be the instance.
        
        Args:
            field_def (Sequence[FieldUIDef | str]): list of field definitions
            dclass (type | object): DataClass type or instance.
            layout (QtWidgets.QLayout | None): Layout to add the UI to. If None, a new grid-layout is created.

        Returns:
            DataClassUI: An instances with a grid-layout containing edit-widgets and labels.
        """
        if layout is None:
            layout = QtWidgets.QGridLayout()
        
        self.model: object = None if isinstance(dclass, type) else dclass  # dataclass instance 
        self.layout: QtWidgets.QGridLayout = layout  # final layout of the UI for dataclass
        self.fields: dict[str, FieldUI] = {} # stored field UI-elements

        field_instances: dict[str, Field] = {field.name: field for field in fields(dclass)}
        field_types = get_dataclass_types(
            dclass, 
            [
                field.name for field in field_definitions 
                if field is not None and not isinstance(field, str) and field.name in field_instances.keys()
             ]
        )
        # 
        
        for idx_row, field_def in enumerate(field_definitions):
            if field_def is None:  # Separator ---
                layout.addWidget(HorizontalSeparator(), idx_row, 0, 1, 3)
                continue

            if isinstance(field_def, str):  # Label only ---
                layout.addWidget(QtWidgets.QLabel(field_def), idx_row, 0)
                continue
            
            if field_def.name not in field_instances:
                raise ValueError(f"Field {field_def.name} not found in dataclass {dclass}")

            field_inst = field_instances[field_def.name]
            
            # Label ---
            qlabel = QtWidgets.QLabel(field_def.label or field_inst.metadata.get("label", field_def.name))
            qlabel.setToolTip(field_def.comment or field_inst.metadata.get("comment", ""))
            layout.addWidget(qlabel, idx_row, 0)
            
            # User input ---
            # If field_def.type is not given, use evaluate from dataclass. 
            # Check __args__ in case of Union/Optional and use first one.
            # The type needs to be instanciable!
            eval_type = field_types[field_def.name]  # evaluated type, might be Optional/Union etc with __args__
            field_type = field_def.pytype or getattr(eval_type, "__args__", [eval_type])[0]  

            widget = TYPE_TO_WIDGET_MAP.get(field_type, QtWidgets.QLineEdit)()

            try:
                widget.setReadOnly(not field_def.editable)
            except AttributeError:
                widget.setEnabled(field_def.editable)

            get_value, set_value = build_getter_setter(widget, field_type)
            self.fields[field_def.name] = FieldUI(
                widget=widget, 
                label=qlabel,
                set_value=set_value,
                get_value=get_value,
                text_color=colors.TEXT_DARK if field_def.editable else colors.GREYED_OUT_TEXT_DARK
            )
            
            if not issubclass(field_type, Path) or not field_def.editable:
                layout.addWidget(widget, idx_row, 1, 1, 2)
            else:
                layout.addWidget(widget, idx_row, 1)
                # Add Path selection button ---
                button = QtWidgets.QPushButton("...")
                button.setFixedWidth(30)
                
                if field_type is model.FilePath:
                    dialog = file_dialogs.OpenFileDialog
                elif field_type is model.DirectoryPath:
                    dialog = file_dialogs.OpenDirectoryDialog 
                else:
                    dialog = file_dialogs.OpenAnySingleDialog

                button.clicked.connect(partial(
                    run_dialog, 
                    dialog=dialog,
                    get_value=get_value,
                    set_value=set_value))
                layout.addWidget(button, idx_row, 2)

        if self.model is not None:
            self.update_ui()  # fill with values from the instance

    def reset_labels(self):
        """ Resets all labels to indicate that the field shows the currently set value in the dataclass."""
        for name in self.fields.keys():
            self.fields[name].reset()
    
    def update_widget_from_model(self, name: str):
        """ Updates the edit-widget of the given field from the dataclass values. 
        
        Args:
            name (str): name of the field in the dataclass
        """
        if self.model is None:
            raise ValueError("No dataclass instance attached to the UI.")

        value = getattr(self.model, name)
        if value is not None:
            self.fields[name].set_value(value)
    
    def update_model_from_widget(self, name: str):
        """ Updates the dataclass value of the given field from the edit-widget. 
        
        Args:
            name (str): name of the field in the dataclass
        """
        if self.model is None:
            raise ValueError("No dataclass instance attached to the UI.")

        field: FieldUI = self.fields[name]

        if not field.modified:  # avoid replacing 'None' with widget defaults
            LOGGER.debug(f"Field {name} was not modified.")
            return 

        value = field.get_value()
        setattr(self.model, name, value)

    def update_ui(self):
        """ Updates all edit-widgets from the dataclass values. """
        for name in self.fields.keys():
            self.update_widget_from_model(name)

    def update_model(self):
        """ Updates all dataclass fields from the current edit-widget values. """
        for name in self.fields.keys():
            self.update_model_from_widget(name)

    def validate(self, only_modified: bool = False):
        """ Checks all edit-widgets for valid choices. 
        
        The validation function NEED to return a truthy value, if 
        the choice is valid. If it is invalid, they can either return a falsy
        value or raise an exception. In the latter case, this exception will be
        printed instead of the default message.
        """
        if self.model is None:
            raise ValueError("No dataclass instance attached to the UI.")

        invalid_fields_str = []
        for name in self.fields.keys():
            field = self.model.__dataclass_fields__[name]
            field_ui = self.fields[field.name]
            if only_modified and not field_ui.modified:
                continue
            
            validate_function = field.metadata.get("validate")
            if validate_function is not None:
                value = self.fields[field.name].get_value()

                validation_result = False
                try: 
                    validation_result = validate_function(value)
                except ValueError as e:
                    invalid_fields_str.append(f"{field_ui.label.text()}: {str(e)}")
                else: 
                    if not validation_result:
                        invalid_fields_str.append(f"{field_ui.label.text()}: {value} is not a valid choice.")

        if invalid_fields_str:
            full_str = "\n".join(invalid_fields_str)
            raise ValueError(f'The following fields contain wrong values:\n{full_str}')


class DataClassTabbedUI():
    """ Controller for the UI representation of a dataclass, containing other dataclasses as fields. 
    It contains a tabbed layout that can be added to any QWidget.   
    """

    def __init__(self, 
                dclass: object | type, 
                layout: QtWidgets.QLayout  | None = None, 
                widget: QtWidgets.QTabWidget | None = None
        ):
        """ Builds a DataClassTabbedUI from a dataclass, using DataClassUIs as widgets in the tabs.
        NOTE: As with the DataClassUI, the dataclass (`dclass`) is not automatically
              attached to the resulting class, 
        
        Args:
            dclass (object | type): dataclass instance or class. 
                                    Needs to contain dataclasses as fields, which will converted to tabs.
            layout (QtWidgets.QLayout | None): Layout to add the UI widget to. 
                                               If None, a new QVBoxLayout is created.
            widget (QtWidgets.QTabWidget | None): TabWidget to add the UI tabs to.
                                                  If None, a new QTabWidget is created.
                                                  The widget is always added to the layout.
        """
        self.layout: QtWidgets.QLayout = layout or QtWidgets.QVBoxLayout()
        self.widget: QtWidgets.QTabWidget = widget or QtWidgets.QTabWidget()
        self._dataclass_uis: dict[str, DataClassUI] = {}

        self.layout.addWidget(self.widget)

        fields = [field.name for field in dc.fields(dclass) if field.name[0] != "_"]
        for field_name in fields:
            sub_dclass = getattr(dclass, field_name)
            dataclass_ui = DataClassUI(
                field_definitions=[FieldUIDef(field.name) for field in dc.fields(sub_dclass) if field.name[0] != "_"],
                dclass=sub_dclass,
            )
            ui_widget = QtWidgets.QWidget()
            ui_widget.setLayout(dataclass_ui.layout)
            self.widget.addTab(ui_widget, field_name.capitalize())
            self._dataclass_uis[field_name] = dataclass_ui
        
        self._model: object = None
        if not isinstance(dclass, type):
            self.model = dclass  # also updates ui with values
        
    @property
    def model(self):
        return self._model
    
    @model.setter
    def model(self, model: object):
        """ Sets the model and updates the dataclass_uis. 
        This does not add new dataclass_uis to the layout and throws errors 
        if fields in the model are missing. """
        self._model = model
        for name, dataclass_ui in self._dataclass_uis.items():
            dataclass_ui.model = getattr(model, name)
            dataclass_ui.update_ui()

    # Function to pass on to dataclass_uis ---
    def update_ui(self):
        for dataclass_ui in self._dataclass_uis.values():
            dataclass_ui.update_ui()

    def update_model(self):
        for dataclass_ui in self._dataclass_uis.values():
            dataclass_ui.update_model()
    
    def validate(self, only_modified: bool = False):
        for dataclass_ui in self._dataclass_uis.values():
            dataclass_ui.validate(only_modified=only_modified)
    
    def reset_labels(self):
        for dataclass_ui in self._dataclass_uis.values():
            dataclass_ui.reset_labels()


# Helpers for getter/setter and widget type ------------------------------------

TYPE_TO_WIDGET_MAP = {
    int: view.QFullIntSpinBox,  # Maybe just use QLineEdit as well?
    float: QtWidgets.QLineEdit,
    str: QtWidgets.QLineEdit,
    Path: QtWidgets.QLineEdit,
    bool: QtWidgets.QCheckBox,
}


def build_getter_setter(widget: QtWidgets.QWidget, field_type: type) -> tuple[Callable, Callable]:
    """ Getter/Setter Factory for widgets. 
    
    Args:
        widget (QtWidgets.QWidget): The widget to get/set the value of.
        field_type (type): The type of the dataclass field.
    """
    if isinstance(widget, QtWidgets.QCheckBox):
        def get_value() -> bool:
            return widget.isChecked()

        def set_value(value: bool):
            widget.setChecked(value)
    
    elif isinstance(widget, QtWidgets.QSpinBox):
        def get_value():
            return field_type(widget.value())

        def set_value(value):
            widget.setValue(value)
        
    else:   # Any kind of widget should be able to handle strings.      
        def get_value():
            if not widget.text():
                return None
            return field_type(widget.text())

        def set_value(value):
            widget.setText(str(value))
    return get_value, set_value        


def run_dialog(dialog: file_dialogs.OpenFilesDialog, get_value: Callable, set_value: Callable):
    """ Asks the user to select a directory/file, using the `get_value` function 
    o determine the default directory and passing the result to `set_value` if not `None`.

    Args:
        dialog (file_dialogs.OpenFilesDialog): The dialog to run.
        get_value (Callable): The function to get the last value, e.g. from a widget.
        set_value (Callable): The function to set the value returned from the dialog.
    """
    old_path: Path = get_value()
    if old_path is not None:
        old_path.parent 
    else:
        old_path = Path() 
    path = dialog(directory=old_path).run_selection_dialog()
    if path is not None:
        set_value(path)


def get_dataclass_types(dclass: type | object, names: Sequence[str]):
    """ 
    Returns a dictionary mapping field names to their associated types.

    Why this weird way?
    First of all, we avoided the cyclic imports of type-hints by using 
    the `from __future__ import annotations`, but whith 
    this, all type-hints on the dataclasses become `ForwardReferences`, 
    i.e. strings. So if we get them from the dataclass-fields
    via `field.pytype` or `field.pytype.__args__`, we cannot instanciate them later on.

    `get_type_hints()` from `typing` solves this problem, but it 
    evaluates the type-hints of all fields in the dataclass. 
    But this can cause again import errors, because the classes were not actually imported
    (this is why there are no cyclic imports)!

    So this function allows using `get_type_hints()` on the dataclass 
    itself, but only gets the types of the fields that we need, in the hopes 
    that there is no import problem with those.
    
    In the use-case of the DataClass UI here, we should only have very basic 
    types for the fields, so this should be fine.
    
    If you are here because there is, you need to redesign your dataclass/imports.
    Sorry.

    Args:
        dclass (type): The data class to extract field types from.
        names (Sequence[str]): The names of the fields to extract types from.

    Returns:
        Dict[str, type]: A dictionary mapping field names to their associated types. 
    """
    required_annotations = {
        name: value for name, value in dict(dclass.__annotations__).items() if name in names
    }
    class SafeMock:
        __annotations__ = required_annotations
    return get_type_hints(SafeMock)
