"""PyGFETdb.GuiDBView.GuiHelpers

High-level helpers used by the GUI view modules (GuiDBView).

This module groups lightweight utilities that are shared across the
graphing/reporting GUI code and surface-level Qt model adapters. It is
intended to make the GUI code more declarative by providing:

- `PandasModel`: a QAbstractTableModel adapter for pandas.DataFrame used
  by Qt views in `GuiDBView`.
- Plotting helpers: `GenFigure`, `FormatAxis`, `GenVectorFigures`, and
  other small utilities that build matplotlib figures consistently for
  the project.
- Report builders: `GenPSDBodeReport`, `GenDeviceReportGui` and helpers
  that generate multi-page PDF reports using matplotlib and
  multiprocessing.
- Small data helpers used to annotate and transform DataFrame objects
  produced by `PyGFETdb.DBInterface.Data2Pandas` (e.g. `AddColRow`,
  `AddCycle`).

Usage notes / project-specific conventions
- Several plotting functions expect `dfData.attrs` to contain keys
  populated by `DBInterface.Data2Pandas` (see `PyGFETdb/DBInterface.py`):
  e.g. `Vgs`, `VgsNorm`, `ArrayColsNorm`, `ColUnits` and `ArrayCols`.
- Vector plotting functions rely on the `CharCl` object stored in each
  DataFrame row. `CharCl` is an instance of `DataCharAC` / `DataCharDC`
  defined in `PyGFETdb/DataClass.py` and must implement methods used by
  the helpers (e.g. `GetPSD`, `GetFpsd`, `GetGmMag`, `GetGmPh`,
  `GetIds`, `GetFgm`). See `DataClass.py` for the concrete API.
- Report generation uses `tempfile.TemporaryDirectory` and either
  `process_map` from `tqdm.contrib.concurrent` or `multiprocessing.Pool`.
  PDF pages are merged with `PyPDF2.PdfMerger`.

Quick example
- Construct a pandas.DataFrame `df` using `DBInterface.Data2Pandas` and create a Qt model with
  `PandasModel(df)`.
- Produce a PSD/Bode report by calling `GenPSDBodeReport(dfData)` where `dfData` is a DataFrame
  produced by `DBInterface.Data2Pandas`.

Related files
- `PyGFETdb/DBInterface.py`  (data-to-pandas conversion, `Data2Pandas`)
- `PyGFETdb/DataClass.py`   (DataCharAC / DataCharDC implementations)
- `PyGFETdb/GuiDBView/GuiDBView_v2.py` (GUI that consumes these
  helpers)

"""

import glob
import math
import os
import platform
import subprocess
import tempfile
import warnings
from multiprocessing import Pool

import matplotlib as mpl
import numpy as np
import pandas as pd
from PyPDF2 import PdfMerger
from PyQt5.QtCore import QAbstractTableModel, QModelIndex
from matplotlib import pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
from qtpy.QtCore import Qt
from scipy import stats
from tqdm.contrib.concurrent import process_map
import seaborn as sns


