/*
    SPDX-FileCopyrightText: 2023 John Evans <john.e.evans.email@googlemail.com>

    SPDX-License-Identifier: GPL-2.0-or-later
*/

#include "buildfilteroffsets.h"
#include <kstars_debug.h>
#include "kstars.h"
#include "ekos/auxiliary/tabledelegate.h"
#include "Options.h"

namespace Ekos
{

// buildFilterOffsets (BFO) sets up a dialog to manage automatic calculation of filter offsets
// by performing autofocus runs on user selected filters and working out the relative offsets.
// The idea is to use the utility once in a while to setup the offsets which them enables the user to avoid having
// to do an autofocus run when swapping filters. The benefit of this might be to avoid focusing on a difficult-to-
// focus filter but focusing on a lock filter and then using the offset; or just to avoid an Autofocus run when
// changing filter.
//
// The utility follows the standard Ekos cpp/h/ui file setup. The basic dialog is contained in the .ui file but
// because the table is constructed from data at runtime some of the GUI is constructed in code in the .cpp.
// BFO uses the MVC design pattern. The BuildFilterOffset object is a friend of FilterManager because BFO is closely
// related to FilterManager. This allows several FilterManager methods to be accessed by BFO. In addition, signals
// with Focus are passed via FilterManager.
//
// On launch, BFO displays a grid containing all the filters in the Filter Settings. Relevant information is copied across
// to BFO and the filters appear in the same order. The 1st filter is suffiixed with a "*", marking it as the reference
// filter which is the one against which all other relevant offsets are measured.
//
// To change the reference filter, the user double clicks another filter.
//
// The user needs to set the number of Autofocus (AF) runs to perform on each filter. The default is 5, the maximum is 10.
// Setting this field to 0, removes the associated filter from further processing.
//
// The user presses Run and the utility moves to the processing stage. Extra columns are displayed, one for each AF run,
// as well as average, new offset and save columns. For each filter the number of requested AF runs is performed. The
// table cell associated with the in-flight AF run is highlighted. As an AF run completes the results are displayed in
// the table. When all AF runs for a filter are complete, processing moves to the next filter.
//
// Normally, if a lock filter is configured for a particular filter, then when focusing, the lock filter is swapped in
// AF runs, and then the original filter is swapped back into position. For BFO this model is inappropriate. When an
// AF run is requested on a filter then AF is always run on that filter so the lock filter policy is not honoured when
// running AF from BFO.
//
// The user can interrupt processing by pressing stop. A confirm dialog then allows the user to stop BFO or resume.
//
// BFO can take a while to complete. For example if 5 AF runs are requested on 8 filters and AF takes, say 2 mins per
// run, then BFO will take over an hour. During this time environmental conditions such as temperature and altitude
// could change the focus point. For these reasons, it's possible to Adapt each focus run back to the temp and alt applicable
// during the first AF run for more accurate calculation of the offsets. If Adapt Focus is checked, then the adapted
// vales are used in calculations; if not checked then the raw (unadapted) values are used. The toggle can be used at
// any time. The tooltip on each AF run provides a tabular explanation of the adaptations and how the raw value is
// changed to the adapted value. In order to use Adapt Focus, the ticks per temperature and ticks per altitude fields
// in the Filter Settings popup need to be filled in appropriately for each filter being processed.
//
// If AF fails then the user is prompted to retry or abort the processing.
//
// When processing completes the user can review the results. Each processed filter's new offset has an associated save
// checkbox allowing all or some values to be saved. Saving persists the new offset values in the Filter Settings popup
// for future use during imaging.
//
// The average AF value for a filter is a simple mean. There are typically not enough sample points taken for robust
// statistical processing to add any value. So the user needs to review the AF values and decide if they want to remove
// any outliers (set the AF value to 0 to exclude from processing, or adjust the number). In addition, it is possible
// to override the offset with a manually entered value.
//
BuildFilterOffsets::BuildFilterOffsets(FilterManager *filterManager) : QDialog(filterManager)
{
#ifdef Q_OS_MACOS
    setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
#endif

    m_filterManager = filterManager;

    setupUi(this);
    setupConnections();
    setupGUIOnce();

    setObjectName(QString("BuildFilterOffsets:%1").arg(filterManager->filterWheel()->getDeviceName()));
}

BuildFilterOffsets::~BuildFilterOffsets()
{
}

void BuildFilterOffsets::showDialog()
{
    // Reset selection so all filters are used in GUI mode
    m_selectedFilters.clear();

    // Initialize for a fresh session
    initialize();

    // Re-setup the per-show GUI elements (buttons, status bar, etc.)
    setupGUI();

    // Reset the adapt focus checkbox to match saved option
    buildOffsetsAdaptFocus->setChecked(Options::adaptFocusBFO());

    // Reset the progress bar
    buildOffsetsProgressBar->reset();

    // Resize the dialog
    buildOffsetsDialogResize();

    // Show the dialog - synchronous call
    this->exec();
}

void BuildFilterOffsets::initialize()
{
    // Re-initialize state each time
    initBuildFilterOffsets();

    // Reset the model - clear all data and reset to initial columns
    m_BFOModel.clear();

    // Clear AF solutions from previous runs
    m_AFSolutions.clear();

    // Re-setup the table with current filter data
    setupBuildFilterOffsetsTable();
}

void BuildFilterOffsets::setFilterSelection(const QStringList &filters)
{
    m_selectedFilters = filters;
    // Request 5 AF runs by default for each selected filter
    for (const auto &filter : filters)
        setNumAFRuns(filter, 5);
}

void BuildFilterOffsets::setNumAFRuns(const QString &filter, int runs)
{
    // Find the row for this filter and set the AF runs
    for (int row = 0; row < m_BFOModel.rowCount(); row++)
    {
        if (m_BFOModel.item(row, getColumn(BFO_FILTER))->text().remove(" *") == filter)
        {
            m_BFOModel.setItem(row, getColumn(BFO_NUM_FOCUS_RUNS), new QStandardItem(QString::number(runs)));
            return;
        }
    }
}

void BuildFilterOffsets::setNumAFRuns(int runs)
{
    // Apply to selected filters, or all filters if no selection
    QStringList targets = m_selectedFilters.isEmpty() ? m_filters.toList() : m_selectedFilters;
    for (const auto &filter : targets)
        setNumAFRuns(filter, runs);
}

void BuildFilterOffsets::setRefFilter(const QString &filter)
{
    // Find the row for this filter and set it as reference
    for (int row = 0; row < m_filters.count(); row++)
    {
        if (m_filters[row] == filter)
        {
            m_refFilter = row;
            // Update display: remove * from old ref, add to new
            for (int r = 0; r < m_BFOModel.rowCount(); r++)
            {
                QString text = m_BFOModel.item(r, getColumn(BFO_FILTER))->text();
                if (text.endsWith(" *"))
                    m_BFOModel.setItem(r, getColumn(BFO_FILTER), new QStandardItem(m_filters[r]));
                if (r == row)
                    m_BFOModel.setItem(r, getColumn(BFO_FILTER), new QStandardItem(m_filters[r] + " *"));
            }
            return;
        }
    }
}

void BuildFilterOffsets::startProcessing()
{
    buildTheOffsets();
}

void BuildFilterOffsets::saveOffsets()
{
    saveTheOffsets();
}

QVector<BuildFilterOffsets::AFSolutionDetail> BuildFilterOffsets::getAFSolutions() const
{
    return m_AFSolutions;
}

QMap<QString, int> BuildFilterOffsets::getCalculatedOffsets() const
{
    QMap<QString, int> offsets;
    for (int row = 0; row < m_filters.count(); row++)
    {
        auto *item = m_BFOModel.item(row, getColumn(BFO_NEW_OFFSET));
        if (item)
            offsets.insert(m_filters[row], item->text().toInt());
    }
    return offsets;
}

QStringList BuildFilterOffsets::getSelectedFilters() const
{
    return m_selectedFilters;
}

void BuildFilterOffsets::setupConnections()
{
    if (!m_filterManager)
        return;

    // Connections to FilterManager
    connect(this, &BuildFilterOffsets::runAutoFocus, m_filterManager, &FilterManager::runAutoFocus);
    connect(this, &BuildFilterOffsets::abortAutoFocus, m_filterManager, &FilterManager::abortAutoFocus);

    // Connections from FilterManager
    connect(m_filterManager, &FilterManager::autoFocusDone, this, &BuildFilterOffsets::autoFocusComplete);
    connect(m_filterManager, &FilterManager::ready, this, &BuildFilterOffsets::buildTheOffsetsTaskComplete);

    // Connections internal to BuildFilterOffsets
    connect(this, &BuildFilterOffsets::ready, this, &BuildFilterOffsets::buildTheOffsetsTaskComplete);
}

void BuildFilterOffsets::setupGUIOnce()
{
    // These are one-time connections that only need to be set up once
    // Connect up button callbacks
    connect(buildOffsetsButtonBox->button(QDialogButtonBox::Save), &QPushButton::clicked, this,
            &BuildFilterOffsets::saveTheOffsets);
    connect(buildOffsetsButtonBox->button(QDialogButtonBox::Close), &QPushButton::clicked, this, [this]()
    {
        // Close the dialog down unless lots of processing has been done. In that case put up an "are you sure" popup
        if (!m_tableInEditMode)
            this->done(QDialog::Rejected);
        else if (KMessageBox::warningContinueCancel(KStars::Instance(),
                 i18n("Are you sure you want to quit?")) == KMessageBox::Continue)
            this->done(QDialog::Rejected);
    });

    m_runButton = buildOffsetsButtonBox->addButton(i18n("Run"), QDialogButtonBox::ActionRole);
    m_stopButton = buildOffsetsButtonBox->addButton(i18n("Stop"), QDialogButtonBox::ActionRole);

    // Set tooltips for the buttons
    m_runButton->setToolTip(i18n("Run Build Filter Offsets utility"));
    m_stopButton->setToolTip(i18n("Interrupt processing when utility is running"));
    buildOffsetsButtonBox->button(QDialogButtonBox::Save)->setToolTip(i18n("Save New Offsets"));

    connect(m_runButton, &QPushButton::clicked, this, &BuildFilterOffsets::buildTheOffsets);
    connect(m_stopButton, &QPushButton::clicked, this, &BuildFilterOffsets::stopProcessing);

    // Setup the Adapt Focus checkbox - connect just once
    connect(buildOffsetsAdaptFocus, &QCheckBox::toggled, this, [&](bool checked)
    {
        Options::setAdaptFocusBFO(checked);
        reloadPositions(checked);
    });

    // Connect cell changed callback
    connect(&m_BFOModel, &QStandardItemModel::itemChanged, this, &BuildFilterOffsets::itemChanged);

    // Connect double click callback
    connect(buildOffsetsTableView, &QAbstractItemView::doubleClicked, this, &BuildFilterOffsets::refChanged);
}

void BuildFilterOffsets::setupGUI()
{
    // Buttons are created once in setupGUIOnce(); here we only reset per-show state

    // Set the buttons' state
    setBuildFilterOffsetsButtons(BFO_INIT);

    // Display an initial message in the status bar
    updateStatusBar(i18n("Idle"));

    // Resize the dialog based on the data
    buildOffsetsDialogResize();
}

void BuildFilterOffsets::initBuildFilterOffsets()
{
    m_inBuildOffsets = false;
    m_stopFlag = m_problemFlag = m_abortAFPending = m_tableInEditMode = false;
    m_filters.clear();
    m_refFilter = -1;
    m_rowIdx = m_colIdx = 0;

    // Drain any old queue items
    m_buildOffsetsQ.clear();
}

void BuildFilterOffsets::setupBuildFilterOffsetsTable()
{
    // Setup MVC
    buildOffsetsTableView->setModel(&m_BFOModel);

    // Setup the table view
    QStringList Headers { i18n("Filter"), i18n("Offset"), i18n("Lock Filter"), i18n("# Focus Runs") };
    m_BFOModel.setColumnCount(Headers.count());
    m_BFOModel.setHorizontalHeaderLabels(Headers);

    // Setup tooltips on column headers
    m_BFOModel.setHeaderData(getColumn(BFO_FILTER), Qt::Horizontal,
                             i18n("Filter. * indicates reference filter. Double click to change"),
                             Qt::ToolTipRole);
    m_BFOModel.setHeaderData(getColumn(BFO_NUM_FOCUS_RUNS), Qt::Horizontal, i18n("# Focus Runs. Set per filter. 0 to ignore"),
                             Qt::ToolTipRole);

    // Setup edit delegates for each column
    // No Edit delegates for Filter, Offset and Lock Filter
    NotEditableDelegate *noEditDel = new NotEditableDelegate(buildOffsetsTableView);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_FILTER), noEditDel);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_OFFSET), noEditDel);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_LOCK), noEditDel);

    // # Focus Runs delegate
    IntegerDelegate *numRunsDel = new IntegerDelegate(buildOffsetsTableView, 0, 10, 1);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_NUM_FOCUS_RUNS), numRunsDel);

    // Setup the table
    m_BFOModel.setRowCount(m_filterManager->m_ActiveFilters.count());

    // Load the data for each filter
    for (int row = 0 ; row < m_filterManager->m_ActiveFilters.count(); row++)
    {
        // Filter name
        m_filters.push_back(m_filterManager->m_ActiveFilters[row]->color());
        QString str;
        if (row == 0)
        {
            str = QString("%1 *").arg(m_filters[row]);
            m_refFilter = 0;
        }
        else
            str = m_filters[row];

        QStandardItem* item0 = new QStandardItem(str);
        m_BFOModel.setItem(row, getColumn(BFO_FILTER), item0);

        // Offset
        QStandardItem* item1 = new QStandardItem(QString::number(m_filterManager->m_ActiveFilters[row]->offset()));
        m_BFOModel.setItem(row, getColumn(BFO_OFFSET), item1);

        // Lock filter
        QStandardItem* item2 = new QStandardItem(m_filterManager->m_ActiveFilters[row]->lockedFilter());
        m_BFOModel.setItem(row, getColumn(BFO_LOCK), item2);

        // Number of AF runs to perform
        QStandardItem* item3 = new QStandardItem(QString::number(5));
        m_BFOModel.setItem(row, getColumn(BFO_NUM_FOCUS_RUNS), item3);
    }
}

