#  Copyright 2017 Carlos Pascual-Izarra <cpascual@users.sourceforge.net>
#
#  This file is part of PeakEvo.
#
#  PeakEvo 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 3 of the License, or
#  (at your option) any later version.
#
#  PeakEvo 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 Foobar.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import absolute_import

import datetime
import logging
import os
import sys
import time

import numpy
import pyqtgraph as pg
from qtpy import API, QtCore, QtGui, QtWidgets

from .acqsettings import AcqSettings
from .cachedict import CacheDict
from .configuration import BaseConfigurableClass
from .filetable import FilesTable
from .pressure import DummyPressureSensor, PressureSensor
from .y2axis import Y2ViewBox

# Set logging
logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)


class PeakEvoMain(QtWidgets.QMainWindow, BaseConfigurableClass):
    CACHE_SIZE = 1000

    def __init__(self, parent=None):
        QtWidgets.QMainWindow.__init__(self, parent=parent)
        BaseConfigurableClass.__init__(self)
        self._qsettings = QtCore.QSettings()

        curdir = os.path.dirname(os.path.realpath(__file__))
        QtCore.QDir.addSearchPath("icons", os.path.join(curdir, "icons"))
        self.cache = CacheDict(size_limit=self.CACHE_SIZE)

        self.background = 0.0  # background intensity array or 0
        self.reference = None  # reference intensity array or None
        self.toffset = None
        self._series_file = None
        self._pressureSensor = None

        self.captureTimer = QtCore.QTimer()

        self.filesTable = FilesTable()
        self.acqSettings = AcqSettings()
        self.spectrumPlot = pg.PlotWidget()
        self.seriesPlot = pg.PlotWidget()
        self.setCentralWidget(self.filesTable)
        self.toolBar = self.addToolBar("Main")
        self.toolBar.setObjectName("mainTB")

        self._initAcqSettings()
        try:
            self._initSpectrometer()
        except Exception:
            logging.info("Using dummy spectrometer")
            self._initSpectrometer_dummy()

        self._initPressureSensor()
        self._initSpectraPlot()
        self._initSeriesPlot()
        self._initToolBar()

        self.captureTimer.setInterval(int(self.acqSettings.acqPeriodSB.value() * 1000))
        self.captureTimer.start()

        # connect signals
        self.refreshAction.triggered.connect(self.onRefresh)
        self.liveSpectrumAction.triggered.connect(self.updateSpectraPlot)
        self.filesTable.table.itemSelectionChanged.connect(self.onSelectionChanged)
        self.acqAction.toggled.connect(self.onAcqAction)
        self.acqSettings.acqTimeSB.valueChanged[float].connect(self.onAcqTimeChanged)
        self.acqSettings.acqPeriodSB.valueChanged[float].connect(
            self.onAcqPeriodChanged
        )
        self.captureTimer.timeout.connect(self.onCapture)
        self.acqSettings.autoAcqTimeBT.clicked.connect(self.onAutoAcqTime)
        self.acqSettings.backgroundBT.clicked.connect(self._useAverage)
        self.acqSettings.referenceBT.clicked.connect(self._useAverage)
        self.acqSettings.pressurePortCB.activated[str].connect(
            self._resetPressureSensor
        )
        self.acqSettings.pCalibLE.textChanged.connect(self._updateCalib)

        # register config properties
        self.registerConfigDelegate(self.acqSettings, "acqSettings")
        # self.registerConfigProperty(self._get_background,
        #                             self._set_background,
        #                             'background')
        # self.registerConfigProperty(self._get_reference,
        #                             self._set_reference,
        #                             'reference')

        # Load config settings
        self.loadSettings()

    def _set_reference(self, reference):
        logging.debug("Using reference %s", reference)
        self.reference = reference

    def _get_reference(self):
        return self.reference

    def _set_background(self, background):
        logging.debug("Using background %s", background)
        self.background = background

    def _get_background(self):
        return self.background

    def _updateCalib(self, text):
        if not text.strip():
            self.acqSettings.pCalibLE.setText("1. 0.")
            return
        try:
            calib = numpy.fromstring(text, sep=" ", dtype="float64")
        except Exception:
            return
        if self._pressureSensor is not None:
            self._pressureSensor.calib = calib

    def _initAcqSettings(self):
        self.acqSettingsDW = dw = QtWidgets.QDockWidget("Acquisition Settings")
        dw.setWidget(self.acqSettings)
        dw.setObjectName("acqSettingsDW")
        self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, dw)
        dw.setFloating(True)
        dw.setAllowedAreas(QtCore.Qt.NoDockWidgetArea)
        dw.setVisible(False)

    def _initSpectrometer(self):
        from .spectrometer_sb import SBSpectrometer

        integ_time = self.acqSettings.acqTimeSB.value() * 1000
        self.spectrometer = SBSpectrometer(integ_time=integ_time)
        self.acqSettings.acqTimeSB.setMinimum(
            self.spectrometer.integration_time_micros_limits[0] // 1000
        )

    def _initSpectrometer_dummy(self):
        from .spectrometer_dummy import DummySpectrometer

        integ_time = self.acqSettings.acqTimeSB.value() * 1000
        self.spectrometer = DummySpectrometer(integ_time=integ_time)

    def _initPressureSensor(self):
        # populate the acqSettings port combobox
        from serial.tools.list_ports import comports

        ports = ["<Auto>", "<Dummy>", "<Disable>"] + [p.device for p in comports()]
        cb = self.acqSettings.pressurePortCB
        cb.clear()
        cb.addItems(ports)
        self._resetPressureSensor(cb.currentText())

    def _resetPressureSensor(self, port):
        if not port:
            return
        # stop the previous sensor (if any)
        if self._pressureSensor is not None:
            self._pressureSensor.thread.stop()

        # select the port in the combobox (add it if necessary)
        cb = self.acqSettings.pressurePortCB
        if cb.findText(port) < 0:
            cb.addItem(port)
        if cb.currentText != port:
            cb.setCurrentIndex(cb.findText(port))

        # handle disabled sensor
        if port == "<Disable>":
            self._pressureSensor = None
            return

        # handle port auto-discovery
        if port == "<Auto>":
            port = PressureSensor.get_arduino_port() or "<Dummy>"
            logging.info("Pressure Sensor: auto-selected port %s", port)

        # handle dummy sensor
        if port == "<Dummy>":
            self._pressureSensor = DummyPressureSensor()
            logging.warning("Using Dummy pressure sensor")
        else:
            try:
                # TODO: allow user to chose buffer size via settings
                self._pressureSensor = PressureSensor(port=port, buffer=50)
            except Exception as e:
                logging.warning("Problem connecting to sensor on %s: %r", port, e)
                self._resetPressureSensor("<Dummy>")
            logging.info("Pressure sensor: connected on %s", port)
        self._updateCalib(self.acqSettings.pCalibLE.text())

    def _initToolBar(self):
        # AcqSettings Action
        _acqSettingsAction = self.acqSettingsDW.toggleViewAction()
        _acqSettingsAction.setIcon(QtGui.QIcon("icons:configure.svg"))
        self.toolBar.addAction(_acqSettingsAction)

        # Acquire action
        self.acqAction = self.toolBar.addAction(
            QtGui.QIcon("icons:media-record.svg"), "Acquire"
        )
        self.acqAction.setCheckable(True)

        # Select Last action
        self.selectLastAction = self.toolBar.addAction(
            QtGui.QIcon("icons:go-bottom.svg"), "Auto-Select Last"
        )
        self.selectLastAction.setCheckable(True)
        self.selectLastAction.setChecked(True)

        # Show Live spectrum
        self.liveSpectrumAction = self.toolBar.addAction(
            QtGui.QIcon("icons:view-media-visualization.svg"), "Show Live spectrum"
        )
        self.liveSpectrumAction.setCheckable(True)
        self.liveSpectrumAction.setChecked(True)

        # Refresh action
        self.refreshAction = self.toolBar.addAction(
            QtGui.QIcon("icons:view-refresh.svg"), "Recalculate selected spectra peaks"
        )

        # Pressure widget
        self.pressureLabel = QtWidgets.QLabel()
        self.toolBar.addWidget(self.pressureLabel)

    def _initSpectraPlot(self):
        # configure plot
        self.spectrumPlot.setLabels(
            left=("counts (a.u.)",), bottom=("wavelength (nm)",)
        )

        # add roi
        self.roi = pg.LinearRegionItem([400, 800])
        self.spectrumPlot.addItem(self.roi, ignoreBounds=True)

        # add tmp spectrum
        self.tmpSpectrum = pg.PlotDataItem(pen="c")
        self.spectrumPlot.addItem(self.tmpSpectrum)

        # set up dockwidget
        dw = QtWidgets.QDockWidget("Spectrum")
        dw.setObjectName("spectrumDW")
        dw.setWidget(self.spectrumPlot)
        self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dw)

    def _updateSeriesViews(self, viewBox):
        self.intensitiesViewBox.setGeometry(viewBox.sceneBoundingRect())
        self.intensitiesViewBox.linkedViewChanged(
            viewBox, self.seriesPlot.getAxis("right")
        )

    def _initSeriesPlot(self):
        # configure plot
        self.seriesPlot.setLabels(
            left=("wavelength (nm)",), bottom=("time (s)",), right=("Intensity (a.u.)",)
        )

        self.intensitiesViewBox = Y2ViewBox()
        self.intensitiesViewBox.attachToPlotItem(self.seriesPlot.getPlotItem())

        # add series plot
        self.series = pg.ScatterPlotItem(symbol="o", pen="r", brush="r", size=7)
        self.intensities = pg.ScatterPlotItem(symbol="s", pen="g", brush="g", size=7)
        self.seriesPlot.addItem(self.series)
        self.intensitiesViewBox.addItem(self.intensities)

        # set up dockwidget
        dw = QtWidgets.QDockWidget("Series")
        dw.setObjectName("seriesDW")
        dw.setWidget(self.seriesPlot)
        self.addDockWidget(QtCore.Qt.RightDockWidgetArea, dw)

    def saveSettings(self):
        """
        saves the application settings (so that they can be restored with
        :meth:`loadSettings`)
        """
        qsettings = QtCore.QSettings("PeakEvo", "PeakEvo")
        # main window geometry & state
        qsettings.setValue("geometry", self.saveGeometry())
        qsettings.setValue("state", self.saveState())
        # store the config dict
        qsettings.setValue("config", self.createQConfig())
        logging.info('Settings saved in "%s"', qsettings.fileName())

    def loadSettings(self):
        """
        restores the application settings previously saved with
        :meth:`saveSettings`.
        """
        qsettings = QtCore.QSettings("PeakEvo", "PeakEvo")

        # restore the app config
        try:
            self.applyQConfig(qsettings.value("config") or QtCore.QByteArray())
        except Exception as e:
            msg = (
                'Problem loading configuration from "%s".'
                + " Some settings may not be restored.\n Details: %r"
            )
            logging.warning(msg, qsettings.fileName(), e)
        self.restoreGeometry(qsettings.value("geometry") or QtCore.QByteArray())
        self.restoreState(qsettings.value("state") or QtCore.QByteArray())
        logging.info('Settings restored from "%s"', qsettings.fileName())

    def closeEvent(self, event):
        """Reimplemented from QMainWindow to save settings on close"""
        self.saveSettings()
        QtWidgets.QMainWindow.closeEvent(self, event)

    def updatePeak(self, row):
        """Find peak of file within current ROI"""

        table = self.filesTable.table

        # get data from spectrum
        fname = table.item(row, FilesTable.FNAME).text()
        c = self.cache[fname]
        c.roi = self.roi.getRegion()
        xpeak, ypeak = c.peak

        # Update table
        table.item(row, FilesTable.XPEAK).setText(str(xpeak))
        table.item(row, FilesTable.YPEAK).setText(str(ypeak))
        table.item(row, FilesTable.X).setText(str(c.timestamp))

    def onRefresh(self):
        """
        slot called when the "refresh" button is clicked
        It recalculates the peaks for the selected files and updates
        the series plot with all the data from the table
        """
        # Update peak of **selected** files
        for row in self.filesTable.selectedRows():
            self.updatePeak(row)

        # Update the whole series (using **all** the files in the table)
        self.updateSeriesPlot()

        # Update spectra plot
        self.updateSpectraPlot()

    def onSelectionChanged(self):
        """
        Slot called when the selection changes in the table.
        It updates the spectrum plot
        """
        self.updateSpectraPlot()

    def updateSpectraPlot(self):
        _app = QtCore.QCoreApplication.instance()
        table = self.filesTable.table

        selected_rows = self.filesTable.selectedRows()
        if len(selected_rows) > 25:
            b = QtWidgets.QMessageBox.question(
                self,
                "Speed-up",
                "Many curves are selected. Do you want to avoid plotting them to speed-up?",
            )
            if b == QtWidgets.QMessageBox.Yes:
                return

        self.spectrumPlot.clear()
        for row in selected_rows:
            fname = table.item(row, FilesTable.FNAME).text()
            _app.processEvents()
            self.addSpectrum(fname)

        self.spectrumPlot.addItem(self.roi, ignoreBounds=True)

        if self.liveSpectrumAction.isChecked():
            self.spectrumPlot.addItem(self.tmpSpectrum)

    def addSpectrum(self, name):
        c = self.cache[name]
        x, y = c.spectrum

        # Add spectrum curve
        spectrum = pg.PlotDataItem(x=x, y=y, pen="b", name=name)
        self.spectrumPlot.addItem(spectrum)

        xfilt, yfilt = c.filt
        # add filtered curve
        filtSpectrum = pg.PlotDataItem(x=xfilt, y=yfilt, pen="r")
        self.spectrumPlot.addItem(filtSpectrum)

        # add peak marker
        peakMark = pg.ArrowItem(
            pos=c.peak,
            angle=-90,
            tipAngle=30,
            baseAngle=20,
            headLen=40,
            tailLen=40,
            tailWidth=8,
            pen=None,
            brush="r",
        )
        # self.peakMark.setZValue()
        self.spectrumPlot.addItem(peakMark)

    def updateSeriesPlot(self):
        """ "Update points in series plot"""
        # clear plot
        self.series.clear()
        self.intensities.clear()
        self.seriesPlot.replot()

        # add point-by-point from table
        table = self.filesTable.table
        for row in range(table.rowCount()):
            try:
                # TODO: use cache instead of table?
                t = float(table.item(row, FilesTable.X).text())
                xpeak = float(table.item(row, FilesTable.XPEAK).text())
                ypeak = float(table.item(row, FilesTable.YPEAK).text() or 0)
            except ValueError as e:
                logging.warning("Skipping row {:d} ({!r})".format(row, e))
                continue
            if self.toffset is None:
                self.toffset = t
            self.series.addPoints(x=(t - self.toffset,), y=(xpeak,))
            self.intensities.addPoints(x=(t - self.toffset,), y=(ypeak,))

    def onAcqAction(self, checked):
        self.liveSpectrumAction.setChecked(not checked)
        self.liveSpectrumAction.setEnabled(not checked)
        self.toffset = None
        if checked:
            # clear plot
            self.series.clear()
            self.intensities.clear()
            self.seriesPlot.replot()
            # prepare dir
            _dir = self.acqSettings.outDirLE.text()
            if not os.path.exists(_dir):
                try:
                    os.makedirs(_dir)
                except Exception as e:
                    logging.warning("%r", e)
                    self.acqAction.setChecked(False)
                    return
            # ask for series file name and open file
            _fname, _ = QtWidgets.QFileDialog.getSaveFileName(
                parent=self,
                caption="New Series File",
                directory=_dir,
                filter=".dat files(*.dat);;All files(*)",
            )
            if _fname:
                try:
                    self._series_file = open(_fname, "w")
                except Exception as e:
                    logging.warning('Error opening "%s": %r', _fname, e)
                    self._series_file = None
                    self.acqAction.setChecked(False)
                    return
            else:
                self._series_file = None
                self.acqAction.setChecked(False)
                return
        else:
            self.updateSpectraPlot()
            # close series file
            if self._series_file is not None:
                try:
                    self._series_file.close()
                except Exception as e:
                    logging.warning('Error closing "%s": %r', self._series_file.name, e)
                finally:
                    self._series_file = None

    def onAcqPeriodChanged(self, seconds):
        self.captureTimer.setInterval(int(seconds * 1000))

    def onAcqTimeChanged(self, ms):
        # Set the acq time of the spectrometer
        us = ms * 1000
        if us >= self.spectrometer.integration_time_micros_limits[0]:
            self.spectrometer.integration_time = us

    def onAutoAcqTime(self):
        """auto-adjust integration time to optimize dynamic range
        within the ROI"""
        _SAT = numpy.iinfo(numpy.uint16).max
        _MAX_TIME = 1000000
        _FACTOR = 1.2
        s = self.spectrometer
        s.integration_time = s.integration_time_micros_limits[0]
        x = s.wavelengths()
        roi_min, roi_max = self.roi.getRegion()
        mask = (x > roi_min) * (x < roi_max)
        y = s.intensities()[mask]
        sat = nburnt = (y == _SAT).sum()
        _app = QtCore.QCoreApplication.instance()
        logging.info("Auto-adjusting integration time:")
        logging.info("t={}, sat={}".format(s.integration_time, nburnt))
        # Increase integration time until we get saturation
        while sat <= nburnt and s.integration_time < _MAX_TIME:
            s.integration_time = int(s.integration_time * _FACTOR)
            y = s.intensities()
            sat = (y[mask] == _SAT).sum()
            logging.info("t={}, sat={}".format(s.integration_time, sat))
            self.tmpSpectrum.setData(x=x, y=y)
            self.acqSettings.acqTimeSB.setValue(s.integration_time // 1000)
            _app.processEvents()
        # Go back to 50% of the last valid step (to leave margin)
        s.integration_time = int(0.5 * s.integration_time / _FACTOR)
        self.acqSettings.acqTimeSB.setValue(s.integration_time // 1000)
        logging.info("t={}".format(s.integration_time))

    def _useAverage(self, checked):
        sender = self.sender()
        if sender is self.acqSettings.backgroundBT:
            v, attr = 0, "background"
            le = self.acqSettings.backgroundLE
        elif sender is self.acqSettings.referenceBT:
            v, attr = 1, "reference"
            le = self.acqSettings.referenceLE
        else:
            raise RuntimeError("Unknown sender in _useAverage slot")

        rows = sorted(self.filesTable.selectedRows(), reverse=True)

        if not rows:
            checked = False
            self.sender().setChecked(checked)

        if not checked:
            # reset background / reference
            setattr(self, attr, v)
            le.clear()
            return

        # load data (and check acq_time)
        data = []
        table = self.filesTable.table
        acq_time = self.acqSettings.acqTimeSB.value()
        for row in rows:
            name = table.item(row, FilesTable.FNAME).text()
            c = self.cache[name]
            c.load()
            data.append(c)

            if round(c.acq_time * 1e-3, 1) != acq_time:
                msg = (
                    "Incompatible acquisition time:\n"
                    "  - Current value: {:.1f}ms\n" + '  - Value in "{:s}" : {:.1f}ms'
                )
                msg = msg.format(acq_time, os.path.basename(name), c.acq_time * 1e-3)
                QtWidgets.QMessageBox.warning(self, "Incompatible reference", msg)
                setattr(self, attr, v)
                le.clear()
                self.sender().setChecked(False)
                return

        # Offer to save
        oName, _ = QtWidgets.QFileDialog.getSaveFileName(
            self,
            "QFileDialog.getSaveFileName()",
            os.path.join(self.acqSettings.outDirLE.text(), attr + ".npz"),
            "All Files (*)",
        )

        if oName:
            # average selected spectra
            table = self.filesTable.table
            v = 0.0
            for c in data:
                x, y = c.spectrum
                v += y

            v /= len(rows)

            # store the value in self
            setattr(self, attr, v)

            # save acquired data to disk
            # TODO: use an averaged acq_time instead of trusting the SB value
            now_ts = time.time()
            numpy.savez(
                oName, x=x, y=y, acq_time=int(round(acq_time * 1e3)), timestamp=now_ts
            )

            # set the line edit
            text = os.path.relpath(oName, self.acqSettings.outDirLE.text())
            le.setText(text)

            # remove spectra from table
            self.filesTable.removeSelectedRows()

    def onCapture(self):
        """slot called by capture timer"""

        now_ts = time.time()
        x, y = self.spectrometer.capture()
        if self._pressureSensor is None:
            p = 0.0
        else:
            p, _ = self._pressureSensor.read()

        # flat-field and dark-field correction
        y = y.astype("float64") - self.background
        if self.reference is not None:  # normalize only if reference is set
            y /= self.reference - self.background

        if not self.acqAction.isChecked() or self._series_file is None:
            # If not acquiring, just update tmpSpectrum and return
            self.tmpSpectrum.setData(x=x, y=y)
            self.pressureLabel.setText("P={:4.1f}kPa".format(p))
            return

        # If we are acquiring ...
        if self.toffset is None:
            self.toffset = now_ts
        dt = now_ts - self.toffset
        now = datetime.datetime.fromtimestamp(now_ts)

        name_pattern = "{dt:%Y%m%d_%H%M%S}.{ms:03d}{suffix:s}.npz"

        self.acqSettings.nameSuffixLE.text()
        name = os.path.join(
            self.acqSettings.outDirLE.text(),
            name_pattern.format(
                dt=now,
                ms=now.microsecond // 1000,
                suffix=self.acqSettings.nameSuffixLE.text(),
            ),
        )

        c = self.cache[name]
        c.spectrum = x, y
        c.timestamp = now_ts
        c.acq_time = self.spectrometer.integration_time
        c.roi = self.roi.getRegion()
        xpeak, ypeak = c.peak
        self.filesTable.appendRow(
            x=now_ts, peak=xpeak, ypeak=ypeak, pressure=p, name=name
        )

        # save acquired data
        c.savez()

        # Add point to series plot
        self.series.addPoints(x=(dt,), y=(xpeak,))
        self.intensities.addPoints(x=(dt,), y=(ypeak,))

        # Add point to series file
        if self._series_file is not None:
            self._series_file.write(
                "{}\t{}\t{}\t{}\t{}\n".format(now_ts, dt, xpeak, ypeak, p)
            )

        if self.selectLastAction.isChecked():
            self.filesTable.selectRow(-1)


def main():
    import argparse

    from peakevo import __version__

    # Add version option

    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--version",
        action="version",
        version="%(prog)s {version}".format(version=__version__),
    )
    parser.add_argument(
        "--keep-alive", help="do not close GUI on exceptions", action="store_true"
    )
    args = parser.parse_args()

    # Avoid closing GUI on exception if requested
    if args.keep_alive and API == "pyqt5":
        import traceback

        def excepthook(etype, value, tb):
            traceback.print_exception(etype, value, tb)

        sys.excepthook = excepthook

    app = QtWidgets.QApplication(sys.argv)
    w = PeakEvoMain()
    w.show()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