class PandasModel(QAbstractTableModel):
    """A model to interface a Qt view with pandas dataframe.

    This class adapts a pandas.DataFrame for use with Qt views (e.g.
    QTableView). It exposes the minimal QAbstractTableModel methods
    required by the GUI (`rowCount`, `columnCount`, `data`,
    `headerData`). The DataFrame is accessed in read-only mode by this
    adapter.

    Attributes
    ----------
    _dataframe : pandas.DataFrame
        The underlying DataFrame shown by the model.
    """

    def __init__(self, dataframe: pd.DataFrame, parent=None):
        QAbstractTableModel.__init__(self, parent)
        self._dataframe = dataframe

    def rowCount(self, parent=QModelIndex()) -> int:
        """Return number of rows in the underlying DataFrame.

        Parameters
        ----------
        parent : QModelIndex, optional
            Qt parent index (ignored in this simple adapter).

        Returns
        -------
        int
            Number of rows in the DataFrame.
        """
        if parent == QModelIndex():
            return len(self._dataframe)
        return 0

    def columnCount(self, parent=QModelIndex()) -> int:
        """Return number of columns in the underlying DataFrame.

        Parameters
        ----------
        parent : QModelIndex, optional
            Qt parent index (ignored).

        Returns
        -------
        int
            Number of columns in the DataFrame.
        """
        if parent == QModelIndex():
            return len(self._dataframe.columns)
        return 0

    def data(self, index: QModelIndex, role=Qt.ItemDataRole):
        """Return a string representation of the cell value for display.

        The adapter returns a human-readable string for the Qt display
        role. Non-display roles are not handled by this adapter.

        Parameters
        ----------
        index : QModelIndex
            The cell index requested by Qt.
        role : Qt.ItemDataRole
            The data role (only Qt.DisplayRole is supported).

        Returns
        -------
        str | None
            Stringified cell value or None for unsupported roles.
        """
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            data = self._dataframe.iloc[index.row(), index.column()]
            if type(data) == str:
                return data
            st = str(data)
            return st
        return None

    def headerData(self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole):
        """Provide header labels for horizontal and vertical headers.

        Parameters
        ----------
        section : int
            Header section index.
        orientation : Qt.Orientation
            Horizontal or Vertical orientation.
        role : Qt.ItemDataRole
            Qt role (only Qt.DisplayRole is returned).

        Returns
        -------
        str | None
            Header label string for the requested orientation/section.
        """
        if role == Qt.DisplayRole:
            if orientation == Qt.Horizontal:
                return str(self._dataframe.columns[section])
            if orientation == Qt.Vertical:
                return str(self._dataframe.index[section])
        return None


LogPars = ('Irms', 'Vrms', 'NoA', 'NoC', 'IrmsNorm', 'VrmsNorm', 'NoANorm', 'NoCNorm', 'GmFcut', 'GmFcutNorm', 'GMAbs', 'GMAbsNorm')
Axlimits = {'GmErr': (-0.1, 0.1),
            'GmErrNorm': (-0.1, 0.1)}


def GenFigure(PlotPars):
    """Create a matplotlib figure with a grid of axes sized for PlotPars.

    Parameters
    ----------
    PlotPars : iterable
        Iterable of parameter names (determines how many axes are
        required).

    Returns
    -------
    (fig, axes)
        Matplotlib Figure and a flattened list/array of Axes objects.
    """
    nRows = math.ceil(np.sqrt(len(PlotPars)))
    nCols = math.ceil(len(PlotPars) / nRows)
    fig, Axs = plt.subplots(nrows=nRows, ncols=nCols)
    if nRows == 1 and nCols == 1:
        Axs = [Axs, ]
    else:
        Axs = Axs.flatten()

    return fig, Axs


def FormatAxis(PlotPars, dfAttrs):
    """Format axes and labels for a set of plot parameters.

    This helper creates a figure using :func:`GenFigure` and applies
    consistent axis labels, scales and limits based on project
    conventions and the DataFrame attribute dictionary produced by
    `DBInterface.Data2Pandas`.

    Parameters
    ----------
    PlotPars : iterable
        Names of parameters to create axes for (e.g. ['Ids', 'PSD']).
    dfAttrs : dict
        Attributes dictionary taken from a pandas.DataFrame (`df.attrs`)
        containing keys like 'ColUnits', 'ArrayColsNorm', 'Vgs', etc.

    Returns
    -------
    (fig, AxsDict)
        Matplotlib Figure and a mapping from parameter name to Axes.
    """
    # figure generation
    fig, Axs = GenFigure(PlotPars)

    # Format axis
    AxsDict = {}
    for ip, par in enumerate(PlotPars):
        ax = Axs[ip]
        AxsDict[par] = ax

        slabel = '{}[{}]'.format(par, dfAttrs['ColUnits'][par])
        ax.set_ylabel(slabel)

        # # select Vgs vector
        if (par in dfAttrs['ArrayColsNorm']) or par.endswith('Norm'):
            ax.set_xlabel('Vgs - CNP [V]')
        else:
            ax.set_xlabel('Vgs [V]')

        if par in Axlimits:
            ax.set_ylim(*Axlimits[par])

        if (par in LogPars) or ('rms' in par):
            ax.set_yscale('log')
        elif par == 'PSD':
            ax.set_yscale('log')
            ax.set_xscale('log')
            ax.set_xlabel('Frequency [Hz]')
        elif par == 'BodeMag':
            ax.set_yscale('log')
            ax.set_xscale('log')
            ax.set_xlabel('Frequency [Hz]')
        elif par == 'BodePhase':
            ax.set_xscale('log')
            ax.set_xlabel('Frequency [Hz]')

    return fig, AxsDict