void BuildFilterOffsets::setBuildFilterOffsetsButtons(const BFOButtonState state)
{
    // In programmatic mode, buttons are not created
    if (!m_runButton || !m_stopButton)
        return;

    switch (state)
    {
        case BFO_INIT:
            m_runButton->setEnabled(true);
            m_stopButton->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Save)->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Close)->setEnabled(true);
            break;

        case BFO_RUN:
            m_runButton->setEnabled(false);
            m_stopButton->setEnabled(true);
            buildOffsetsButtonBox->button(QDialogButtonBox::Save)->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Close)->setEnabled(false);
            break;

        case BFO_SAVE:
            m_runButton->setEnabled(false);
            m_stopButton->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Save)->setEnabled(true);
            buildOffsetsButtonBox->button(QDialogButtonBox::Close)->setEnabled(true);
            break;

        case BFO_STOP:
            m_runButton->setEnabled(false);
            m_stopButton->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Save)->setEnabled(false);
            buildOffsetsButtonBox->button(QDialogButtonBox::Close)->setEnabled(false);
            break;

        default:
            break;
    }
}

// Loop through all the filters to process, and for each...
// - set Autofocus to use the filter
// - Loop for the number of runs chosen by the user for that filter
//   - Run AF
//   - Get the focus solution
//   - Load the focus solution into table widget in the appropriate cell
// - Calculate the average AF solution for that filter and display it
void BuildFilterOffsets::buildTheOffsets()
{
    // If the dialog hasn't been set up yet (programmatic/EkosLive path),
    // initialize and show it non-modally so the user can see progress,
    // then fall through to continue processing.
    if (m_BFOModel.rowCount() == 0)
    {
        initialize();
        setupGUI();
        buildOffsetsAdaptFocus->setChecked(Options::adaptFocusBFO());
        buildOffsetsProgressBar->reset();
        buildOffsetsDialogResize();
        show();
    }

    buildOffsetsQItem qItem;

    // Set the buttons
    setBuildFilterOffsetsButtons(BFO_RUN);

    // Make the Number of runs column not editable
    // No Edit delegates for Filter, Offset and Lock Filter
    QPointer<NotEditableDelegate> noEditDel = new NotEditableDelegate(buildOffsetsTableView);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_NUM_FOCUS_RUNS), noEditDel);

    // Loop through the work to do and load up Queue and extend tableWidget to record AF answers
    int maxAFRuns = 0;
    int startRow = -1;
    for (int row = 0; row < m_filters.count(); row++ )
    {
        const int numRuns = m_BFOModel.item(row, getColumn(BFO_NUM_FOCUS_RUNS))->text().toInt();
        if (numRuns > 0)
        {
            if (startRow < 0)
                startRow = row;

            // Set the filter change
            qItem.color = m_filters[row];
            qItem.changeFilter = true;
            m_buildOffsetsQ.enqueue(qItem);

            // Load up the AF runs based on how many the user requested
            qItem.changeFilter = false;
            maxAFRuns = std::max(maxAFRuns, numRuns);
            for (int runNum = 1; runNum <= numRuns; runNum++)
            {
                qItem.numAFRun = runNum;
                m_buildOffsetsQ.enqueue(qItem);
            }
        }
    }

    // Add columns to the Model for AF runs and set the headers. Each AF run result is editable
    // but the calculated average is not.
    int origCols = m_BFOModel.columnCount();
    m_BFOModel.setColumnCount(origCols + maxAFRuns + 3);
    for (int col = 0; col < maxAFRuns; col++)
    {
        QStandardItem *newItem = new QStandardItem(i18n("AF Run %1", col + 1));
        m_BFOModel.setHorizontalHeaderItem(origCols + col, newItem);
        m_BFOModel.setHeaderData(origCols + col, Qt::Horizontal,
                                 i18n("AF Run %1. Calculated automatically but can be edited. Set to 0 to exclude from average.", col + 1),
                                 Qt::ToolTipRole);
        buildOffsetsTableView->setItemDelegateForColumn(origCols + col, noEditDel);
    }

    // Add 3 more columns for the average of the AF runs, the offset and whether to save the offset
    QStandardItem *averageItem = new QStandardItem(i18n("Average"));
    m_BFOModel.setHorizontalHeaderItem(origCols + maxAFRuns, averageItem);
    m_BFOModel.setHeaderData(origCols + maxAFRuns, Qt::Horizontal, i18n("AF Average (mean)."), Qt::ToolTipRole);
    buildOffsetsTableView->setItemDelegateForColumn(origCols + maxAFRuns, noEditDel);

    QStandardItem *offsetItem = new QStandardItem(i18n("New Offset"));
    m_BFOModel.setHorizontalHeaderItem(origCols + maxAFRuns + 1, offsetItem);
    m_BFOModel.setHeaderData(origCols + maxAFRuns + 1, Qt::Horizontal,
                             i18n("New Offset. Calculated relative to Filter with *. Can be edited."), Qt::ToolTipRole);
    buildOffsetsTableView->setItemDelegateForColumn(origCols + maxAFRuns + 1, noEditDel);

    QPointer<ToggleDelegate> saveDelegate = new ToggleDelegate(&m_BFOModel);
    QStandardItem *saveItem = new QStandardItem(i18n("Save"));
    m_BFOModel.setHorizontalHeaderItem(origCols + maxAFRuns + 2, saveItem);
    m_BFOModel.setHeaderData(origCols + maxAFRuns + 2, Qt::Horizontal,
                             i18n("Save. Check to save the New Offset for the associated Filter."), Qt::ToolTipRole);
    buildOffsetsTableView->setItemDelegateForColumn(origCols + maxAFRuns + 2, saveDelegate);

    // Resize the dialog
    buildOffsetsDialogResize();

    // Set the selected cell to the first AF run of the ref filter
    m_rowIdx = startRow;
    m_colIdx = getColumn(BFO_AF_RUN_1);
    QModelIndex index = buildOffsetsTableView->model()->index(m_rowIdx, m_colIdx);
    buildOffsetsTableView->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect);

    // Initialise the progress bar
    buildOffsetsProgressBar->reset();
    buildOffsetsProgressBar->setRange(0, m_buildOffsetsQ.count());
    buildOffsetsProgressBar->setValue(0);

    // The Q has been loaded with all required actions so lets start processing
    m_inBuildOffsets = true;

    // Emit progress
    emit progressUpdated(0, m_buildOffsetsQ.count(), i18n("Starting Build Filter Offsets..."));
    runBuildOffsets();
}

