# -*- coding: utf-8 -*-
"""
/***************************************************************************
 FieldStats
                                 A QGIS plugin
 This pluggins calculate basic stats and graph histogram and boxplot
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                              -------------------
        begin                : 2024-06-07
        git sha              : $Format:%H$
        copyright            : (C) 2024 by Manuel Alejandro Montealegre Martínez
        email                : manuel.montealegre@udea.edu.co
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, Qt
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction, QApplication, QFileDialog, QTableWidgetItem

# Import the code for the dialog
from .field_stats_dialog import FieldStatsDialog
import os.path

# Import qgis core complete, pandas and matplotlib.
# These are wrapped in try/except so that a missing dependency (pandas or
# matplotlib not installed in QGIS's Python environment) does not prevent
# the plugin itself from loading. DEPENDENCIAS_DISPONIBLES is checked in
# run(), where a clear message is shown instead of QGIS's generic
# "couldn't load plugin" error.
from qgis.core import *
try:
    import matplotlib.pyplot as plt
    import pandas as pd
    # Try qt6 if dont work try qt5 for qgis 4 and 3
    try:
        from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
    except ImportError:
        from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
    DEPENDENCIAS_DISPONIBLES = True
    ERROR_DEPENDENCIAS = None
except ImportError as error:
    DEPENDENCIAS_DISPONIBLES = False
    ERROR_DEPENDENCIAS = str(error)

class FieldStats:
    """QGIS Plugin Implementation."""

    # Field Stats v0.3+ color palette, used for the boxplot/histogram graph
    COLOR_CAJA = '#339999'      # teal - boxplot/histogram fill
    COLOR_MEDIA = '#8B0000'     # darkred - mean line/point
    COLOR_Q1 = '#FFD700'        # gold - Q1 line
    COLOR_Q3 = '#4B0082'        # indigo - Q3 line
    COLOR_ATIPICOS = '#B22222'  # firebrick - outlier points

    # Row labels for tblResultados, in the same order returned by
    # estadisticas_num().
    ETIQUETAS_ESTADISTICAS = [
        'Registros', 'Nulos', 'Suma', 'Media', 'SD (muestra)',
        'Mín', 'Q1 (0.25)', 'Q2 (0.5)', 'Q3 (0.75)', 'Máx'
    ]

    def __init__(self, iface):
        """Constructor.

        :param iface: An interface instance that will be passed to this class
            which provides the hook by which you can manipulate the QGIS
            application at run time.
        :type iface: QgsInterface
        """
        # Save reference to the QGIS interface
        self.iface = iface
        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)
        # initialize locale
        locale = QSettings().value('locale/userLocale')[0:2]
        locale_path = os.path.join(
            self.plugin_dir,
            'i18n',
            'FieldStats_{}.qm'.format(locale))

        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        # Declare instance attributes
        self.actions = []
        self.menu = self.tr(u'&Field Stats')

        # Check if plugin was started the first time in current QGIS session
        # Must be set in initGui() to survive plugin reloads
        self.first_start = None

    # noinspection PyMethodMayBeStatic
    def tr(self, message):
        """Get the translation for a string using Qt translation API.

        We implement this ourselves since we do not inherit QObject.

        :param message: String for translation.
        :type message: str, QString

        :returns: Translated version of message.
        :rtype: QString
        """
        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
        return QCoreApplication.translate('FieldStats', message)


    def add_action(
        self,
        icon_path,
        text,
        callback,
        enabled_flag=True,
        add_to_menu=True,
        add_to_toolbar=True,
        status_tip=None,
        whats_this=None,
        parent=None):
        """Add a toolbar icon to the toolbar.

        :param icon_path: Path to the icon for this action. Can be a resource
            path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
        :type icon_path: str

        :param text: Text that should be shown in menu items for this action.
        :type text: str

        :param callback: Function to be called when the action is triggered.
        :type callback: function

        :param enabled_flag: A flag indicating if the action should be enabled
            by default. Defaults to True.
        :type enabled_flag: bool

        :param add_to_menu: Flag indicating whether the action should also
            be added to the menu. Defaults to True.
        :type add_to_menu: bool

        :param add_to_toolbar: Flag indicating whether the action should also
            be added to the toolbar. Defaults to True.
        :type add_to_toolbar: bool

        :param status_tip: Optional text to show in a popup when mouse pointer
            hovers over the action.
        :type status_tip: str

        :param parent: Parent widget for the new action. Defaults None.
        :type parent: QWidget

        :param whats_this: Optional text to show in the status bar when the
            mouse pointer hovers over the action.

        :returns: The action that was created. Note that the action is also
            added to self.actions list.
        :rtype: QAction
        """

        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if status_tip is not None:
            action.setStatusTip(status_tip)

        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            # Adds plugin icon to Plugins toolbar
            self.iface.addToolBarIcon(action)

        if add_to_menu:
            self.iface.addPluginToVectorMenu(
                self.menu,
                action)

        self.actions.append(action)

        return action

    def initGui(self):
        """Create the menu entries and toolbar icons inside the QGIS GUI."""

        icon_path = os.path.join(self.plugin_dir, 'icon.png')
        action = self.add_action(
            icon_path,
            text=self.tr(u'Calculate stats'),
            callback=self.run,
            parent=self.iface.mainWindow())

        try:
            tipo_capa_vector = Qgis.LayerType.Vector
        except AttributeError:
            tipo_capa_vector = QgsMapLayer.VectorLayer
        self.iface.addCustomActionForLayerType(
            action,
            '',               # empty menu name: only add it to the context
                              # menu, not to an extra menu in the main window
            tipo_capa_vector,
            True              # allLayers=True: available for every vector
                              # layer automatically, current and future
        )
        # Keep a reference so unload() can unregister it
        self.context_menu_action = action

        # will be set False in run()
        self.first_start = True


    def unload(self):
        """Removes the plugin menu item and icon from QGIS GUI."""
        for action in self.actions:
            self.iface.removePluginVectorMenu(
                self.tr(u'&Field Stats'),
                action)
            self.iface.removeToolBarIcon(action)
        # Unregister the layer right-click context menu entry
        if getattr(self, 'context_menu_action', None) is not None:
            self.iface.removeCustomActionForLayerType(self.context_menu_action)


    def run(self):
        """Run method that performs all the real work"""

        # Create the dialog with elements (after translation) and keep reference
        # Only create GUI ONCE in callback, so that it will only load when the plugin is started
        if self.first_start == True:
            self.first_start = False

        # Stop here with a clear message if pandas/matplotlib are missing,
        # instead of letting the dialog open and crash on first use.
        if not DEPENDENCIAS_DISPONIBLES:
            self.iface.messageBar().pushCritical(
                self.tr(u'Field Stats'),
                self.tr(
                    u'Faltan paquetes de Python requeridos (pandas y/o '
                    u'matplotlib). Instálalos en el entorno de Python de '
                    u'QGIS y vuelve a intentarlo. Detalle: {}'
                ).format(ERROR_DEPENDENCIAS)
            )
            return

        self.dlg = FieldStatsDialog()
        # Filter layers to show vector type only
        self.dlg.cmbCapas.setFilters(QgsMapLayerProxyModel.VectorLayer)
        # Add ComboBoxField Conector
        self.dlg.cmbCapas.layerChanged.connect(self.dlg.cmbCampos.setLayer)
        # Filter numeric fields only
        self.dlg.cmbCampos.setFilters(QgsFieldProxyModel.Numeric)
        
        capa_activa = self.iface.activeLayer()
        if capa_activa is not None:
            # active layer
            self.dlg.cmbCapas.setLayer(capa_activa)
            # fields from active layer
            self.dlg.cmbCampos.setLayer(capa_activa)
        # Check if there is selected rows
        self.dlg.chcSeleccion.stateChanged.connect(self.btn_calcular_click)
        # Calculate stats when click on button
        self.dlg.btnCalcular.clicked.connect(self.btn_calcular_click)
        # round decimal places
        self.dlg.spBoxDecimales.valueChanged.connect(self.num_decimales)
        # copy results table to clipboard
        self.dlg.btnCopiar.clicked.connect(self.copiar_resultados)
        # export graph to file
        self.dlg.btnExportar.clicked.connect(self.exportar_grafica)

        # show the dialog
        self.dlg.show()
        # Run the dialog event loop.
        if hasattr(self.dlg, 'exec'):
            result = self.dlg.exec()
        else:
            result = self.dlg.exec_()
        # See if OK was pressed
        if result:
            # Do something useful here - delete the line containing pass and
            # substitute with your code.
            pass
    
    # Methods to get info
    # To get values from field
    def obtener_valores_de_campo(self):
        """
        obtener_valores_de_campo
        return a Pandas data series with values from selected field, or
        None if there is no layer or no numeric field to read from (e.g.
        the layer has no numeric fields, so cmbCampos is empty).
        """
        capa = self.dlg.cmbCapas.currentLayer()
        campo_seleccionado = self.dlg.cmbCampos.currentField()
        if capa is None or not campo_seleccionado:
            return None
        # if only selected is enable
        if self.dlg.chcSeleccion.isChecked() == True:
            # list of values from selected rows of selected field
            datos = QgsVectorLayerUtils.getValues(layer = capa,
                                                  fieldOrExpression = campo_seleccionado,
                                                  selectedOnly = True)[0] # True for selected Only
            # Make an empty dataFrame
            df_datos = pd.DataFrame()
            # Add values and name field to empty dataFrame
            df_datos[campo_seleccionado] = datos
            # Change de Pandas DataFrame to Pandas numeric DataSeries
            datos = pd.to_numeric(df_datos[campo_seleccionado], errors='coerce')
            # Return the Pandas DataSeries
            return datos
        # If only selected is dissable
        else:
            # list of values from selected rows of selected field
            datos = QgsVectorLayerUtils.getValues(layer = capa,
                                                  fieldOrExpression = campo_seleccionado,
                                                  selectedOnly = False)[0] #False to take all values
            # Make an empty dataFrame
            df_datos = pd.DataFrame()
            # Add values and name field to empty dataFrame
            df_datos[campo_seleccionado] = datos
            # Change de Pandas DataFrame to Pandas numeric DataSeries
            datos = pd.to_numeric(df_datos[campo_seleccionado], errors='coerce')
            # Return the Pandas DataSeries
            return datos

    # Method to pick the right "no data" message depending on why
    # obtener_valores_de_campo() returned None.
    def mensaje_sin_datos(self):
        """
        mensaje_sin_datos
        Return the most helpful "no data" message for the current state of
        cmbCapas/cmbCampos.
        """
        if self.dlg.cmbCapas.currentLayer() is None:
            return 'Seleccione una capa'
        elif not self.dlg.cmbCampos.currentField():
            return 'La capa no tiene campos numéricos'
        else:
            return 'No hay datos'

    # Method to make the stats when push button
    def btn_calcular_click(self):
        """
        btn_calcular_click
        display a message with stats and make the graphs
        """
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            # Use whatever decimal-places value the user currently has set,
            # instead of forcing it back to 6 on every calculation.
            decimales = self.dlg.spBoxDecimales.value()
            # Get values from selected field
            datos = self.obtener_valores_de_campo()
            # make stats
            if datos is None:
                self.mostrar_mensaje_tabla(self.mensaje_sin_datos())
                self.actualizar_grafica()
            else:
                estadisticas_campo = self.estadisticas_num(datos)
                # Round to the current decimal-places setting
                redondeo_estadisticas = [round(valor, decimales) for valor in estadisticas_campo]
                # Refresh results table
                self.actualizar_tabla_resultados(redondeo_estadisticas)
                # Refresh histogram and boxplot
                self.actualizar_grafica()
        except Exception as error:
            # Catch anything unexpected (e.g. a corrupt/locked layer, an
            # unusual field type) so it shows as a friendly message instead
            # of a raw Python traceback breaking the dialog.
            self.iface.messageBar().pushCritical(
                self.tr(u'Field Stats'),
                self.tr(u'No se pudieron calcular las estadísticas: {}').format(error)
            )
        finally:
            QApplication.restoreOverrideCursor()

    # Method to round to given decimal place
    def num_decimales(self):
        """
        num_decimales
        Refresh message and decimal places from stats
        """
        QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
        try:
            # Get the value from spBoxDecimales
            decimales = self.dlg.spBoxDecimales.value()
            # Get values from selected field
            datos = self.obtener_valores_de_campo()
            if datos is None:
                self.mostrar_mensaje_tabla(self.mensaje_sin_datos())
            else:
                # Calculate stats
                estadisticas_campo = self.estadisticas_num(datos)
                # Round stats to given decimal place
                redondeo_estadisticas = [round(valor, decimales) for valor in estadisticas_campo]
                # Refresh results table
                self.actualizar_tabla_resultados(redondeo_estadisticas)
        except Exception as error:
            self.iface.messageBar().pushCritical(
                self.tr(u'Field Stats'),
                self.tr(u'No se pudieron calcular las estadísticas: {}').format(error)
            )
        finally:
            QApplication.restoreOverrideCursor()

    # Method to fill tblResultados with a list of (already rounded) stats,
    # in the same order as ETIQUETAS_ESTADISTICAS / estadisticas_num().
    def actualizar_tabla_resultados(self, valores):
        """
        actualizar_tabla_resultados
        Fill tblResultados with the calculated stats.
        """
        tabla = self.dlg.tblResultados
        # Clear any leftover cell span from a previous call to
        # mostrar_mensaje_tabla() (e.g. "La capa no tiene campos
        # numéricos"), which merges row 0's two columns. setRowCount()
        # alone does NOT clear spans, so without this the "Registros" row
        # could stay merged and hide its value after switching from a
        # layer with no data to one with valid data.
        tabla.clearSpans()
        tabla.setRowCount(len(self.ETIQUETAS_ESTADISTICAS))
        for fila, etiqueta in enumerate(self.ETIQUETAS_ESTADISTICAS):
            valor = valores[fila]
            # Registros and Nulos are counts, show them without decimals
            if etiqueta in ('Registros', 'Nulos'):
                texto_valor = str(int(valor))
            else:
                texto_valor = str(valor)
            item_etiqueta = QTableWidgetItem(etiqueta)
            item_valor = QTableWidgetItem(texto_valor)
            item_valor.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
            tabla.setItem(fila, 0, item_etiqueta)
            tabla.setItem(fila, 1, item_valor)

    # Method to show a single message spanning tblResultados, used when
    # there is no data to display (e.g. empty layer/field).
    def mostrar_mensaje_tabla(self, mensaje):
        """
        mostrar_mensaje_tabla
        Show a single-row message across tblResultados.
        """
        tabla = self.dlg.tblResultados
        tabla.clearSpans()
        tabla.setRowCount(1)
        item = QTableWidgetItem(mensaje)
        item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
        tabla.setItem(0, 0, item)
        tabla.setSpan(0, 0, 1, 2)

    # Method to copy the results table to the clipboard as tab-separated text
    def copiar_resultados(self):
        """
        copiar_resultados
        Copy tblResultados content to the clipboard as tab-separated text,
        one row per line, so it can be pasted directly into a spreadsheet.
        """
        tabla = self.dlg.tblResultados
        filas = []
        for fila in range(tabla.rowCount()):
            item_etiqueta = tabla.item(fila, 0)
            item_valor = tabla.item(fila, 1)
            etiqueta = item_etiqueta.text() if item_etiqueta else ''
            valor = item_valor.text() if item_valor else ''
            filas.append('{}\t{}'.format(etiqueta, valor))
        QApplication.clipboard().setText('\n'.join(filas))

    # Method to export the current graph (boxplot + histogram) to a file
    def exportar_grafica(self):
        """
        exportar_grafica
        Save the current matplotlib figure to a file chosen by the user.
        """
        if not hasattr(self, 'figura') or self.figura is None:
            return
        ruta, _ = QFileDialog.getSaveFileName(
            self.dlg,
            self.tr(u'Exportar gráfica'),
            '',
            'PNG (*.png);;PDF (*.pdf);;SVG (*.svg)'
        )
        if ruta:
            self.figura.savefig(ruta, dpi=150, bbox_inches='tight')

    # Method to update stats
    def estadisticas_num(self, data_series):
        """
        estadisticas_num
        Get a pandas DataSeries to calc stats
        Return a list with stats
        """
        # Empty list
        lista_resultados = []
        # add total values (count)
        lista_resultados.append(data_series.size)
        # add count null or nan values
        lista_resultados.append(data_series.isnull().sum())
        # add Sum
        lista_resultados.append(data_series.sum())
        # Add media
        lista_resultados.append(data_series.mean())
        # Add standar deviation, calculate for 
        lista_resultados.append(data_series.std())
        # Add min
        lista_resultados.append(data_series.min())
        # Add Q1 quantil 0.25
        lista_resultados.append(data_series.quantile(q=0.25))
        # Add Q2 quantil 0.5
        lista_resultados.append(data_series.quantile(q=0.5))
        # Add Q3 quantil 0.75
        lista_resultados.append(data_series.quantile(q=0.75))
        # Add max
        lista_resultados.append(data_series.max())
        # Return list of stats
        return lista_resultados
    
    # Method to make a histogram and boxplot
    def agregar_grafica(self):
        """
        agregar_grafica
        Add axes to canvas to make histogram and boxplot
        """
        # clear all graph
        self.limpiar_grafica()
        # graph style
        estilos = plt.style.available
        # look for proper style
        if 'seaborn-v0_8-whitegrid' in estilos:
            plt.style.use('seaborn-v0_8-whitegrid')
        elif 'seaborn-whitegrid' in estilos:
            plt.style.use('seaborn-whitegrid')
        elif 'ggplot' in estilos:
            plt.style.use('ggplot')
        elif 'bmh' in estilos:
            plt.style.use('bmh')
        else:
            plt.style.use('classic')
        # add figure to canvas
        self.figura = plt.figure(figsize=(4, 2.8), tight_layout=True)
        # make an instance of FigureCanvas and add figure without axes
        self.canvas = FigureCanvas(self.figura)
        # add an instance of canvas
        self.dlg.graficaEstadistica.addWidget(self.canvas)        
        # add axes to make graph
        self.axes = self.canvas.figure.subplots(nrows = 2, # two rows
                                                ncols=1, # one column
                                                sharex=True, # share values of x axis
                                                gridspec_kw={"height_ratios": (.3, .7)} # boxplot in 30% and histogram in 70%
                                                )

    # Method to refresh boxplot and histogram
    def actualizar_grafica(self):
        """
        actualizar_grafica
        make a graph with boxplot and histogram
        """
        # add graph
        self.agregar_grafica()
        # get values to graph
        datos = self.obtener_valores_de_campo()
        # check if data is not empty
        if datos is None:
            self.agregar_grafica()
            # refreh graph
            self.canvas.draw()
        # check if values is not empty
        elif datos.size == 0:
            #add graph
            self.agregar_grafica()
            # refresh canvas
            self.canvas.draw()
        elif datos.isnull().all():
            # add graph
            self.agregar_grafica()
            # refresh canvas
            self.canvas.draw()
        else:
            boxplot_kwargs = dict(
                vert=False, # horizontal boxplot
                widths=(0.7), # set wide for boxplot
                showmeans=True, # show media value in boxplot
                meanprops={'marker': 'o',
                           'markersize': 3,
                           'markerfacecolor': self.COLOR_MEDIA,
                           'markeredgecolor':'none'}, # set color and size to media point
                patch_artist=True, # fill box, if false the box is empty
                boxprops = dict(facecolor = self.COLOR_CAJA), # set box color
                flierprops={'marker': 'o',
                            'markersize': 3,
                            'markerfacecolor': self.COLOR_ATIPICOS,
                            'markeredgecolor':'none'}, # set outliers color and size
                showfliers=True # show outliers, if False hide
            )
            try:
                self.axes[0].boxplot(datos, tick_labels=[''], **boxplot_kwargs)
            except TypeError:
                self.axes[0].boxplot(datos, labels=[''], **boxplot_kwargs)
            
            # Make axes with histogram
            self.axes[1].hist(datos, # values to histogram
                              bins='sqrt', # method for bins size
                              color = self.COLOR_CAJA # color graph
                              )
            
            # Add vertical line with mean value
            self.axes[1].axvline(x = datos.mean(), # value to graph: mean
                                 color = self.COLOR_MEDIA,
                                 linestyle='dashed',
                                 linewidth= 1,
                                 label='Media'
                                 )
            
            # Add vertical line with Q1 value
            self.axes[1].axvline(x = datos.quantile(0.25), # value to graph: Q1
                                 color = self.COLOR_Q1,
                                 linestyle='dashed',
                                 linewidth= 1,
                                 label='Q1'
                                 )
            
            # Add vertical line with Q3 value
            self.axes[1].axvline(x = datos.quantile(0.75), # value to graph: Q3
                                 color = self.COLOR_Q3,
                                 linestyle='dashed',
                                 linewidth= 1,
                                 label='Q3'
                                 )
            
            # Add title to histogram
            self.axes[1].set_title('Distribución de frecuencias', fontsize=9)
            self.axes[1].set_xlabel(datos.name, fontsize=9)
            self.axes[1].set_ylabel('Conteo', fontsize=9)
            self.axes[1].tick_params(axis='both', which='major', labelsize=9)
            self.axes[1].tick_params(axis='both', which='minor', labelsize=9)
            self.axes[1].legend(loc='best', fontsize='8')
            
            # Refresh plot
            self.canvas.draw()
    
    # Method to clean plot
    def limpiar_grafica(self):
        graficas_previas = []
        for i in range(self.dlg.graficaEstadistica.count()):
            grafica = self.dlg.graficaEstadistica.itemAt(i).widget()
            if grafica:
                graficas_previas.append(grafica)
        for grafica in graficas_previas:
            grafica.deleteLater()