def GenScalarFigures(Data, PlotPars, Xvar, Huevar, PltFunct, Axs=None,
                     SeparateLegend=False, **kwargs):
    """Generate scalar plots (box/violin/bar/strip) for a DataFrame.

    The function wraps seaborn plotting functions to create a set of
    scalar summary figures for columns contained in `PlotPars`.

    Parameters
    ----------
    Data : pandas.DataFrame
        Input data with the columns listed in PlotPars.
    PlotPars : iterable
        Names of scalar parameters to plot.
    Xvar : str
        Column name used for the x-axis grouping (e.g. 'Cycle').
    Huevar : str | None
        Optional column used to color/group the plots.
    PltFunct : callable
        Seaborn plotting function (e.g. sns.boxplot, sns.violinplot).
    Axs : list[Axes] | None
        Optional axes list to draw into. When None, new figure is created.
    SeparateLegend : bool
        If True, create a separate legend figure instead of embedding it.

    Returns
    -------
    matplotlib.Figure | None
        The created Figure (None when Axs was provided by caller).
    """
    if Axs is None:
        fig, Axs = GenFigure(PlotPars)
    else:
        fig = None

    for ic, p in enumerate(PlotPars):
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            PltFunct(x=Xvar,
                     y=p,
                     hue=Huevar,
                     data=Data,
                     ax=Axs[ic],
                     **kwargs)

        for pc in LogPars:
            if p.find(pc) != -1:
                Axs[ic].set_yscale('log')
                break

        leg = Axs[ic].get_legend()
        if SeparateLegend is True:
            Axs[ic]._remove_legend(Axs[ic].get_legend())
        elif leg is not None:
            plt.setp(leg.get_texts(),
                     fontsize='xx-small')
            plt.setp(leg.get_title(),
                     fontsize='xx-small')

        plt.setp(Axs[ic].get_xticklabels(),
                 rotation=45,
                 ha="right",
                 fontsize='xx-small',
                 rotation_mode="anchor")
        plt.setp(Axs[ic].get_yticklabels(),
                 fontsize='xx-small')

    if SeparateLegend is True:
        fl, axl = plt.subplots()
        axl.axis('off')
        axl.legend(*Axs[ic].get_legend_handles_labels(), title=Huevar)

    return fig