// This is signalled when an asynchronous task has been completed, either
// a filter change or an autofocus run
void BuildFilterOffsets::buildTheOffsetsTaskComplete()
{
    if (m_stopFlag)
    {
        if (m_remoteStop)
        {
            m_stopFlag = m_abortAFPending = m_remoteStop = false;
            // Notify listeners that processing has stopped
            emit progressUpdated(0, 0, i18n("Stopped"));
            this->done(QDialog::Rejected);
        }
        // User hit the stop button, so see what they want to do
        else if (KMessageBox::warningContinueCancel(KStars::Instance(),
                 i18n("Are you sure you want to stop Build Filter Offsets?"), i18n("Stop Build Filter Offsets"),
                 KStandardGuiItem::stop(), KStandardGuiItem::cancel(), "") == KMessageBox::Cancel)
        {
            // User wants to retry processing
            m_stopFlag = false;
            setBuildFilterOffsetsButtons(BFO_RUN);

            if (m_abortAFPending)
            {
                // If the in-flight task was aborted then retry - don't take the next task off the Q
                m_abortAFPending = false;
                processQItem(m_qItemInProgress);
            }
            else
            {
                // No tasks were aborted so we can just start the next task in the queue
                buildOffsetsProgressBar->setValue(buildOffsetsProgressBar->value() + 1);
                runBuildOffsets();
            }
        }
        else
        {
            // User wants to abort
            m_stopFlag = m_abortAFPending = false;
            // Notify listeners that processing has stopped
            emit progressUpdated(0, 0, i18n("Stopped"));
            this->done(QDialog::Rejected);
        }
    }
    else if (m_problemFlag)
    {
        // The in flight task had a problem so see what the user wants to do
        if (KMessageBox::warningContinueCancel(KStars::Instance(),
                                               i18n("An unexpected problem occurred.\nStop Build Filter Offsets, or Cancel to retry?"),
                                               i18n("Build Filter Offsets Unexpected Problem"),
                                               KStandardGuiItem::stop(), KStandardGuiItem::cancel(), "") == KMessageBox::Cancel)
        {
            // User wants to retry
            m_problemFlag = false;
            processQItem(m_qItemInProgress);
        }
        else
        {
            // User wants to abort
            m_problemFlag = false;
            // Notify listeners that processing has stopped due to a problem
            emit progressUpdated(0, 0, i18n("Stopped"));
            this->done(QDialog::Rejected);
        }
    }
    else
    {
        // All good so update the progress bar and process the next task
        buildOffsetsProgressBar->setValue(buildOffsetsProgressBar->value() + 1);
        emit progressUpdated(buildOffsetsProgressBar->value(), buildOffsetsProgressBar->maximum(),
                             buildOffsetsStatusBar->currentMessage());
        runBuildOffsets();
    }
}

