/*  Ekos state machine for a single capture job sequence
    SPDX-FileCopyrightText: Wolfgang Reissenberger <sterne-jaeger@openfuture.de>

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

#include "sequencejobstate.h"

#include "Options.h"
#include "kstarsdata.h"
#include "indicom.h"
#include "capturetypes.h"
#include "ekos/auxiliary/rotatorutils.h"
#include <ekos_capture_debug.h>

namespace Ekos
{
SequenceJobState::SequenceJobState(const QSharedPointer<CameraState> &sharedState)
{
    m_CameraState = sharedState;
    m_FlatSyncCheck.setSingleShot(true);
    m_FlatSyncCheck.setInterval(1000);
    m_FlatSyncCheck.callOnTimeout(this, [this]()
    {
        Q_EMIT flatSyncFocus(targetFilterID);
    });

    // Monitor capture operations timeout
    m_CaptureOperationsTimer.setSingleShot(false);
    m_CaptureOperationsTimer.setInterval(Options::captureOperationsTimeout() * 1000);
    connect(&m_CaptureOperationsTimer, &QTimer::timeout, this, [this]()
    {
        // If camera is paused, stop timer completely.
        if (m_CameraState->isCapturePausing() || m_PreparationState == PREP_COMPLETED || m_PreparationState == PREP_NONE)
        {
            m_CaptureOperationsTimer.stop();
            return;
        }

        // If preparation is not complete by now, let should abort.
        if (!preparationCompleted())
        {
            // Map action enum values to readable names
            static const QMap<CaptureWorkflowActionType, QString> actionNames =
            {
                {CAPTURE_ACTION_NONE, "NONE"},
                {CAPTURE_ACTION_START, "START"},
                {CAPTURE_ACTION_PAUSE, "PAUSE"},
                {CAPTURE_ACTION_SUSPEND, "SUSPEND"},
                {CAPTURE_ACTION_FILTER, "FILTER"},
                {CAPTURE_ACTION_TEMPERATURE, "TEMPERATURE"},
                {CAPTURE_ACTION_ROTATOR, "ROTATOR"},
                {CAPTURE_ACTION_PREPARE_LIGHTSOURCE, "LIGHTSOURCE"},
                {CAPTURE_ACTION_MOUNT_PARK, "MOUNT_PARK"},
                {CAPTURE_ACTION_DOME_PARK, "DOME_PARK"},
                {CAPTURE_ACTION_FLAT_SYNC_FOCUS, "FLAT_SYNC_FOCUS"},
                {CAPTURE_ACTION_SCOPE_COVER, "SCOPE_COVER"},
                {CAPTURE_ACTION_AUTOFOCUS, "AUTOFOCUS"},
                {CAPTURE_ACTION_DITHER_REQUEST, "DITHER_REQUEST"},
                {CAPTURE_ACTION_DITHER, "DITHER"},
                {CAPTURE_ACTION_CHECK_GUIDING, "CHECK_GUIDING"},
            };
            QStringList pending;
            for (auto it = prepareActions.constBegin(); it != prepareActions.constEnd(); ++it)
            {
                if (!it.value())
                    pending << actionNames.value(it.key(), QString::number(it.key()));
            }
            qCDebug(KSTARS_EKOS_CAPTURE) << "Capture operations timed out after" << Options::captureOperationsTimeout()
                                         << "seconds. Pending actions:" << pending.join(",")
                                         << "Prep state:" << m_PreparationState;
            Q_EMIT newLog(i18n("Capture operations timed out after %1 seconds.", Options::captureOperationsTimeout()));
            m_CaptureOperationsTimer.stop();
            Q_EMIT prepareComplete(false);
        }
    });
}

void SequenceJobState::setFrameType(CCDFrameType frameType)
{
    // set the frame type
    m_frameType = frameType;
    // reset the preparation state
    m_PreparationState = PREP_NONE;
}

void SequenceJobState::initPreparation(bool isPreview)
{
    m_status           = JOB_BUSY;
    m_isPreview        = isPreview;
    wpScopeStatus      = WP_NONE;
    // Explicitly reset to PREP_NONE so that temperature/rotator callbacks emitted
    // during the brief window before PREP_BUSY is set are NOT silently discarded
    // by preparationCompleted() (which now only returns true for PREP_COMPLETED).
    // checkAllActionsReady() safely ignores calls during PREP_NONE via its
    // "case PREP_NONE: break" branch, preventing premature prepareComplete().
    m_PreparationState = PREP_NONE;
    // Reset the cover query state so that the dialog is re-shown if the job is
    // restarted after being externally aborted (e.g. user stop, guiding failure,
    // captureOperationsTimeout) while a cover confirmation was still pending.
    // The actual physical cover state (m_CameraState->m_ManualCoverState) is NOT
    // reset here, so if the scope was already covered the dialog will not appear.
    coverQueryState    = CAL_CHECK_TASK;
    m_CaptureOperationsTimer.start();
}

void SequenceJobState::prepareLightFrameCapture(bool enforceCCDTemp, bool isPreview)
{
    // precondition: do not start while already being busy and conditions haven't changed
    if (m_status == JOB_BUSY && enforceCCDTemp == m_enforceTemperature)
        return;

    qCDebug(KSTARS_EKOS_CAPTURE) << "Preparing light frame capture (enforce temp:" << enforceCCDTemp << "preview:" << isPreview
                                 << ")";

    // initialize the states
    initPreparation(isPreview);

    // Reset all prepare actions
    setAllActionsReady();

    // disable batch mode for previews
    Q_EMIT setCCDBatchMode(!isPreview);

    // Check if we need to update temperature (only skip if the value is initialized and within the limits)
    prepareTemperatureCheck(enforceCCDTemp);

    // Check if we need to update rotator (only skip if the value is initialized and within the limits)
    prepareRotatorCheck();

    // Hint: Filter changes are actually done in SequenceJob::capture();

    // preparation started
    m_PreparationState = PREP_BUSY;
    // check if the preparations are already completed
    checkAllActionsReady();
}

void SequenceJobState::prepareFlatFrameCapture(bool enforceCCDTemp, bool isPreview)
{
    // precondition: do not start while already being busy and conditions haven't changed
    if (m_status == JOB_BUSY && enforceCCDTemp == m_enforceTemperature)
        return;

    qCDebug(KSTARS_EKOS_CAPTURE) << "Preparing flat frame capture (enforce temp:" << enforceCCDTemp << "preview:" << isPreview
                                 << ")";

    // initialize the states
    initPreparation(isPreview);

    // Reset all prepare actions
    setAllActionsReady();

    // disable batch mode for previews
    Q_EMIT setCCDBatchMode(!isPreview);

    // Check if we need to update temperature (only skip if the value is initialized and within the limits)
    prepareTemperatureCheck(enforceCCDTemp);

    // preparation started
    m_PreparationState = PREP_BUSY;
    // check if the preparations are already completed
    checkAllActionsReady();
}

void SequenceJobState::prepareDarkFrameCapture(bool enforceCCDTemp, bool isPreview)
{
    // precondition: do not start while already being busy and conditions haven't changed
    if (m_status == JOB_BUSY && enforceCCDTemp == m_enforceTemperature)
        return;

    qCDebug(KSTARS_EKOS_CAPTURE) << "Preparing dark frame capture (enforce temp:" << enforceCCDTemp << "preview:" << isPreview
                                 << ")";

    // initialize the states
    initPreparation(isPreview);

    // Reset all prepare actions
    setAllActionsReady();

    // disable batch mode for previews
    Q_EMIT setCCDBatchMode(!isPreview);

    // Check if we need to update temperature (only skip if the value is initialized and within the limits)
    prepareTemperatureCheck(enforceCCDTemp);

    // preparation started
    m_PreparationState = PREP_BUSY;
    // check if the preparations are already completed
    checkAllActionsReady();
}

void SequenceJobState::prepareBiasFrameCapture(bool enforceCCDTemp, bool isPreview)
{
    prepareDarkFrameCapture(enforceCCDTemp, isPreview);
}

bool SequenceJobState::initCapture(CCDFrameType frameType, bool isPreview, bool isAutofocusReady, FITSMode mode)
{
    m_PreparationState = PREP_INIT_CAPTURE;
    autoFocusReady = isAutofocusReady;
    m_fitsMode = mode;
    m_CaptureOperationsTimer.start();

    {
        // Map action enum values to readable names
        static const QMap<CaptureWorkflowActionType, QString> actionNames =
        {
            {CAPTURE_ACTION_NONE, "NONE"}, {CAPTURE_ACTION_START, "START"},
            {CAPTURE_ACTION_PAUSE, "PAUSE"}, {CAPTURE_ACTION_SUSPEND, "SUSPEND"},
            {CAPTURE_ACTION_FILTER, "FILTER"}, {CAPTURE_ACTION_TEMPERATURE, "TEMPERATURE"},
            {CAPTURE_ACTION_ROTATOR, "ROTATOR"}, {CAPTURE_ACTION_PREPARE_LIGHTSOURCE, "LIGHTSOURCE"},
            {CAPTURE_ACTION_MOUNT_PARK, "MOUNT_PARK"}, {CAPTURE_ACTION_DOME_PARK, "DOME_PARK"},
            {CAPTURE_ACTION_FLAT_SYNC_FOCUS, "FLAT_SYNC_FOCUS"}, {CAPTURE_ACTION_SCOPE_COVER, "SCOPE_COVER"},
            {CAPTURE_ACTION_AUTOFOCUS, "AUTOFOCUS"}, {CAPTURE_ACTION_DITHER_REQUEST, "DITHER_REQUEST"},
            {CAPTURE_ACTION_DITHER, "DITHER"}, {CAPTURE_ACTION_CHECK_GUIDING, "CHECK_GUIDING"},
        };
        QStringList actionList;
        for (auto it = prepareActions.constBegin(); it != prepareActions.constEnd(); ++it)
            actionList << QString("%1=%2").arg(actionNames.value(it.key(), QString::number(it.key())),
                                               it.value() ? "ready" : "pending");
        qCDebug(KSTARS_EKOS_CAPTURE) << "initCapture() targetFilter:" << targetFilterID
                                     << "currentFilter:" << m_CameraState->currentFilterID
                                     << "actions:" << actionList.join(", ")
                                     << "filterPolicy:" << m_filterPolicy;
    }

    //check for setting the target filter
    prepareTargetFilter(frameType, isPreview);
    checkAllActionsReady();

    return areActionsReady();
}

bool SequenceJobState::areActionsReady()
{
    for (bool &ready : prepareActions.values())
    {
        if (ready == false)
            return false;
    }

    return true;
}

void SequenceJobState::checkAllActionsReady()
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
    {
        m_CaptureOperationsTimer.stop();
        return;
    }

    switch (m_PreparationState)
    {
        // capture preparation
        case PREP_BUSY:
            switch (m_frameType)
            {
                case FRAME_LIGHT:
                    if (areActionsReady())
                    {
                        // as last step ensure that the scope is uncovered
                        if (checkLightFrameScopeCoverOpen() != IPS_OK)
                            return;

                        qCDebug(KSTARS_EKOS_CAPTURE) << "Light prep: complete, emitting prepareComplete()";
                        m_PreparationState = PREP_COMPLETED;
                        Q_EMIT prepareComplete();
                    }
                    break;
                case FRAME_FLAT:
                    if (!areActionsReady())
                        return;

                    // 1. Check if the selected flats light source is ready
                    if (checkFlatsCoverReady() != IPS_OK)
                        return;

                    // 2. If we used AUTOFOCUS before for a specific frame (e.g. Lum)
                    //    then the absolute focus position for Lum is recorded in the filter manager
                    //    when we take flats again, we always go back to the same focus position as the light frames to ensure
                    //    near identical focus for both frames.
                    if (checkFlatSyncFocus() != IPS_OK)
                        return;

                    // all preparations ready, avoid doubled events
                    if (m_PreparationState == PREP_BUSY)
                    {
                        qCDebug(KSTARS_EKOS_CAPTURE) << "Flat prep: complete, emitting prepareComplete()";
                        m_PreparationState = PREP_COMPLETED;
                        Q_EMIT prepareComplete();
                    }
                    break;
                // darks and bias frames are handled in the same way
                case FRAME_DARK:
                case FRAME_BIAS:
                    if (!areActionsReady())
                        return;

                    // 1. check if the scope is covered appropriately
                    if (checkDarksCoverReady() != IPS_OK)
                        return;

                    // 2. avoid doubled events
                    if (m_PreparationState == PREP_BUSY)
                    {
                        qCDebug(KSTARS_EKOS_CAPTURE) << "Dark/Bias prep: complete, emitting prepareComplete()";
                        m_PreparationState = PREP_COMPLETED;
                        Q_EMIT prepareComplete();
                    }
                    break;
                default:
                    // all other cases not refactored yet, preparation immediately completed
                    Q_EMIT prepareComplete();
                    break;
            }
            break;

        // capture initialization (final preparation steps before starting frame capturing)
        case PREP_INIT_CAPTURE:
            if (areActionsReady())
            {
                // Set PREP_COMPLETED (not PREP_NONE) so that post-capture signals from
                // FilterManager (FILTER_CHANGE, FILTER_IDLE, etc.) emitted while the camera
                // is exposing are silently discarded by preparationCompleted(). Using PREP_NONE
                // here would mean those signals bypass the guard and corrupt the state machine.
                // The next call to initPreparation() will reset the state to PREP_NONE cleanly.
                m_PreparationState = PREP_COMPLETED;
                Q_EMIT initCaptureComplete(m_fitsMode);
            }
            break;

        case PREP_NONE:
        case PREP_COMPLETED:
            // in all other cases do nothing
            break;
    }
}

void SequenceJobState::setAllActionsReady()
{
    QMutableMapIterator<CaptureWorkflowActionType, bool> it(prepareActions);

    while (it.hasNext())
    {
        it.next();
        it.setValue(true);
    }
    // reset the initialization state
    for (CaptureWorkflowActionType action :
            {
                CAPTURE_ACTION_FILTER, CAPTURE_ACTION_ROTATOR, CAPTURE_ACTION_TEMPERATURE
            })
        setInitialized(action, false);

    // reset the autofocus-triggered flag so FILTER_IDLE is not blocked at the start of
    // a new preparation cycle (each cycle must decide independently whether AF was triggered)
    m_filterAutofocusTriggered = false;
}

void SequenceJobState::prepareTargetFilter(CCDFrameType frameType, bool isPreview)
{
    if (targetFilterID != INVALID_VALUE)
    {
        if (isInitialized(CAPTURE_ACTION_FILTER) == false)
        {
            prepareActions[CAPTURE_ACTION_FILTER] = false;

            // Don't perform autofocus on preview or calibration frames or if Autofocus is not ready yet.
            if (isPreview || frameType != FRAME_LIGHT || autoFocusReady == false)
                m_filterPolicy = static_cast<FilterManager::FilterPolicy>(m_filterPolicy & ~FilterManager::AUTOFOCUS_POLICY);

            Q_EMIT readFilterPosition();
        }
        else if (targetFilterID != m_CameraState->currentFilterID)
        {
            // mark filter preparation action
            prepareActions[CAPTURE_ACTION_FILTER] = false;

            // determine policy
            m_filterPolicy = FilterManager::ALL_POLICIES;

            // Don't perform autofocus on preview or calibration frames or if Autofocus is not ready yet.
            if (isPreview || frameType != FRAME_LIGHT || autoFocusReady == false)
                m_filterPolicy = static_cast<FilterManager::FilterPolicy>(m_filterPolicy & ~FilterManager::AUTOFOCUS_POLICY);

            Q_EMIT changeFilterPosition(targetFilterID, m_filterPolicy);
            Q_EMIT prepareState(CAPTURE_CHANGING_FILTER);
        }
    }
}

void SequenceJobState::prepareTemperatureCheck(bool enforceCCDTemp)
{
    // turn on CCD temperature enforcing if required
    m_enforceTemperature = enforceCCDTemp;

    if (m_enforceTemperature)
    {
        prepareActions[CAPTURE_ACTION_TEMPERATURE] = false;
        if (isInitialized(CAPTURE_ACTION_TEMPERATURE))
        {
            // ignore the next value since after setting temperature the next received value will be
            // exactly this value no matter what the CCD temperature
            ignoreNextValue[CAPTURE_ACTION_TEMPERATURE] = true;
            // request setting temperature
            Q_EMIT setCCDTemperature(targetTemperature);
            Q_EMIT prepareState(CAPTURE_SETTING_TEMPERATURE);
        }
        // trigger setting current value first if not initialized
        else
            Q_EMIT readCurrentState(CAPTURE_SETTING_TEMPERATURE);

    }
}

void SequenceJobState::prepareRotatorCheck()
{
    if (targetPositionAngle > Ekos::INVALID_VALUE)
    {
        if (isInitialized(CAPTURE_ACTION_ROTATOR))
        {
            prepareActions[CAPTURE_ACTION_ROTATOR] = false;
            double rawAngle = RotatorUtils::Instance()->calcRotatorAngle(targetPositionAngle);
            Q_EMIT prepareState(CAPTURE_SETTING_ROTATOR);
            Q_EMIT setRotatorAngle(rawAngle);
        }
        // trigger setting current value first if not initialized
        else
            Q_EMIT readCurrentState(CAPTURE_SETTING_ROTATOR);
    }
}

IPState SequenceJobState::checkCalibrationPreActionsReady()
{
    IPState result = IPS_OK;

    if (m_CalibrationPreAction & CAPTURE_PREACTION_WALL)
        result = checkWallPositionReady(FRAME_FLAT);

    if (result != IPS_OK)
        return result;

    if (m_CalibrationPreAction & CAPTURE_PREACTION_PARK_MOUNT)
        result = checkPreMountParkReady();

    if (result != IPS_OK)
        return result;

    if (m_CalibrationPreAction & CAPTURE_PREACTION_PARK_DOME)
        result = checkPreDomeParkReady();

    return result;
}

IPState SequenceJobState::checkFlatsCoverReady()
{
    auto result = checkCalibrationPreActionsReady();
    if (result == IPS_OK)
    {
        // Sky flats require no cover checks
        // But we still need to ensure the dust cap is unparked if present
        // Like capturing light frames.
        if (m_CameraState->skyFlat())
            return checkDustCapReady(FRAME_LIGHT);

        if (m_CameraState->hasDustCap && m_CameraState->hasLightBox)
            return checkDustCapReady(FRAME_FLAT);
        // In case we have a wall action then we are facing a flat light source and we can immediately continue to next step
        else if (m_CalibrationPreAction & CAPTURE_PREACTION_WALL)
            return IPS_OK;
        else
        {
            // In case we ONLY have a lightbox then we need to ensure it's toggled correctly first
            if (m_CameraState->hasLightBox)
                return checkDustCapReady(FRAME_FLAT);

            return checkManualCoverReady(true);
        }
    }

    return result;
}

IPState SequenceJobState::checkDarksCoverReady()
{
    qCDebug(KSTARS_EKOS_CAPTURE) << "Checking darks cover ready...";
    IPState result = checkCalibrationPreActionsReady();;

    if (result == IPS_OK)
    {
        // 1. check if the CCD has a shutter
        qCDebug(KSTARS_EKOS_CAPTURE) << "Checking if camera has shutter...";
        result = checkHasShutter();
        if (result != IPS_OK)
        {
            qCDebug(KSTARS_EKOS_CAPTURE) << "Shutter check returned:" << result;
            return result;
        }

        if (m_CameraState->hasDustCap)
        {
            qCDebug(KSTARS_EKOS_CAPTURE) << "Has dust cap, checking dust cap ready for FRAME_DARK";
            return checkDustCapReady(FRAME_DARK);
        }
        // In case we have a wall action then we are facing a designated location and we can immediately continue to next step
        else if (m_CalibrationPreAction & CAPTURE_PREACTION_WALL)
        {
            qCDebug(KSTARS_EKOS_CAPTURE) << "Wall action configured, darks cover ready";
            return IPS_OK;
        }
        else
        {
            qCDebug(KSTARS_EKOS_CAPTURE) << "No dust cap or wall, checking manual cover";
            return checkManualCoverReady(false);
        }
    }
    qCDebug(KSTARS_EKOS_CAPTURE) << "Calibration pre-actions not ready:" << result;
    return result;
}

IPState SequenceJobState::checkManualCoverReady(bool lightSourceRequired)
{
    // Manual mode we need to cover mount with evenly illuminated field.
    if (lightSourceRequired && m_CameraState->m_ManualCoverState != CameraState::MANUAL_COVER_CLOSED_LIGHT)
    {
        if (coverQueryState == CAL_CHECK_CONFIRMATION)
            return IPS_BUSY;

        // request asking the user to cover the scope manually with a light source
        Q_EMIT askManualScopeCover(i18n("Cover the telescope with an evenly illuminated light source."),
                                   i18n("Flat Frame"), true);
        coverQueryState = CAL_CHECK_CONFIRMATION;

        return IPS_BUSY;
    }
    else if (!lightSourceRequired && m_CameraState->m_ManualCoverState != CameraState::MANUAL_COVER_CLOSED_DARK &&
             m_CameraState->shutterStatus == SHUTTER_NO)
    {
        if (coverQueryState == CAL_CHECK_CONFIRMATION)
            return IPS_BUSY;

        Q_EMIT askManualScopeCover(i18n("Cover the telescope in order to take a dark exposure."),
                                   i18n("Dark Exposure"), false);

        coverQueryState = CAL_CHECK_CONFIRMATION;

        return IPS_BUSY;
    }
    return IPS_OK;
}

IPState SequenceJobState::checkDustCapReady(CCDFrameType frameType)
{
    qCDebug(KSTARS_EKOS_CAPTURE) << "Checking dust cap ready for frame type:" << frameType
                                 << "Current cap state:" << m_CameraState->getDustCapState();

    // turning on flat light running
    if (m_CameraState->getLightBoxLightState() == ISD::LightBox::LIGHT_BUSY  ||
            m_CameraState->getDustCapState() == ISD::DustCap::CAP_PARKING ||
            m_CameraState->getDustCapState() == ISD::DustCap::CAP_UNPARKING)
    {
        qCDebug(KSTARS_EKOS_CAPTURE) << "Dust cap busy (parking/unparking), returning IPS_BUSY";
        return IPS_BUSY;
    }
    // error occurred
    if (m_CameraState->getDustCapState() == ISD::DustCap::CAP_ERROR)
        return IPS_ALERT;

    auto captureLights = (frameType == FRAME_LIGHT);

    // for flats open the cap and close it otherwise
    ISD::DustCap::Status targetCapState = captureLights ? ISD::DustCap::CAP_IDLE : ISD::DustCap::CAP_PARKED;
    // If cap is parked, unpark it since dark cap uses external light source.
    if (m_CameraState->hasDustCap && m_CameraState->getDustCapState() != targetCapState)
    {
        qCDebug(KSTARS_EKOS_CAPTURE) << "Dust cap state mismatch - current:" << m_CameraState->getDustCapState()
                                     << "target:" << targetCapState << "Initiating" << (captureLights ? "unpark" : "park");
        m_CameraState->setDustCapState(captureLights ? ISD::DustCap::CAP_UNPARKING : ISD::DustCap::CAP_PARKING);
        Q_EMIT parkDustCap(!captureLights);
        Q_EMIT newLog(captureLights ? i18n("Unparking dust cap...") : i18n("Parking dust cap..."));
        return IPS_BUSY;
    }

    auto captureFlats = (frameType == FRAME_FLAT);
    ISD::LightBox::LightStatus targetLightBoxStatus = captureFlats ? ISD::LightBox::LIGHT_ON :
        ISD::LightBox::LIGHT_OFF;

    if (m_CameraState->hasLightBox && m_CameraState->getLightBoxLightState() != targetLightBoxStatus)
    {
        m_CameraState->setLightBoxLightState(ISD::LightBox::LIGHT_BUSY);
        Q_EMIT setLightBoxLight(captureFlats);
        Q_EMIT newLog(captureFlats ? i18n("Turn light box light on...") : i18n("Turn light box light off..."));
        return IPS_BUSY;
    }

    // nothing more to do
    qCDebug(KSTARS_EKOS_CAPTURE) << "Dust cap ready - state matches target";
    return IPS_OK;
}

IPState SequenceJobState::checkWallPositionReady(CCDFrameType frametype)
{
    if (m_CameraState->hasTelescope)
    {
        // check if mount is unparked and unpark if necessary
        if (wpScopeStatus < WP_UNPARKED)
        {
            switch (m_CameraState->getScopeParkState())
            {
                case ISD::PARK_ERROR:
                    Q_EMIT newLog(i18n("Parking mount failed, aborting..."));
                    Q_EMIT abortCapture();
                    return IPS_ALERT;
                case ISD::PARK_UNPARKING:
                case ISD::PARK_PARKING:
                    return IPS_BUSY;
                case ISD::PARK_PARKED:
                    // unpark the scope
                    wpScopeStatus = WP_UNPARKING;
                    Q_EMIT newLog(i18n("Unparking mount..."));
                    Q_EMIT setScopeParked(false);
                    return IPS_BUSY;
                case ISD::PARK_UNKNOWN:
                    // retrieve the mount park state
                    Q_EMIT readCurrentMountParkState();
                    return IPS_BUSY;
                case ISD::PARK_UNPARKED:
                    wpScopeStatus = WP_UNPARKED;
                    Q_EMIT newLog(i18n("Mount unparked."));
                    // check what to do next
                    checkWallPositionReady(frametype);
                    break;
            }
            return IPS_BUSY;
        }
        else if (wpScopeStatus < WP_SLEWING)
        {
            wallCoord.HorizontalToEquatorial(KStarsData::Instance()->lst(),
                                             KStarsData::Instance()->geo()->lat());
            wpScopeStatus = WP_SLEWING;
            Q_EMIT slewTelescope(wallCoord);
            Q_EMIT newLog(i18n("Mount slewing to wall position (az =%1 alt =%2)",
                               wallCoord.az().toDMSString(), wallCoord.alt().toDMSString()));
            return IPS_BUSY;
        }
        // wait until actions completed
        else if (wpScopeStatus == WP_SLEWING || wpScopeStatus == WP_TRACKING_BUSY)
            return IPS_BUSY;
        // Check if slewing is complete
        else if (wpScopeStatus == WP_SLEW_COMPLETED)
        {
            wpScopeStatus = WP_TRACKING_BUSY;
            Q_EMIT setScopeTracking(false);
            Q_EMIT newLog(i18n("Slew to wall position complete, stop tracking."));
            return IPS_BUSY;
        }
        else if (wpScopeStatus == WP_TRACKING_OFF)
            Q_EMIT newLog(i18n("Slew to wall position complete, tracking stopped."));

        // wall position reached, check if we have a light box to turn on for flats and off otherwise
        bool captureFlats = (frametype == FRAME_FLAT);
        ISD::LightBox::LightStatus targetLightState = (captureFlats ? ISD::LightBox::LIGHT_ON :
            ISD::LightBox::LIGHT_OFF);

        if (m_CameraState->hasLightBox == true)
        {
            if (m_CameraState->getLightBoxLightState() != targetLightState)
            {
                m_CameraState->setLightBoxLightState(ISD::LightBox::LIGHT_BUSY);
                Q_EMIT setLightBoxLight(captureFlats);
                Q_EMIT newLog(captureFlats ? i18n("Turn light box light on...") : i18n("Turn light box light off..."));
                return IPS_BUSY;
            }
        }
    }
    // everything ready
    return IPS_OK;
}

IPState SequenceJobState::checkPreMountParkReady()
{
    if (m_CameraState->hasTelescope)
    {
        switch (m_CameraState->getScopeParkState())
        {
            case ISD::PARK_PARKED:
                break;
            case ISD::PARK_ERROR:
                Q_EMIT newLog(i18n("Parking mount failed, aborting..."));
                Q_EMIT abortCapture();
                return IPS_ALERT;
            case ISD::PARK_PARKING:
                return IPS_BUSY;
            case ISD::PARK_UNPARKED:
            case ISD::PARK_UNPARKING:
                // park the scope
                Q_EMIT setScopeParked(true);
                Q_EMIT newLog(i18n("Parking mount prior to calibration frames capture..."));
                return IPS_BUSY;
            case ISD::PARK_UNKNOWN:
                // retrieve the mount park state
                Q_EMIT readCurrentMountParkState();
                return IPS_BUSY;
        }
    }
    // everything ready
    return IPS_OK;

}

IPState SequenceJobState::checkPreDomeParkReady()
{
    if (m_CameraState->hasDome)
    {
        if (m_CameraState->getDomeState() == ISD::Dome::DOME_ERROR)
        {
            Q_EMIT newLog(i18n("Parking dome failed, aborting..."));
            Q_EMIT abortCapture();
            return IPS_ALERT;
        }
        else if (m_CameraState->getDomeState() == ISD::Dome::DOME_PARKING)
            return IPS_BUSY;
        else if (m_CameraState->getDomeState() != ISD::Dome::DOME_PARKED)
        {
            m_CameraState->setDomeState(ISD::Dome::DOME_PARKING);
            Q_EMIT setDomeParked(true);
            Q_EMIT newLog(i18n("Parking dome prior to calibration frames capture..."));
            return IPS_BUSY;
        }
    }
    // everything ready
    return IPS_OK;
}

IPState SequenceJobState::checkFlatSyncFocus()
{
    // check already running?
    if (flatSyncStatus == FS_BUSY)
    {
        m_FlatSyncCheck.start();
        return IPS_BUSY;
    }

    if (m_frameType == FRAME_FLAT && Options::flatSyncFocus() && flatSyncStatus != FS_COMPLETED)
    {
        flatSyncStatus = FS_BUSY;
        Q_EMIT flatSyncFocus(targetFilterID);
        return IPS_BUSY;
    }
    // everything ready
    return IPS_OK;
}

IPState SequenceJobState::checkHasShutter()
{
    if (m_CameraState->shutterStatus == SHUTTER_BUSY)
        return IPS_BUSY;
    if (m_CameraState->shutterStatus != SHUTTER_UNKNOWN)
        return IPS_OK;
    // query the status
    m_CameraState->shutterStatus = SHUTTER_BUSY;
    Q_EMIT queryHasShutter();
    return IPS_BUSY;
}

IPState SequenceJobState::checkLightFrameScopeCoverOpen()
{
    // Account for light box only (no dust cap)
    if (m_CameraState->hasLightBox && m_CameraState->getLightBoxLightState() != ISD::LightBox::LIGHT_OFF)
    {
        if (m_CameraState->getLightBoxLightState() != ISD::LightBox::LIGHT_BUSY)
        {
            m_CameraState->setLightBoxLightState(ISD::LightBox::LIGHT_BUSY);
            Q_EMIT setLightBoxLight(false);
            Q_EMIT newLog(i18n("Turn light box light off..."));
        }
        return IPS_BUSY;
    }

    // If we have a dust cap, then we must unpark
    if (m_CameraState->hasDustCap)
    {
        if (m_CameraState->getDustCapState() != ISD::DustCap::CAP_IDLE)
        {
            if (m_CameraState->getDustCapState() != ISD::DustCap::CAP_UNPARKING)
            {
                m_CameraState->setDustCapState(ISD::DustCap::CAP_UNPARKING);
                Q_EMIT parkDustCap(false);
                Q_EMIT newLog(i18n("Unparking dust cap..."));
            }
            return IPS_BUSY;
        }

        return IPS_OK;
    }

    // If telescopes were MANUALLY covered before
    // we need to manually uncover them.
    if (m_CameraState->m_ManualCoverState != CameraState::MANAUL_COVER_OPEN)
    {
        // If we already asked for confirmation and waiting for it
        // let us see if the confirmation is fulfilled
        // otherwise we return.
        if (coverQueryState == CAL_CHECK_CONFIRMATION)
            return IPS_BUSY;

        Q_EMIT askManualScopeOpen(m_CameraState->m_ManualCoverState == CameraState::MANUAL_COVER_CLOSED_LIGHT);

        return IPS_BUSY;
    }

    // scope cover open (or no scope cover)
    return IPS_OK;
}

bool SequenceJobState::isInitialized(CaptureWorkflowActionType action)
{
    return m_CameraState.data()->isInitialized[action];
}

void SequenceJobState::setInitialized(CaptureWorkflowActionType action, bool init)
{
    m_CameraState.data()->isInitialized[action] = init;
}

void SequenceJobState::setCurrentFilterID(int value)
{
    // Always keep currentFilterID in sync with hardware reality.
    // Other modules (e.g. Align) may change the filter outside of Capture's
    // preparation cycle (notably during post-meridian-flip plate solving).
    // If we skip this update, initCapture()'s prepareTargetFilter() will see a
    // stale value and fail to restore the correct capture filter.
    m_CameraState->currentFilterID = value;

    // Skip action-triggering logic if preparation is already completed —
    // we only need the state tracking above, not the filter-change commands.
    if (preparationCompleted())
    {
        qCDebug(KSTARS_EKOS_CAPTURE) << "Current Filter ID" << value <<
                                        "updated (preparation already complete, no action triggered).";
        return;
    }

    // Signal the FilterManager if we need a new targetFilterID or a new autofocus on that filter
    if (isInitialized(CAPTURE_ACTION_FILTER) == false &&
            (value != targetFilterID || m_filterPolicy & FilterManager::AUTOFOCUS_POLICY))
    {
        // mark filter preparation action
        prepareActions[CAPTURE_ACTION_FILTER] = false;

        // Pre-register AUTOFOCUS as not-ready whenever AUTOFOCUS_POLICY is set in the filter policy.
        // FilterManager will always call setFilterStatus(FILTER_AUTOFOCUS) when AUTOFOCUS_POLICY is
        // present — regardless of whether the filter physically needs to move or is already at target.
        // Without this pre-registration, checkAllActionsReady() can fire the moment the filter
        // position is confirmed (FILTER=true, AUTOFOCUS not yet in map → areActionsReady() = true),
        // causing prepareComplete()/initCaptureComplete() to fire prematurely before autofocus runs.
        // This guard is intentionally NOT conditioned on (value != targetFilterID): the race exists
        // equally when the filter is already at target, because FilterManager still queues
        // FILTER_AUTOFOCUS asynchronously based solely on the policy flags.
        // Safety: if AUTOFOCUS_POLICY is absent (e.g. stripped because autoFocusReady==false),
        // FilterManager emits FILTER_IDLE without ever calling setFilterStatus(FILTER_AUTOFOCUS),
        // so AUTOFOCUS never enters the map and areActionsReady() will naturally return true. ✓
        if (m_filterPolicy & FilterManager::AUTOFOCUS_POLICY)
        {
            qCDebug(KSTARS_EKOS_CAPTURE) << "Pre-registering AUTOFOCUS action as not ready "
                                            "(AUTOFOCUS_POLICY is set).";
            prepareActions[CAPTURE_ACTION_AUTOFOCUS] = false;
        }

        Q_EMIT changeFilterPosition(targetFilterID, m_filterPolicy);
        Q_EMIT prepareState(CAPTURE_CHANGING_FILTER);
    }
    setInitialized(CAPTURE_ACTION_FILTER, true);

    if (value == targetFilterID)
    {
        prepareActions[CAPTURE_ACTION_FILTER] = true;
        qCDebug(KSTARS_EKOS_CAPTURE) << "Filter reached target" << targetFilterID
                                     << "AUTOFOCUS pending:" << (prepareActions.contains(CAPTURE_ACTION_AUTOFOCUS)
                                             && !prepareActions[CAPTURE_ACTION_AUTOFOCUS]);
    }
    else if (value < 0)
    {
        m_PreparationState = PREP_NONE;
        Q_EMIT prepareComplete(false);
        return;
    }

    checkAllActionsReady();
}

void SequenceJobState::setCurrentCCDTemperature(double currentTemperature)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    // skip if next value should be ignored
    if (ignoreNextValue[CAPTURE_ACTION_TEMPERATURE])
    {
        ignoreNextValue[CAPTURE_ACTION_TEMPERATURE] = false;
        return;
    }

    if (isInitialized(CAPTURE_ACTION_TEMPERATURE))
    {
        if (m_enforceTemperature == false
                || std::abs(targetTemperature - currentTemperature) <= Options::maxTemperatureDiff())
            prepareActions[CAPTURE_ACTION_TEMPERATURE] = true;

        checkAllActionsReady();
    }
    else
    {
        setInitialized(CAPTURE_ACTION_TEMPERATURE, true);
        if (m_enforceTemperature == false
                || std::abs(targetTemperature - currentTemperature) <= Options::maxTemperatureDiff())
        {
            prepareActions[CAPTURE_ACTION_TEMPERATURE] = true;
            checkAllActionsReady();
        }
        else
        {
            prepareTemperatureCheck(m_enforceTemperature);
        }
    }
}

void SequenceJobState::setCurrentRotatorPositionAngle(double rotatorAngle, IPState state)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    double currentPositionAngle = RotatorUtils::Instance()->calcCameraAngle(rotatorAngle, false);

    if (isInitialized(CAPTURE_ACTION_ROTATOR))
    {
        // TODO introduce settle time
        // TODO make sure rotator has fully stopped -> see 'align.cpp' captureAndSolve()
        if (std::abs(currentPositionAngle - targetPositionAngle) * 60 <= Options::astrometryRotatorThreshold()
                && state != IPS_BUSY)
            prepareActions[CAPTURE_ACTION_ROTATOR] = true;

        checkAllActionsReady();
    }
    else
    {
        setInitialized(CAPTURE_ACTION_ROTATOR, true);
        if (std::abs(currentPositionAngle - targetPositionAngle) * 60 <= Options::astrometryRotatorThreshold()
                && state != IPS_BUSY)
        {
            prepareActions[CAPTURE_ACTION_ROTATOR] = true;
            checkAllActionsReady();
        }
        else
        {
            prepareRotatorCheck();
        }
    }
}

void SequenceJobState::setFocusStatus(FocusState state)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
    {
        qCDebug(KSTARS_EKOS_CAPTURE) << "Focus status" << state << "received but preparation already complete - ignoring.";
        return;
    }

    switch (state)
    {
        case FOCUS_COMPLETE:
            // Autofocus completed successfully — clear the trigger flag and release AUTOFOCUS.
            m_filterAutofocusTriggered = false;
            if (prepareActions.contains(CAPTURE_ACTION_AUTOFOCUS) &&
                    prepareActions[CAPTURE_ACTION_AUTOFOCUS] == false)
            {
                prepareActions[CAPTURE_ACTION_AUTOFOCUS] = true;
                checkAllActionsReady();
            }
            break;
        case FOCUS_ABORTED:
        case FOCUS_FAILED:
            // Autofocus failed — clear the trigger flag and finish preparation with failure.
            m_filterAutofocusTriggered = false;
            Q_EMIT prepareComplete(false);
            break;
        default:
            // in all other cases do nothing
            break;
    }
}

void SequenceJobState::updateManualScopeCover(bool closed, bool success, bool light)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    // covering confirmed
    if (success == true)
    {
        if (closed)
            m_CameraState->m_ManualCoverState = light ? CameraState::MANUAL_COVER_CLOSED_LIGHT :
                                                CameraState::MANUAL_COVER_CLOSED_DARK;
        else
            m_CameraState->m_ManualCoverState = CameraState::MANAUL_COVER_OPEN;
        coverQueryState = CAL_CHECK_TASK;
        // re-run checks
        checkAllActionsReady();
    }
    // cancelled
    else
    {
        // Do NOT reset shutterStatus here: the shutter state was already correctly
        // determined (camera is in shutterlessCCDs or shutterfulCCDs). Resetting it
        // to SHUTTER_UNKNOWN would force a redundant re-query on the next job run,
        // creating an unnecessary re-entrant call chain.
        coverQueryState = CAL_CHECK_TASK;
        // abort, no further checks
        Q_EMIT abortCapture();
    }
}

void SequenceJobState::lightBoxLight(bool on)
{
    // ALWAYS update the state to reflect what Ekos commanded
    m_CameraState->setLightBoxLightState(on ? ISD::LightBox::LIGHT_ON : ISD::LightBox::LIGHT_OFF);

    // Only re-run checks if preparation is not completed
    if (!preparationCompleted())
        checkAllActionsReady();
}

void SequenceJobState::lightBoxStateChanged(ISD::LightBox::LightStatus status)
{
    // Only log if the state has actually changed
    if (m_CameraState->getLightBoxLightState() != status)
    {
        switch (status)
        {
            case ISD::LightBox::LIGHT_ON:
                Q_EMIT newLog(i18n("Light box light turned on."));
                break;
            case ISD::LightBox::LIGHT_OFF:
                Q_EMIT newLog(i18n("Light box light turned off."));
                break;
            case ISD::LightBox::LIGHT_BUSY:
                Q_EMIT newLog(i18n("Light box busy."));
                break;
            case ISD::LightBox::LIGHT_ERROR:
                Q_EMIT newLog(i18n("Light box error."));
                break;
            default:
                break;
        }
    }

    // ALWAYS update the state to reflect actual hardware state
    m_CameraState->setLightBoxLightState(status);

    // Only re-run checks if preparation is not completed
    if (!preparationCompleted())
        checkAllActionsReady();
}

void SequenceJobState::dustCapStateChanged(ISD::DustCap::Status status)
{
    // ALWAYS update the state to reflect actual hardware state
    // Only log if the state has actually changed
    if (m_CameraState->getDustCapState() != status)
    {
        switch (status)
        {
            case ISD::DustCap::CAP_ERROR:
                Q_EMIT newLog(i18n("Dust cap error occurred."));
                // Only abort if we're in active preparation
                if (!preparationCompleted())
                    Q_EMIT abortCapture();
                break;
            case ISD::DustCap::CAP_IDLE:
                Q_EMIT newLog(i18n("Dust cap is idle."));
                break;
            case ISD::DustCap::CAP_PARKED:
                Q_EMIT newLog(i18n("Dust cap is parked."));
                break;
            case ISD::DustCap::CAP_PARKING:
                Q_EMIT newLog(i18n("Dust cap is parking..."));
                break;
            case ISD::DustCap::CAP_UNPARKING:
                Q_EMIT newLog(i18n("Dust cap is unparking..."));
                break;
            default:
                Q_EMIT newLog(i18n("Dust cap status unknown."));
                break;
        }
    }

    m_CameraState->setDustCapState(status);

    // Only re-run checks if preparation is not completed
    if (!preparationCompleted())
        checkAllActionsReady();
}

void SequenceJobState::scopeStatusChanged(ISD::Mount::Status status)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    // handle wall position
    switch (status)
    {
        case ISD::Mount::MOUNT_TRACKING:
            if (wpScopeStatus == WP_SLEWING)
                wpScopeStatus = WP_SLEW_COMPLETED;
            break;
        case ISD::Mount::MOUNT_IDLE:
            if (wpScopeStatus == WP_SLEWING || wpScopeStatus == WP_TRACKING_BUSY)
                wpScopeStatus = WP_TRACKING_OFF;
            break;
        case ISD::Mount::MOUNT_PARKING:
            // Ensure the parking state to avoid double park calls
            m_CameraState->setScopeParkState(ISD::PARK_PARKING);
            break;
        default:
            // do nothing
            break;
    }
    // re-run checks
    checkAllActionsReady();
}

void SequenceJobState::scopeParkStatusChanged(ISD::ParkStatus)
{
    // re-run checks
    checkAllActionsReady();
}

void SequenceJobState::domeStatusChanged(ISD::Dome::Status)
{
    // re-run checks
    checkAllActionsReady();
}

void SequenceJobState::flatSyncFocusChanged(bool completed)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    flatSyncStatus = (completed ? FS_COMPLETED : FS_BUSY);
    // re-run checks
    checkAllActionsReady();
}

void SequenceJobState::hasShutter(bool present)
{
    // ignore events if preparation is already completed
    if (preparationCompleted())
        return;

    if (present == true)
        m_CameraState->shutterStatus = SHUTTER_YES;
    else
        m_CameraState->shutterStatus = SHUTTER_NO;

    // re-run checks
    checkAllActionsReady();
}

SequenceJobState::PreparationState SequenceJobState::getPreparationState() const
{
    return m_PreparationState;
}

void SequenceJobState::setFilterStatus(FilterState filterState)
{
    qCDebug(KSTARS_EKOS_CAPTURE) << "SequenceJobState::setFilterStatus()" << filterState
                                 << "prepState:" << m_PreparationState
                                 << "filterAutofocusTriggered:" << m_filterAutofocusTriggered
                                 << "actions:" << prepareActions;

    // ignore events if preparation is already completed
    if (preparationCompleted())
    {
        qCDebug(KSTARS_EKOS_CAPTURE) << "Filter status" << filterState << "received but preparation already complete - ignoring.";
        return;
    }

    switch (filterState)
    {
        case FILTER_AUTOFOCUS:
            // FilterManager has triggered autofocus — mark it so that subsequent FILTER_IDLE
            // signals (from e.g. the lock-filter change completing) are not mistaken for
            // "no autofocus needed" and do not prematurely release the AUTOFOCUS lock.
            m_filterAutofocusTriggered = true;
            qCDebug(KSTARS_EKOS_CAPTURE) << "Filter status FILTER_AUTOFOCUS received. "
                                            "Waiting for FOCUS_COMPLETE to clear AUTOFOCUS.";
            prepareActions[CAPTURE_ACTION_AUTOFOCUS] = false;
            Q_EMIT prepareState(CAPTURE_FOCUSING);
            break;

        case FILTER_OFFSET:
            // FilterManager is applying a focus offset — keep waiting.
            qCDebug(KSTARS_EKOS_CAPTURE) << "Filter status FILTER_OFFSET received. Waiting for focus to complete.";
            prepareActions[CAPTURE_ACTION_AUTOFOCUS] = false;
            Q_EMIT prepareState(CAPTURE_FOCUSING);
            break;

        case FILTER_IDLE:
            // FilterManager has finished all filter operations.
            // Only release the AUTOFOCUS lock if FilterManager did NOT trigger autofocus
            // (m_filterAutofocusTriggered == false). When autofocus IS running, FILTER_IDLE
            // is merely the intermediate lock-filter change completing; in that case the lock
            // must stay until FOCUS_COMPLETE arrives via setFocusStatus().
            if (!m_filterAutofocusTriggered &&
                    prepareActions.contains(CAPTURE_ACTION_AUTOFOCUS) &&
                    !prepareActions[CAPTURE_ACTION_AUTOFOCUS])
            {
                qCDebug(KSTARS_EKOS_CAPTURE) << "FilterManager completed without autofocus "
                                                "(FILTER_IDLE received, no AF triggered) - clearing AUTOFOCUS pending state.";
                prepareActions[CAPTURE_ACTION_AUTOFOCUS] = true;
                checkAllActionsReady();
            }
            else if (m_filterAutofocusTriggered)
            {
                qCDebug(KSTARS_EKOS_CAPTURE) << "FILTER_IDLE received but autofocus is still running "
                                                "(m_filterAutofocusTriggered=true) - keeping AUTOFOCUS pending.";
            }
            break;

        // nothing to do in all other cases
        case FILTER_CHANGE:
            break;
    }

}
} // namespace