def GenVectorFigures(data, GroupBy, PlotPars,
                     AxsDict=None,
                     Lines=True, Mean=False, Std=False,
                     SeparateLegend=False, Vgs=None, Ud0Norm=True, PSDFit=False, cmap='jet'):
    """Plot vector/array parameters (e.g. Ids(Vgs), PSD(f), Bode) grouped by a key.

    This function iterates groups in `data.groupby(GroupBy)` and for
    each group plots array-valued parameters specified in `PlotPars`.
    It expects that each DataFrame row contains a `CharCl` object with
    methods used to obtain PSD/Bode vectors when applicable.

    Parameters
    ----------
    data : pandas.DataFrame
        DataFrame containing scalar and array parameters and a `CharCl`
        column for vector data access.
    GroupBy : str
        Column name to group the DataFrame by (e.g. 'Cycle' or 'Name').
    PlotPars : iterable
        Names of parameters to plot. If a parameter is present in
        `data.attrs['ArrayColsNorm']` its x-axis will use `VgsNorm`.
    AxsDict : dict | None
        Optional mapping from parameter to Axes. If not provided, new
        figure/axes are created via :func:`FormatAxis`.
    Lines, Mean, Std : bool
        Plotting modes: individual lines, mean line and std shading.
    SeparateLegend : bool
        If True, legend entries are placed in a separate figure.
    Vgs : array-like | None
        Optional Vgs vector passed to `CharCl` methods when required.
    Ud0Norm : bool
        If True, normalized Vgs (Vgs - CNP) is used where requested.
    PSDFit : bool
        If True, plot PSD fit overlay when plotting PSD.
    cmap : str | matplotlib colormap
        Colormap name passed to matplotlib.

    Returns
    -------
    (fig, AxsDict)
        Figure (or None if reusing axes) and mapping from parameter to Axes.
    """

    dgroups = data.groupby(GroupBy, observed=True)

    # Colors iteration
    Norm = mpl.colors.Normalize(vmin=0, vmax=len(dgroups)-1)
    cmap = mpl.cm.ScalarMappable(norm=Norm, cmap=cmap)

    if AxsDict is None:
        fig, AxsDict = FormatAxis(PlotPars, data.attrs)
    else:
        fig = None

    for ic, gn in enumerate(dgroups.groups):
        gg = dgroups.get_group(gn)
        Col = cmap.to_rgba(ic)
        for parn in PlotPars:
            if parn in data.attrs['ArrayColsNorm']:
                xVar = data.attrs['VgsNorm']
            else:
                xVar = data.attrs['Vgs']

            Vals = np.array([])
            ax = AxsDict[parn]
            for index, row in gg.iterrows():
                if parn == 'PSD':
                    xVar = row.CharCl.GetFpsd()
                    if xVar is None:
                        continue
                    v = row.CharCl.GetPSD(Vgs=Vgs, Ud0Norm=Ud0Norm)
                    if v is None:
                        continue
                    else:
                        v = v.transpose()
                    if PSDFit:
                        ax.loglog(xVar, row.CharCl.GetFitPSD(), 'k--', alpha=1)
                elif parn == 'BodeMag':
                    xVar = row.CharCl.GetFgm()
                    if xVar is None:
                        continue
                    v = row.CharCl.GetGmMag(Vgs=Vgs, Ud0Norm=Ud0Norm)
                    if v is None:
                        continue
                    else:
                        v = v.transpose()
                elif parn == 'BodePhase':
                    xVar = row.CharCl.GetFgm()
                    if xVar is None:
                        continue
                    v = row.CharCl.GetGmPh(Vgs=Vgs, Ud0Norm=Ud0Norm)
                    if v is None:
                        continue
                    else:
                        v = v.transpose()
                else:
                    v = row[parn]

                if type(v) == float:
                    continue
                try:
                    if Lines:
                        ax.plot(xVar, v.transpose(),
                                color=Col,
                                alpha=0.8,
                                label=gn)
                    Vals = np.vstack((Vals, v)) if Vals.size else v
                except:
                    pass

            if Vals.size == 0:
                continue

            Vals = Vals.magnitude.transpose()

            if Mean:
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore")
                    mean = np.nanmedian(Vals, axis=1)
                ax.plot(xVar, mean, '-.', color=Col, lw=1.5, label=gn)

            if Std:
                with warnings.catch_warnings():
                    warnings.simplefilter("ignore")
                    std = stats.tstd(Vals, axis=1)  # changed to mad for robustness
                ax.fill_between(xVar, mean + std, mean - std, color=Col, alpha=0.2)

            if SeparateLegend is False:
                handles, labels = ax.get_legend_handles_labels()
                by_label = dict(zip(labels, handles))
                ax.legend(by_label.values(), by_label.keys(), fontsize='xx-small')

    if SeparateLegend is True:
        fig, axl = plt.subplots()
        axl.axis('off')
        handles, labels = ax.get_legend_handles_labels()
        by_label = dict(zip(labels, handles))
        axl.legend(by_label.values(), by_label.keys(), title=GroupBy)

    return fig, AxsDict