// Resize the dialog to the data
void BuildFilterOffsets::buildOffsetsDialogResize()
{
    // Resize the columns to the data
    buildOffsetsTableView->horizontalHeader()->resizeSections(QHeaderView::ResizeToContents);

    // Resize the dialog to the width and height of the table widget
    const int width = buildOffsetsTableView->horizontalHeader()->length() + 40;
    const int height = buildOffsetsTableView->verticalHeader()->length() + buildOffsetsButtonBox->height() +
                       buildOffsetsProgressBar->height() + buildOffsetsStatusBar->height() + 60;
    this->resize(width, height);
}

void BuildFilterOffsets::updateStatusBar(const QString &message)
{
    buildOffsetsStatusBar->showMessage(message);
    emit progressUpdated(buildOffsetsProgressBar->value(), buildOffsetsProgressBar->maximum(),
                         message);
}

void BuildFilterOffsets::runBuildOffsets()
{
    if (m_buildOffsetsQ.isEmpty())
    {
        // All tasks have been actioned so allow the user to edit the results, save them or quit.
        setCellsEditable();
        setBuildFilterOffsetsButtons(BFO_SAVE);
        m_tableInEditMode = true;
        updateStatusBar(i18n("Processing complete."));
        Q_EMIT processingComplete();
    }
    else
    {
        // Take the next item off the queue
        m_qItemInProgress = m_buildOffsetsQ.dequeue();
        processQItem(m_qItemInProgress);
    }
}

