# Copyright (c) 2011-2014, B.I.Stepanov Institute of Physics, National Academy
# of Sciences of Belarus.
#
# 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.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 51
# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import datetime

from PyQt4.QtCore import *
from PyQt4.QtGui import *

from common.utils import gui
from common.utils import txt

from common.DataTableWidget import *
from common.StatusSignalingWidget import *

from PhotometerInput import *

__all__ = ['PhotometerTableWidget']

# *****************************************************************************
class PhotometerTableWidget(StatusSignalingWidget):
    """Widget responsible for selection of photometer input data for the manual
    version of aerosol profile retrieval algorithm."""

    # ---- Private class attributes -------------------------------------------
    # Maximal offset that is allowed between synchronized lidar and photometer
    # measurements. Used in 'updateSynchronizationHints'.
    lidarSynchronizationTolerance = datetime.timedelta(hours = 12)

    # ---- Public methods -----------------------------------------------------
    def __init__(self, parent = None):
        StatusSignalingWidget.__init__(self, parent)

        # ---- Members ----
        # This will be a list of 'PhotometerInput' instances.
        self.dataList = []

        # Icon cache for lidar synchonization and AOT correction table columns.
        self.matchIcon = gui.loadIcon('arrow-grey')
        self.bestMatchIcon = gui.loadIcon('arrow-green')

        self.equalsIcon = gui.loadIcon('equals')
        self.notEqualIcon = gui.loadIcon('not-equal')
        self.equalsIconRed = gui.loadIcon('equals-red')
        self.notEqualIconRed = gui.loadIcon('not-equal-red')
        self.warningIcon = gui.loadIcon('warning')

        # Indices of first and last table rows that have lidar synchronization
        # icons, or 'None' if none of the rows have these icons. Used in
        # 'updateSynchronizationHints' to speed up cleaning of the icon column.
        self.firstMatchRow = None
        self.lastMatchRow = None
        # Index of the table row that best matches the selected lidar data.
        # This is used by 'updateSynchronizationHints' to pass this parameter
        # to 'onScrollToBestMatch'.
        self.bestMatchRow = None

        # ---- Layout ----
        layout = QVBoxLayout()

        self.table = DataTableWidget()
        self.table.itemSelectionChanged.connect(self.onSelectionChanged)
        self.table.itemChanged.connect(self.onItemChanged)
        self.table.itemEditingStarted.connect(self.onItemEditingStarted)
        self.table.itemEditingFinished.connect(self.onItemEditingFinished)
        layout.addWidget(self.table)

        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        # ---- Initialization -------------------------------------------------
        icon355  = gui.loadIcon('button-purple')
        icon532  = gui.loadIcon('button-green')
        icon1064 = gui.loadIcon('button-red')

        self.table.setColumns([
            # This will be the lidar synchronization hint icon column.
            TableWidgetColumn(None, '',  None,
                'Lidar synchronization markers'),

            TableWidgetColumn('date', 'Date', 'date',
                'Date of the photometer measurement'),
            TableWidgetColumn('time', 'Time', 'time',
                'Time of the photometer measurement'),

            TableWidgetColumn('aot675', 'AOT 675', '%.3f',
                'Photometer aerosol optical thickness at 675 nm'),
            TableWidgetColumn('aot675Corr', 'AOT corr', '%.3f',
                'Manually corrected aerosol optical thickness at 675 nm'),

            TableWidgetColumn('vFineCorr', 'V-F', '%.3f',
                u'Volumetric concentration of fine aerosol mode (\u03bcm)'),
            TableWidgetColumn('vCoarseCorr', 'V-C', '%.3f',
                u'Volumetric concentration of coarse aerosol mode (\u03bcm)'),
            TableWidgetColumn('vSphericalCorr', 'V-Cs', '%.3f',
                u'Volumetric concentration of coarse spherical aerosol mode '
                u'(\u03bcm)'),
            TableWidgetColumn('vSpheroidCorr', 'V-Cns', '%.3f',
                u'Volumetric concentration of coarse non-spherical aerosol '
                u'mode (\u03bcm)'),

            TableWidgetColumn('sphericity', '%sph', '%.1f',
                'Fraction of spherical aerosol particles (%)'),

            TableWidgetColumn('evRatio2Modes', 'EVR2', '%.1e',
                'Eigenvalue ratio for the 2-mode aerosol backscatter matrix'),
            TableWidgetColumn('evRatio3Modes', 'EVR3', '%.1e',
                'Eigenvalue ratio for the 3-mode aerosol backscatter matrix'),

            TableWidgetColumn('wl355', 'Wave', '%.1f',
                'Wavelength (nm)', icon355),
            TableWidgetColumn('re355', 'Re', '%.2f',
                'Real part of aerosol refractive index', icon355),
            TableWidgetColumn('im355', 'Im', '%.3f',
                'Imaginary part of aerosol refractive index', icon355),
            TableWidgetColumn('ext355Corr', 'ext', '%.3f',
                'Total aerosol extinction (optical depth)', icon355),
            TableWidgetColumn('ssa355', 'ssa', '%.3f',
                'Aerosol single scattering albedo', icon355),
            TableWidgetColumn('f11_355', 'F11', '%.3f',
                'Main component of aerosol scattering matrix', icon355),
            TableWidgetColumn('f22by11_355', 'F2/1', '%.3f', 'Normalized '
                'second component of aerosol scattering matrix', icon355),

            TableWidgetColumn('wl532', 'Wave', '%.1f',
                'Wavelength (nm)', icon532),
            TableWidgetColumn('re532', 'Re', '%.2f',
                'Real part of aerosol refractive index', icon532),
            TableWidgetColumn('im532', 'Im', '%.3f',
                'Imaginary part of aerosol refractive index', icon532),
            TableWidgetColumn('ext532Corr', 'ext', '%.3f',
                'Total aerosol extinction (optical depth)', icon532),
            TableWidgetColumn('ssa532', 'ssa', '%.3f',
                'Aerosol single scattering albedo', icon532),
            TableWidgetColumn('f11_532', 'F11', '%.3f',
                'Main component of aerosol scattering matrix', icon532),
            TableWidgetColumn('f22by11_532', 'F2/1', '%.3f', 'Normalized '
                'second component of aerosol scattering matrix', icon532),

            TableWidgetColumn('wl1064', 'Wave', '%.1f',
                'Wavelength (nm)', icon1064),
            TableWidgetColumn('re1064', 'Re', '%.2f',
                'Real part of aerosol refractive index', icon1064),
            TableWidgetColumn('im1064', 'Im', '%.3f',
                'Imaginary part of aerosol refractive index', icon1064),
            TableWidgetColumn('ext1064Corr', 'ext', '%.3f',
                'Total aerosol extinction (optical depth)', icon1064),
            TableWidgetColumn('ssa1064', 'ssa', '%.3f',
                'Aerosol single scattering albedo', icon1064),
            TableWidgetColumn('f11_1064', 'F11', '%.3f',
                'Main component of aerosol scattering matrix', icon1064),
            TableWidgetColumn('f22by11_1064', 'F2/1', '%.3f', 'Normalized '
                'second component of aerosol scattering matrix', icon1064)
        ])

        # Index of the editable AOT 675 column.
        self.aot675CorrColumn = self.table.getColumnIndexByAttributeName(
            'aot675Corr')
        self.aot675CorrFormat = self.table.getColumnByAttributeName(
            'aot675Corr').formatString

    def getTableWidget(self):
        return self.table

    @staticmethod
    def getIconColumn():
        """Return index of the table column holding lidar synchronization hint
        icons."""
        return 0

    def connectDataList(self, photometerDataList):

        # Suspend the selection changed signal while the table cleans up.
        with gui.SignalBlocker(self.table):
            # Clear the table widget.
            self.table.clearTable()

            # Clear variables that depend on table's content.
            self.firstMatchRow = None
            self.lastMatchRow = None
            self.bestMatchRow = None

        self.dataList = photometerDataList

        if len(self.dataList) == 0:
            self.setErrorMessage(
                'Photometer data file contains no data records')
            return

        # Suspend signals intended for user interaction processing, while the
        # table is being populated.
        with gui.SignalBlocker(self.table):

            # From this time on, no error checking is performed.
            self.table.populateTable(self.dataList)

            for i in range(len(self.dataList)):

                # Create table items for the icon column.
                iconItem = QTableWidgetItem()
                iconItem.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
                self.table.setItem(i, self.getIconColumn(), iconItem)

                # Apply separate style settings to the editable AOT 675 column.
                aot675Item = self.table.item(i, self.aot675CorrColumn)
                aot675Item.setFlags(aot675Item.flags() | Qt.ItemIsEditable)
                aot675Item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)

                # Stick to the static AOT 675 if both values look the same.
                if (self.aot675CorrFormat % self.dataList[i].aot675Corr ==
                    self.aot675CorrFormat % self.dataList[i].aot675):
                    self.dataList[i].aot675Corr = self.dataList[i].aot675

                # Initialize the editable column's icon.
                self.updateAot675CorrIcon(i)

        # Show the error message reporting lack of selected data.
        self.onSelectionChanged()

    def getData(self):
        """Return a 'PhotometerInput' instance or 'None' if the photometer
        measurement is bad or not selected."""

        selectedRow = self.table.getSelectedRow()

        if selectedRow is None or self.getStatusMessage() is not None:
            return None

        return self.dataList[selectedRow]

    def updateSynchronizationHints(self, startDateTime, stopDateTime):
        """Update lidar synchronization icons according to the given lower and
        upper bounds of the lidar measurement time, and then update table's
        scrolling so that row that best matches the measurement time interval
        will appear at the center of the displayed table area.

        If 'startDateTime' and 'stopDateTime' are 'None', just clear the
        synchronization icons and leave table's scrolling position intact."""

        # Do nothing if the table is empty.
        if len(self.dataList) == 0:
            return

        # Clear the synchonization hint icons, if any. Use cached boundary
        # indices to speed up the process.
        if self.firstMatchRow is not None:

            with gui.SignalBlocker(self.table):
                for row in range(self.firstMatchRow, self.lastMatchRow + 1):
                    self.table.item(row, self.getIconColumn()).setIcon(QIcon())

        self.bestMatchRow = None

        # If there are no lidar data selected, the icon column is left blank.
        if startDateTime is None and stopDateTime is None:
            return

        # Make sure that input data are correct, just to feel safe.
        assert stopDateTime >= startDateTime

        # Boundaries of the time interval, any point of which is at most
        # 'lidarSynchronizationTolerance' away from any portion of the selected
        # lidar measurement. The longer is the lidar measurement, the smaller
        # is the allowed photometer time interval. Any photometer measurement
        # within this time interval will be marked with an icon.
        minDateTime = stopDateTime - self.lidarSynchronizationTolerance
        maxDateTime = startDateTime + self.lidarSynchronizationTolerance

        # Data table rows that fall within the boundaries defined above.
        matchingRows = []

        # Median time of the lidar measurement (goal time for the best-matching
        # photometer measurement).
        matchDateTime = startDateTime + (stopDateTime - startDateTime) / 2

        # Row of the best-matching photometer measurement (the nearest one with
        # respect to 'matchDateTime'). Unlike boundary-matching rows defined
        # above, this one will always exist.
        self.bestMatchRow = 0
        bestTimeDelta = abs(matchDateTime - self.dataList[0].getDateTime())

        # Look for boundary-matching rows and determine the best-matching one.
        for row in range(len(self.dataList)):

            dateTime = self.dataList[row].getDateTime()

            if minDateTime <= dateTime <= maxDateTime:
                matchingRows.append(row)

            timeDelta = abs(matchDateTime - dateTime)

            if timeDelta < bestTimeDelta:
                self.bestMatchRow = row
                bestTimeDelta = timeDelta

        # Store the found boundaries for the lidar-matching rows.
        if len(matchingRows) == 0:
            self.firstMatchRow = None
            self.lastMatchRow = None
        else:
            self.firstMatchRow = matchingRows[0]
            self.lastMatchRow = matchingRows[-1]

        # If there are matching rows, mark them with icons.
        if self.firstMatchRow is not None:
            with gui.SignalBlocker(self.table):
                for row in range(self.firstMatchRow, self.lastMatchRow + 1):

                    iconItem = self.table.item(row, self.getIconColumn())

                    # Use plain icons for ordinary matching rows, and a special
                    # one for the best-matching row. Note: if matching rows
                    # exist, the best-matching row will be one of them.
                    if row == self.bestMatchRow:
                        iconItem.setIcon(self.bestMatchIcon)
                    else:
                        iconItem.setIcon(self.matchIcon)

        # If there are no boundary-matching rows, still scroll to the
        # best-matching one.
        #
        # For some reason, the 'scrollToItem' function doesn't work if called
        # from within the same window system event where table population has
        # orrured. Postpone invocation of this method until the next window
        # system event to address this issue.
        QTimer.singleShot(0, self.onScrollToBestMatch)

    # ---- Private methods ----------------------------------------------------
    def updateAot675CorrIcon(self, row):
        """Set the editable AOT 675 item icon based on its data:
          - set the icon to a warning sign if the value entered is not a valid
            floating point number;
          - set the icon to a not equal sign if the editable AOT 675 value is
            different from the static one;
          - set the icon color to red if the editable value is different from
            the original one."""

        assert len(self.dataList) == self.table.rowCount() > row

        input = self.dataList[row]

        if input.aot675Corr is None:
            icon = self.warningIcon
        elif input.aot675 == input.aot675Corr == input.aot675CorrOriginal:
            icon = self.equalsIcon
        elif input.aot675 != input.aot675Corr == input.aot675CorrOriginal:
            icon = self.notEqualIcon
        elif input.aot675 == input.aot675Corr != input.aot675CorrOriginal:
            icon = self.equalsIconRed
        elif input.aot675 != input.aot675Corr != input.aot675CorrOriginal:
            icon = self.notEqualIconRed

        self.table.item(row, self.aot675CorrColumn).setIcon(icon)

    def updateErrorMessage(self):
        """Check if a completely valid photometer input data is currently
        selected in the widget and set the error message appropriately."""

        # Assure that the table contains data, so that error message set in
        # 'connectDataList' won't get overwritten.
        assert len(self.dataList) == self.table.rowCount() > 0

        selectedRow = self.table.getSelectedRow()
        if selectedRow is None:
            self.setErrorMessage('Photometer mesurement is not selected')
            return

        if self.dataList[selectedRow].aot675Corr is None:
            self.setErrorMessage('Photometer input: the value in the %s '
                'column is not a valid floating point number' %
                txt.quote(self.table.getColumnByAttributeName(
                    'aot675Corr').columnLabel))
            return

        self.clearStatusMessage()

    # ---- Private slots ------------------------------------------------------
    def onItemChanged(self, item):
        """Process user interaction in the editable AOT 675 column."""

        assert len(self.dataList) == self.table.rowCount() > item.row()
        assert item.column() == self.aot675CorrColumn

        inputData = self.dataList[item.row()]

        itemValue = txt.stringToFloat(item.text())

        if itemValue is not None:
            # Read the value entered by the user.
            inputData.aot675Corr = itemValue

        else:
            # This should be replaced with something more elaborate some time.
            QMessageBox.warning(self, 'AOT corr',
                'The value entered is not a valid floating point number')

        # Suspend signals to prevent recursion. Thus the actually entered value
        # of the modified attribute won't get overwritten.
        with gui.SignalBlocker(self.table):

            # Stick to the static AOT 675 if both values look the same.
            if (self.aot675CorrFormat % inputData.aot675Corr ==
                self.aot675CorrFormat % inputData.aot675):
                inputData.aot675Corr = inputData.aot675
            # Stick to the original AOT 675 if both values look the same.
            if (self.aot675CorrFormat % inputData.aot675Corr ==
                self.aot675CorrFormat % inputData.aot675CorrOriginal):
                inputData.aot675Corr = inputData.aot675CorrOriginal

            inputData.updateCorrectedThicknesses()

            # Format the entered value in the uniform way and update the
            # values in dependent table columns.
            self.table.updateTableRow(item.row(), inputData)

            # Update the icon.
            self.updateAot675CorrIcon(item.row())

        self.updateErrorMessage()

    def onSelectionChanged(self):
        self.updateErrorMessage()

    def onItemEditingStarted(self):
        # Make the selected photometer input data unavailable while it's in
        # indefinite (and possibly invalid) state.
        self.setInfoMessage('Press Enter to apply editing or Esc to cancel')

    def onItemEditingFinished(self):
        # Make the data available again, if possible.
        self.updateErrorMessage()

    def onScrollToBestMatch(self):
        # Scroll the table so that 'self.bestMatchRow' appears at its center.
        scrollItem = self.table.item(self.bestMatchRow, self.getIconColumn())
        self.table.scrollToItem(scrollItem, QAbstractItemView.PositionAtCenter)