def GenPSDBodeFigure(Args):
    """Render a single device PSD/Bode figure and save it to a temporary PDF.

    Intended to be used in parallel (e.g. with :func:`process_map`). The
    function receives a single argument dictionary with keys:
    - 'Cl': DataCharAC/DataCharDC instance with GetIds/GetPSD/GetFgm
    - 'tmpDir': TemporaryDirectory object where the PDF will be written
    - 'title': Title string for the figure
    - 'dfAttrs': DataFrame attrs dictionary used by :func:`FormatAxis`

    Parameters
    ----------
    Args : dict
        Argument dictionary (see description above).
    """
    Cl = Args['Cl']
    tmpDir = Args['tmpDir']
    dfAttrs = Args['dfAttrs']
    Vgs = Cl.GetVgs()
    Norm = mpl.colors.Normalize(vmin=0, vmax=len(Vgs))
    cmap = mpl.cm.ScalarMappable(norm=Norm, cmap='jet')

    fig, AxsDict = FormatAxis(('Ids', 'BodeMag', 'PSD', 'BodePhase'), dfAttrs)
    fig.set_size_inches(11, 8)
    fig.suptitle(Args['title'])

    ax = AxsDict['Ids']
    ax.plot(Vgs, Cl.GetIds().flatten())
    fpsd = Cl.GetFpsd()
    if fpsd is not None:
        psd = Cl.GetPSD()
        ax = AxsDict['PSD']
        for iv, vg in enumerate(Vgs):
            ax.loglog(fpsd, psd[:, iv],
                      color=cmap.to_rgba(iv),
                      label='Vgs = {:0.2}'.format(vg))
        ax.legend()

    Fgm = Cl.GetFgm()
    if Fgm is not None:
        GmMag = Cl.GetGmMag()
        ax = AxsDict['BodeMag']
        for iv, vg in enumerate(Vgs):
            ax.loglog(Fgm, GmMag[:, iv],
                      color=cmap.to_rgba(iv),
                      label='Vgs = {:0.2}'.format(vg))
            Gm = np.abs(Cl.GetGM(Vgs=vg).flatten())
            ax.plot(Fgm[0], Gm, color=cmap.to_rgba(iv), marker='*')
        ax.legend()
        GmPh = Cl.GetGmPh()
        ax = AxsDict['BodePhase']
        for iv, vg in enumerate(Vgs):
            ax.semilogx(Fgm, GmPh[:, iv],
                        color=cmap.to_rgba(iv),
                        label='Vgs = {:0.2}'.format(vg))
        ax.legend()
    fig.tight_layout()
    file = tempfile.NamedTemporaryFile(dir=tmpDir.name, suffix='.pdf', delete=False)
    fig.savefig(file, format='pdf')
    plt.close(fig)


def GenPSDBodeReport(dfData):
    """Generate a multi-page PSD/Bode PDF report for the given DataFrame.

    The function creates a temporary directory, produces one PDF page per
    measurement entry (via :func:`GenPSDBodeFigure`) and merges them into
    a single output temporary PDF which is opened on the host platform.

    Parameters
    ----------
    dfData : pandas.DataFrame
        DataFrame produced by :func:`DBInterface.Data2Pandas`. The function
        will update `dfData.attrs['ColUnits']` with PSD/Bode units.

    Returns
    -------
    None
    """
    dfData.attrs['ColUnits'].update({'PSD': 'A**2/Hz',
                                     'BodeMag': 'S',
                                     'BodePhase': 'Deg',
                                     })

    dgroups = dfData.groupby('Name', observed=True)

    tmpDir = tempfile.TemporaryDirectory()
    print(tmpDir.name)

    plt.ioff()

    Args = []
    for ic, (gn, gg) in enumerate(dgroups):
        for index, row in gg.iterrows():
            Args.append({'tmpDir': tmpDir,
                         'Cl': row.CharCl,
                         'title': '{} \n {}'.format(gn, row.Date),
                         'dfAttrs': dfData.attrs})

    print('Generating Report')
    process_map(GenPSDBodeFigure, Args)

    plt.ion()

    merger = PdfMerger()
    FileName = tempfile.NamedTemporaryFile(suffix='.pdf', delete=False)

    for pdf in glob.glob(tmpDir.name + '/*.pdf'):
        merger.append(pdf)

    print(FileName.name)

    merger.write(FileName.name)
    merger.close()
    tmpDir.cleanup()

    if platform.system() == 'Darwin':  # macOS
        subprocess.call(('open', FileName.name))
    elif platform.system() == 'Windows':  # Windows
        os.startfile(FileName.name)
    else:  # linux variants
        subprocess.call(('xdg-open', FileName.name))