void BuildFilterOffsets::processQItem(const buildOffsetsQItem currentItem)
{
    if (currentItem.changeFilter)
    {
        // Need to change filter
        updateStatusBar(i18n("Changing filter to %1...", currentItem.color));

        auto pos = m_filterManager->m_currentFilterLabels.indexOf(currentItem.color) + 1;
        if (!m_filterManager->setFilterPosition(pos, m_filterManager->CHANGE_POLICY))
        {
            // Filter wheel position change failed.
            updateStatusBar(i18n("Problem changing filter to %1...", currentItem.color));
            m_problemFlag = true;
        }
    }
    else
    {
        // Signal an AF run with an arg of "build offsets"
        const int run = m_colIdx - getColumn(BFO_AF_RUN_1) + 1;
        const int numRuns = m_BFOModel.item(m_rowIdx, getColumn(BFO_NUM_FOCUS_RUNS))->text().toInt();
        updateStatusBar(i18n("Running Autofocus on %1 (%2/%3)...", currentItem.color, run, numRuns));
        Q_EMIT runAutoFocus(AutofocusReason::FOCUS_FILTER_OFFSETS, "");
    }
}

// This is called at the end of an AF run
void BuildFilterOffsets::autoFocusComplete(FocusState completionState, int position, double temperature, double altitude)
{
    if (!m_inBuildOffsets)
        return;

    if (completionState != FOCUS_COMPLETE)
    {
        // The AF run has failed. If the user aborted the run then this is an expected signal
        if (!m_abortAFPending)
            // In this case the failure is a genuine problem so set a problem flag
            m_problemFlag = true;
    }
    else
    {
        // AF run was successful so load the solution results
        processAFcomplete(position, temperature, altitude);

        // Load the result into the table. The Model update will trigger further updates
        loadPosition(buildOffsetsAdaptFocus->isChecked(), m_rowIdx, m_colIdx);

        // Now see what's next, another AF run on this filter or are we moving to the next filter
        if (m_colIdx - getColumn(BFO_NUM_FOCUS_RUNS) < getNumRuns(m_rowIdx))
            m_colIdx++;
        else
        {
            // Move the active cell to the next AF run in the table
            // Usually this will be the next row, but if this row has zero AF runs skip to the next
            for (int nextRow = m_rowIdx + 1; nextRow < m_filters.count(); nextRow++)
            {
                if (getNumRuns(nextRow) > 0)
                {
                    // Found the next filter to process
                    m_rowIdx = nextRow;
                    m_colIdx = getColumn(BFO_AF_RUN_1);
                    break;
                }
            }
        }
        // Highlight the next cell...
        const QModelIndex index = buildOffsetsTableView->model()->index(m_rowIdx, m_colIdx);
        buildOffsetsTableView->selectionModel()->select(index, QItemSelectionModel::ClearAndSelect);
    }
    // Signal the next processing step
    Q_EMIT ready();
}

// Called to store the AF position details. The raw AF position is passed in (from Focus)
// The Adapted position (based on temperature and altitude) is calculated
// The sense of the adaptation is to take the current position and adapt it to the
// conditions (temp and alt) of the reference.
void BuildFilterOffsets::processAFcomplete(const int position, const double temperature, const double altitude)
{
    AFSolutionDetail solution;

    solution.position = position;
    solution.temperature = temperature;
    solution.altitude = altitude;
    solution.ticksPerTemp = m_filterManager->getFilterTicksPerTemp(m_filters[m_rowIdx]);
    solution.ticksPerAlt = m_filterManager->getFilterTicksPerAlt(m_filters[m_rowIdx]);

    if (m_rowIdx == 0 && m_colIdx == getColumn(BFO_AF_RUN_1))
    {
        // Set the reference temp and alt to be the first AF run
        m_refTemperature = temperature;
        m_refAltitude = altitude;
    }

    // Calculate the temperature adaptation
    if (temperature == INVALID_VALUE || m_refTemperature == INVALID_VALUE)
    {
        solution.deltaTemp = 0.0;
        solution.deltaTicksTemperature = 0.0;
    }
    else
    {
        solution.deltaTemp = m_refTemperature - temperature;
        solution.deltaTicksTemperature = solution.ticksPerTemp * solution.deltaTemp;
    }

    // Calculate the altitude adaptation
    if (altitude == INVALID_VALUE || m_refAltitude == INVALID_VALUE)
    {
        solution.deltaAlt = 0.0;
        solution.deltaTicksAltitude = 0.0;
    }
    else
    {
        solution.deltaAlt = m_refAltitude - altitude;
        solution.deltaTicksAltitude = solution.ticksPerAlt * solution.deltaAlt;
    }

    // Calculate the total adaptation
    solution.deltaTicksTotal = static_cast<int>(round(solution.deltaTicksTemperature + solution.deltaTicksAltitude));

    // Calculate the Adapted position
    solution.adaptedPosition = position + solution.deltaTicksTotal;

    m_AFSolutions.push_back(solution);
}