def AddColRow(dfData):
    """Extract wafer/device grid columns/rows from the 'Name' field.

    The function parses the `Name` column expected to contain hyphen
    separated tokens where the last two tokens encode row/column
    identifiers. It adds four new columns to `dfData`: 'Col', 'Row',
    'nCol', 'nRow' (numeric two-digit suffixes parsed to integers).

    Parameters
    ----------
    dfData : pandas.DataFrame
        DataFrame with a 'Name' column in the expected format.

    Returns
    -------
    None (operates in-place)
    """
    Col = []
    Row = []
    nCol = []
    nRow = []
    for ir, d in dfData.iterrows():
        n = d['Name'].split('-')
        Col.append(n[-1])
        Row.append(n[-2])
        nCol.append(int(n[-1][-2:]))
        nRow.append(int(n[-2][-2:]))

    dfData['Col'] = Col
    dfData['Row'] = Row
    dfData['nCol'] = nCol
    dfData['nRow'] = nRow


def AddCycle(dfData):
    """Annotate DataFrame with 'Cycle' and 'InvertCycle' columns.

    Groups the DataFrame by 'Name' and assigns a zero-based cycle
    index to each measurement within a group. Also adds 'InvertCycle'
    counting backwards from the total cycles for that group.

    Parameters
    ----------
    dfData : pandas.DataFrame
        DataFrame containing a 'Name' column used for grouping.

    Returns
    -------
    pandas.DataFrame
        The modified DataFrame with 'Cycle' and 'InvertCycle' columns.
    """
    gTrts = dfData.groupby('Name', observed=True)
    dfData.insert(2, 'Cycle', np.zeros(len(dfData), dtype=int))
    dfData.insert(3, 'InvertCycle', np.zeros(len(dfData), dtype=int))
    for gn, gt in gTrts:
        Totalcycles = len(gt)
        for cy, (ir, d) in enumerate(gt.iterrows()):
            dfData.at[ir, 'Cycle'] = cy
            dfData.at[ir, 'InvertCycle'] = Totalcycles - cy
    
    return dfData


def GenDeviceFigures(Data, Name, ScalarPars, ScalarPltFunct, VectorPars, **kwargs):
    """Create a set of figures summarizing device data (scalar + vector).

    The function combines scalar summary plots created with
    :func:`GenScalarFigures` and vector plots created with
    :func:`GenVectorFigures`. If per-column heatmaps are required the
    DataFrame must include 'Col'/'Row' columns (use :func:`AddColRow`).

    Parameters
    ----------
    Data : pandas.DataFrame
        Device-specific DataFrame (grouped slice).
    Name : str
        Device name used for figure titles.
    ScalarPars : iterable
        Scalar parameter names to plot.
    ScalarPltFunct : callable
        Seaborn function used to render scalar plots.
    VectorPars : iterable
        Array-valued parameter names to plot (passed to GenVectorFigures).

    Returns
    -------
    (Name, list_of_figures)
        Tuple with device name and list of matplotlib Figure objects.
    """
    Figs = []
    Fig = GenScalarFigures(Data=Data,
                           PlotPars=ScalarPars,
                           Xvar='Cycle',
                           Huevar=None,
                           PltFunct=ScalarPltFunct)
    Fig.set_size_inches(11, 8)
    Fig.suptitle(Name)
    Fig.tight_layout()
    Figs.append(Fig)
    Fig, axdict = GenVectorFigures(data=Data,
                                   GroupBy='Cycle',
                                   PlotPars=VectorPars)

    Fig.set_size_inches(11, 8)
    Fig.suptitle(Name)
    Fig.tight_layout()
    plt.close(Fig)

    if 'Col' not in Data.keys():
        return Figs

    gCys = Data.groupby('Cycle')
    for gcn, g in gCys:
        Fig, Axs = GenFigure(ScalarPars)
        for ic, p in enumerate(ScalarPars):
            sns.heatmap(g.pivot('Col', 'Row', p),
                        ax=Axs[ic])
            Axs[ic].set_title(p)

        Fig.suptitle(str(Name) + ' Cycle ' + str(gcn))
        Fig.set_size_inches(11, 8)
        Fig.tight_layout()
        Figs.append(Fig)

    return Name, Figs


def GenDeviceReport(Data, FileOut, ScalarPars, ScalarPltFunct, VectorPars):
    """Generate device report PDF writing pages to `FileOut`.

    This function creates figures for all devices in `Data` and saves
    them as pages in `FileOut` using PdfPages. It uses a process pool to
    parallelize figure generation.

    Parameters
    ----------
    Data : pandas.DataFrame
        Full dataset containing multiple devices (must have 'Device' column).
    FileOut : str
        Path to the output PDF file.
    ScalarPars, ScalarPltFunct, VectorPars : see :func:`GenDeviceFigures`.

    Returns
    -------
    None
    """
    gDevs = Data.groupby('Device')

    PDF = PdfPages(FileOut)
    plt.ioff()
    Args = []
    for ig, (gn, gd) in enumerate(gDevs):
        print('Generating {} - {} of {}'.format(gn, ig, len(gDevs)))
        # Figs = GenDeviceFigures(gd, gn, ScalarPars, ScalarPltFunct, VectorPars)
        Args.append((gd, gn, ScalarPars, ScalarPltFunct, VectorPars))

    with Pool() as p:
        Result = p.starmap_async(GenDeviceFigures, Args)
        for ic, (Name, Figs) in enumerate(Result.get()):
            print('Get Figures from {}, {} of {}'.format(Name, ic, len(Args)))
            for f in Figs:
                PDF.savefig(f)
                plt.close(f)

    plt.ion()
    PDF.close()


def GenDeviceReportBrigde(Args):
    """Bridge function used for parallel generation of device report pages.

    Receives a dictionary of arguments, delegates to :func:`GenDeviceFigures`
    and writes each resulting figure to a temporary PDF file inside
    `Args['tmpDir']`.

    Parameters
    ----------
    Args : dict
        Must contain keys expected by :func:`GenDeviceFigures` and
        'tmpDir' TemporaryDirectory instance.
    """
    Name, Figs = GenDeviceFigures(**Args)
    for ic, f in enumerate(Figs):
        file = tempfile.NamedTemporaryFile(dir=Args['tmpDir'].name,
                                           prefix=Name + '{0:02}'.format(ic),
                                           suffix='.pdf',
                                           delete=False)
        f.savefig(file, format='pdf')
        plt.close(f)


def GenDeviceReportGui(Data, FileOut, ScalarPars, ScalarPltFunct, VectorPars):
    """Generate a device report PDF suitable for GUI workflows.

    Similar to :func:`GenDeviceReport` but writes intermediate PDF pages
    into a temporary directory and merges them using PdfMerger. This is
    the function used by the GUI to create a single report file.

    Parameters
    ----------
    Data : pandas.DataFrame
        Full dataset grouped by 'Device'.
    FileOut : str
        Path where the merged PDF will be written.
    ScalarPars, ScalarPltFunct, VectorPars : see :func:`GenDeviceFigures`.

    Returns
    -------
    None
    """
    gDevs = Data.groupby('Device')

    tmpDir = tempfile.TemporaryDirectory()
    print(tmpDir.name)

    plt.ioff()

    Args = []
    for ig, (gn, gd) in enumerate(gDevs):
        print('Generating {} - {} of {}'.format(gn, ig, len(gDevs)))
        # Figs = GenDeviceFigures(gd, gn, ScalarPars, ScalarPltFunct, VectorPars)
        Args.append({'Data': gd,
                     'Name': gn,
                     'ScalarPars': ScalarPars,
                     'ScalarPltFunct': ScalarPltFunct,
                     'VectorPars': VectorPars,
                     'tmpDir': tmpDir})

    print('Generating Report')
    process_map(GenDeviceReportBrigde, Args)

    merger = PdfMerger()
    files = glob.glob(tmpDir.name + '/*.pdf')
    for pdf in sorted(files):
        merger.append(pdf)
    merger.write(FileOut)
    merger.close()
    tmpDir.cleanup()

    plt.ion()