// Load the focus position depending on the setting of the adaptPos checkbox. The Model update will trigger further updates
void BuildFilterOffsets::loadPosition(const bool checked, const int row, const int col)
{
    int idx = 0;
    // Work out the array index for m_AFSolutions
    for (int i = 0; i < row; i++)
        idx += getNumRuns(i);

    idx += col - getColumn(BFO_AF_RUN_1);

    // Check that the passed in row, col has been processed. If not, nothing to do
    if (idx < m_AFSolutions.count())
    {
        // Get the AF position to use based on the setting of 'checked'
        int pos = (checked) ? m_AFSolutions[idx].adaptedPosition : m_AFSolutions[idx].position;

        // Present a tooltip explanation of how the original position is changed by adaptation, e.g.
        //                    Adapt Focus Explainer
        //               Position  Temperature (°C)   Altitude (°Alt)
        // Measured Pos: 36704     T: 0.9°C (ΔT=0.7)  Alt: 40.1° (ΔAlt=-4.2)
        // Adaptations:      4     T: 7.10 ticks      Alt: -3.20 ticks
        // Adapted Pos:  36708
        //
        const QString temp = QString("%1").arg(m_AFSolutions[idx].temperature, 0, 'f', 1);
        const QString deltaTemp = i18n("(ΔT=%1)", QString("%1").arg(m_AFSolutions[idx].deltaTemp, 0, 'f', 1));
        const QString ticksTemp = i18n("(%1 ticks)", QString("%1").arg(m_AFSolutions[idx].deltaTicksTemperature, 0, 'f', 1));
        const QString alt = QString("%1").arg(m_AFSolutions[idx].altitude, 0, 'f', 1);
        const QString deltaAlt = i18n("(ΔAlt=%1)", QString("%1").arg(m_AFSolutions[idx].deltaAlt, 0, 'f', 1));
        const QString ticksAlt = i18n("(%1 ticks)", QString("%1").arg(m_AFSolutions[idx].deltaTicksAltitude, 0, 'f', 1));

        QStandardItem *posItem = new QStandardItem(QString::number(pos));
        const QString toolTip =
            i18nc("Graphics tooltip; column 1 is a header, column 2 is focus position, column 3 is temperature in °C, column 4 is altitude in °Alt"
              "Row 1 is the headers, row 2 is the measured position, row 3 are the adaptations for temperature and altitude, row 4 is adapted position",
              "<head><style>"
              "  th, td, caption {white-space: nowrap; padding-left: 5px; padding-right: 5px;}"
              "  th { text-align: left;}"
              "  td { text-align: right;}"
              "  caption { text-align: center; vertical-align: top; font-weight: bold; margin: 0px; padding-bottom: 5px;}"
              "</head></style>"
              "<body><table>"
              "<caption align=top>Adapt Focus Explainer</caption>"
              "<tr><th></th><th>Position</th><th>Temperature (°C)</th><th>Altitude (°Alt)</th></tr>"
              "<tr><th>Measured Pos</th><td>%1</td><td>%2 %3</td><td>%4 %5</td></tr>"
              "<tr><th>Adaptations</th><td>%6</td><td>%7</td><td>%8</td></tr>"
              "<tr><th>Adapted Pos</th><td>%9</td></tr>"
              "</table></body>",
                  m_AFSolutions[idx].position, temp, deltaTemp, alt, deltaAlt,
                  m_AFSolutions[idx].deltaTicksTotal, ticksTemp, ticksAlt,
                  m_AFSolutions[idx].adaptedPosition);

        posItem->setToolTip(toolTip);
        m_BFOModel.setItem(row, col, posItem);
    }
}

// Reload the focus position grid depending on the setting of the adaptPos checkbox.
void BuildFilterOffsets::reloadPositions(const bool checked)
{
    for (int row = 0; row <= m_rowIdx; row++)
    {
        const int numRuns = getNumRuns(row);
        const int maxCol = (row < m_rowIdx) ? numRuns : m_colIdx - getColumn(BFO_AF_RUN_1) + 1;
        for (int col = 0; col < maxCol; col++)
            loadPosition(checked, row, col + getColumn(BFO_AF_RUN_1));
    }
}

// Called when the user wants to persist the calculated offsets
void BuildFilterOffsets::saveTheOffsets()
{
    for (int row = 0; row < m_filters.count(); row++)
    {
        // Check there's an item set for the current row before accessing
        auto *saveItem = m_BFOModel.item(row, getColumn(BFO_SAVE_CHECK));
        if (saveItem && saveItem->text().toInt())
        {
            // Save item is set so persist the offset
            auto *offsetItem = m_BFOModel.item(row, getColumn(BFO_NEW_OFFSET));
            const int offset = offsetItem ? offsetItem->text().toInt() : 0;
            if (!m_filterManager->setFilterOffset(m_filters[row], offset))
                qCDebug(KSTARS) << "Unable to save calculated offset for filter " << m_filters[row];
        }
    }
    // All done so close the dialog
    this->done(QDialog::Accepted);
}

// Processing done so make certain cells editable for the user
void BuildFilterOffsets::setCellsEditable()
{
    // Enable an edit delegate on the AF run result columns so the user can adjust as necessary
    // The delegates operate at the row or column level so some cells need to be manually disabled
    for (int col = getColumn(BFO_AF_RUN_1); col < getColumn(BFO_AF_RUN_1) + getMaxRuns(); col++)
    {
        IntegerDelegate *AFDel = new IntegerDelegate(buildOffsetsTableView, 0, 1000000, 1);
        buildOffsetsTableView->setItemDelegateForColumn(col, AFDel);

        // Disable any cells where for that filter less AF runs were requested
        for (int row = 0; row < m_BFOModel.rowCount(); row++)
        {
            const int numRuns = getNumRuns(row);
            if ((numRuns > 0) && (col > numRuns + getColumn(BFO_AF_RUN_1) - 1))
            {
                QStandardItem *currentItem = new QStandardItem();
                currentItem->setEditable(false);
                m_BFOModel.setItem(row, col, currentItem);
            }
        }
    }

    // Offset column
    IntegerDelegate *offsetDel = new IntegerDelegate(buildOffsetsTableView, -500000, 500000, 1);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_NEW_OFFSET), offsetDel);

    // Save column
    ToggleDelegate *saveDel = new ToggleDelegate(buildOffsetsTableView);
    buildOffsetsTableView->setItemDelegateForColumn(getColumn(BFO_SAVE_CHECK), saveDel);

    // Check filters where user requested zero AF runs
    for (int row = 0; row < m_filters.count(); row++)
    {
        if (getNumRuns(row) <= 0)
        {
            // Uncheck save just in case user activated it
            QStandardItem *saveItem = new QStandardItem("");
            m_BFOModel.setItem(row, getColumn(BFO_SAVE_CHECK), saveItem);
            NotEditableDelegate *newDelegate = new NotEditableDelegate(buildOffsetsTableView);
            buildOffsetsTableView->setItemDelegateForRow(row, newDelegate);
        }
    }
}

void BuildFilterOffsets::stopProcessing()
{
    m_stopFlag = true;
    m_remoteStop = true;
    setBuildFilterOffsetsButtons(BFO_STOP);

    if (m_qItemInProgress.changeFilter)
    {
        // Change filter in progress. Let it run to completion
        m_abortAFPending = false;
    }
    else
    {
        // AF run is currently in progress so signal an abort
        updateStatusBar(i18n("Aborting Autofocus..."));
        m_abortAFPending = true;
        Q_EMIT abortAutoFocus();
    }
}

// Callback when an item in the Model is changed
void BuildFilterOffsets::itemChanged(QStandardItem *item)
{
    if (item->column() == getColumn(BFO_NUM_FOCUS_RUNS))
    {
        if (item->row() == m_refFilter && m_BFOModel.item(item->row(), item->column())->text().toInt() == 0)
            // If user is trying to set the num AF runs of the ref filter to 0, set to 1
            m_BFOModel.setItem(item->row(), item->column(), new QStandardItem(QString::number(1)));
    }
    else if ((item->column() >= getColumn(BFO_AF_RUN_1)) && (item->column() < getColumn(BFO_AVERAGE)))
    {
        // One of the AF runs has changed so recalc the Average and Offset
        calculateAFAverage(item->row(), item->column());
        calculateOffset(item->row());
    }
}

// Callback when a row in the Model is changed
void BuildFilterOffsets::refChanged(QModelIndex index)
{
    if (m_inBuildOffsets)
        return;

    const int row = index.row();
    const int col = index.column();
    if (col == 0 && row >= 0 && row < m_filters.count() && row != m_refFilter)
    {
        // User double clicked the filter column in a different cell to the current ref filter
        QStandardItem* itemSelect = new QStandardItem(QString("%1 *").arg(m_filters[row]));
        m_BFOModel.setItem(row, getColumn(BFO_FILTER), itemSelect);

        // The ref filter needs to have at least 1 AF run so that there is an average
        // solution to base the offsets of other filters against... so force this condition
        if (getNumRuns(row) == 0)
            m_BFOModel.setItem(row, getColumn(BFO_NUM_FOCUS_RUNS), new QStandardItem(QString::number(1)));

        // Reset the previous selection
        QStandardItem* itemDeselect = new QStandardItem(m_filters[m_refFilter]);
        m_BFOModel.setItem(m_refFilter, getColumn(BFO_FILTER), itemDeselect);

        m_refFilter = row;
        // Resize the cols and dialog
        buildOffsetsDialogResize();
    }
}

// This routine calculates the average of the AF runs. Given that the number of runs is likely to be low
// a simple average is used. The user may manually adjust the values.
void BuildFilterOffsets::calculateAFAverage(const int row, const int col)
{
    int numRuns;
    if (m_tableInEditMode)
        numRuns = getNumRuns(row);
    else
        numRuns = col - getColumn(BFO_AF_RUN_1) + 1;

    // Firstly, the average of the AF runs
    double total = 0;
    int useableRuns = numRuns;
    for(int i = 0; i < numRuns; i++)
    {
        auto *item = m_BFOModel.item(row, getColumn(BFO_AF_RUN_1) + i);
        int j = item ? item->text().toInt() : 0;
        if (j > 0)
            total += j;
        else
            useableRuns--;
    }

    const int average = (useableRuns > 0) ? static_cast<int>(round(total / useableRuns)) : 0;

    // Update the Model with the newly calculated average
    QStandardItem *averageItem = new QStandardItem(QString::number(average));
    m_BFOModel.setItem(row, getColumn(BFO_AVERAGE), averageItem);
}

// calculateOffset updates new offsets when AF averages have been calculated. There are 2 possibilities:
// 1. The updated row is the reference filter in the list so update the offset of other filters
// 2. The updated row is another filter in which case just update its offset
void BuildFilterOffsets::calculateOffset(const int row)
{
    if (row == m_refFilter)
    {
        // The first filter has been changed so loop through the other filters and adjust the offsets
        if (m_tableInEditMode)
        {
            for (int i = 0; i < m_filters.count(); i++)
            {
                if (i != m_refFilter && getNumRuns(i) > 0)
                    calculateOffset(i);
            }
        }
        else
        {
            // If there are some filters higher in the table than ref filter then these can only be
            // proessed now that the ref filter has been processed.
            for (int i = 0; i < m_refFilter; i++)
            {
                if (getNumRuns(i) > 0)
                    calculateOffset(i);
            }
        }
    }

    // If we haven't processed the ref filter yet then skip over
    if (m_rowIdx >= m_refFilter)
    {
        // The ref filter has been processed so we can calculate the offset from it
        auto *avgItem = m_BFOModel.item(row, getColumn(BFO_AVERAGE));
        auto *refAvgItem = m_BFOModel.item(m_refFilter, getColumn(BFO_AVERAGE));
        if (!avgItem || !refAvgItem)
            return;
        const int average = avgItem->text().toInt();
        const int refFilterAverage = refAvgItem->text().toInt();

        // Calculate the offset and set it in the model
        const int offset = average - refFilterAverage;
        QStandardItem *offsetItem = new QStandardItem(QString::number(offset));
        m_BFOModel.setItem(row, getColumn(BFO_NEW_OFFSET), offsetItem);

        // Set the save checkbox
        QStandardItem *saveItem = new QStandardItem(QString::number(1));
        m_BFOModel.setItem(row, getColumn(BFO_SAVE_CHECK), saveItem);

        // Notify listeners that a filter's offset has been calculated
        Q_EMIT offsetCalculated(m_filters[row], offset, average);
    }
}

// Returns the column in the table for the passed in id. The structure is:
// Col 0 -- BFO_FILTER         -- Filter name
// Col 1 -- BFO_OFFSET         -- Current offset value
// Col 2 -- BFO_LOCK           -- Lock filter name
// Col 3 -- BFO_NUM_FOCUS_RUNS -- Number of AF runs
// Col 4 -- BFO_AF_RUN_1       -- 1st AF run
// Col x -- BFO_AVERAGE        -- Average AF run. User selects the number of AF runs at run time
// Col y -- BFO_NEW_OFFSET     -- New offset.
// Col z -- BFO_SAVE_CHECK     -- Save checkbox
int BuildFilterOffsets::getColumn(const BFOColID id) const
{
    switch (id)
    {
        case BFO_FILTER:
        case BFO_OFFSET:
        case BFO_LOCK:
        case BFO_NUM_FOCUS_RUNS:
        case BFO_AF_RUN_1:
            break;

        case BFO_AVERAGE:
            return m_BFOModel.columnCount() - 3;
            break;

        case BFO_NEW_OFFSET:
            return m_BFOModel.columnCount() - 2;
            break;

        case BFO_SAVE_CHECK:
            return m_BFOModel.columnCount() - 1;
            break;

        default:
            break;
    }
    return id;
}

// Get the number of AF runs for the passed in row
int BuildFilterOffsets::getNumRuns(const int row) const
{
    auto *item = m_BFOModel.item(row, getColumn(BFO_NUM_FOCUS_RUNS));
    return item ? item->text().toInt() : 0;
}

// Get the maximum number of AF runs
int BuildFilterOffsets::getMaxRuns() const
{
    return getColumn(BFO_AVERAGE) - getColumn(BFO_AF_RUN_1);
}

QVariantMap BuildFilterOffsets::getAllSettings()
{
    // Lazy-initialize if called before the dialog is shown (programmatic path)
    if (m_BFOModel.rowCount() == 0)
        initialize();

    QVariantMap settings;
    QVariantList filtersList;

    for (int row = 0; row < m_filters.count(); row++)
    {
        QVariantMap filterData;
        filterData["filter"] = m_filters[row];
        filterData["selected"] = true;

        if (m_BFOModel.item(row, getColumn(BFO_OFFSET)))
            filterData["offset"] = m_BFOModel.item(row, getColumn(BFO_OFFSET))->text().toInt();
        else
            filterData["offset"] = 0;

        if (m_BFOModel.item(row, getColumn(BFO_LOCK)))
            filterData["lock"] = m_BFOModel.item(row, getColumn(BFO_LOCK))->text();
        else
            filterData["lock"] = QString();

        if (m_BFOModel.item(row, getColumn(BFO_NUM_FOCUS_RUNS)))
            filterData["numFocusRuns"] = m_BFOModel.item(row, getColumn(BFO_NUM_FOCUS_RUNS))->text().toInt();
        else
            filterData["numFocusRuns"] = 0;

        // Include AF run results
        {
            QVariantList afRuns;
            const int numRuns = getNumRuns(row);
            for (int run = 0; run < numRuns; run++)
            {
                auto *item = m_BFOModel.item(row, getColumn(BFO_AF_RUN_1) + run);
                if (item)
                    afRuns.append(item->text().toInt());
                else
                    afRuns.append(0);
            }
            filterData["afRuns"] = afRuns;

            auto *avgItem = m_BFOModel.item(row, getColumn(BFO_AVERAGE));
            filterData["average"] = avgItem ? avgItem->text().toInt() : 0;

            auto *newOffsetItem = m_BFOModel.item(row, getColumn(BFO_NEW_OFFSET));
            filterData["newOffset"] = newOffsetItem ? newOffsetItem->text().toInt() : 0;

            auto *saveItem = m_BFOModel.item(row, getColumn(BFO_SAVE_CHECK));
            filterData["save"] = saveItem ? saveItem->text().toInt() : 0;
        }

        filtersList.append(filterData);
    }

    settings["filters"] = filtersList;
    settings["refFilter"] = (m_refFilter >= 0 && m_refFilter < m_filters.count()) ? m_filters[m_refFilter] : QString();
    settings["adaptFocus"] = buildOffsetsAdaptFocus->isChecked();
    settings["inBuildOffsets"] = m_inBuildOffsets;
    settings["tableInEditMode"] = m_tableInEditMode;
    settings["progress"] = buildOffsetsProgressBar->value();
    settings["progressMax"] = buildOffsetsProgressBar->maximum();
    settings["status"] = buildOffsetsStatusBar->currentMessage();

    return settings;
}

void BuildFilterOffsets::setAllSettings(const QVariantMap &settings)
{
    // Lazy-initialize if called before the dialog is shown (programmatic path)
    if (m_BFOModel.rowCount() == 0)
        initialize();

    if (settings.contains("adaptFocus"))
        buildOffsetsAdaptFocus->setChecked(settings["adaptFocus"].toBool());

    if (settings.contains("filters"))
    {
        const QVariantList filtersList = settings["filters"].toList();
        for (int row = 0; row < filtersList.size() && row < m_filters.count(); row++)
        {
            const QVariantMap filterData = filtersList[row].toMap();

            if (filterData.contains("numFocusRuns"))
            {
                QStandardItem *item = new QStandardItem(QString::number(filterData["numFocusRuns"].toInt()));
                m_BFOModel.setItem(row, getColumn(BFO_NUM_FOCUS_RUNS), item);
            }

            if (filterData.contains("offset"))
            {
                QStandardItem *item = new QStandardItem(QString::number(filterData["offset"].toInt()));
                m_BFOModel.setItem(row, getColumn(BFO_OFFSET), item);
            }

            if (filterData.contains("lock"))
            {
                QStandardItem *item = new QStandardItem(filterData["lock"].toString());
                m_BFOModel.setItem(row, getColumn(BFO_LOCK), item);
            }

            // Support the "selected" field from the app
            if (filterData.contains("selected") && !filterData["selected"].toBool())
            {
                QStandardItem *item = new QStandardItem(QString::number(0));
                m_BFOModel.setItem(row, getColumn(BFO_NUM_FOCUS_RUNS), item);
            }
        }
    }

    // Restore the reference filter by name
    if (settings.contains("refFilter"))
    {
        QString refName = settings["refFilter"].toString();
        if (!refName.isEmpty())
            setRefFilter(refName);
    }
}

}