<?php

/**
 * Runner class manages package building operations.
 *
 * This class is responsible for:
 * - Handling package building states and transitions
 * - Monitoring and cancelling stuck or long-running processes
 * - Managing worker processes for background operations
 * - Enforcing system requirements and build constraints
 * - Coordinating storage processing after package creation
 *
 * The class implements a robust state machine to ensure reliable package creation
 * and proper handling of various edge cases like timeouts and resource constraints.
 */

namespace Duplicator\Package;

use Duplicator\Models\GlobalEntity;
use Duplicator\Utils\Logging\DupLog;
use Duplicator\Package\DupPackage;
use Duplicator\Core\Constants;
use Duplicator\Core\Controllers\ControllersManager;
use Duplicator\Libs\Snap\SnapUtil;
use Duplicator\Models\DynamicGlobalEntity;
use Duplicator\Models\SystemGlobalEntity;
use Duplicator\Package\Archive\PackageArchive;
use Duplicator\Utils\LockUtil;
use Exception;

final class Runner
{
    const DEFAULT_MAX_BUILD_TIME_IN_MIN = 270;
    const PACKAGE_STUCK_TIME_IN_SEC     = 375; // 75 x 5;

    /** @var bool */
    public static $delayed_exit_and_kickoff = false;

    /**
     * Initialize Backup Runner
     *
     * @return void
     */
    public static function init(): void
    {
        if (self::shouldSkipInit()) {
            return;
        }

        // Open logs for the current package
        $package = DupPackage::getNextActive();
        if ($package instanceof AbstractPackage) {
            DupLog::open($package->getNameHash());
        }

        DupLog::trace('Running Backup runner init');

        if (!LockUtil::lockProcess()) {
            return;
        }
        DupLog::trace("Lock acquired. Executing Backup runner init core code");

        self::updatePackageCheckTimestamp();
        self::processPendingCancellations();

        // Process pre-worker hooks and determine whether to kick off a worker.
        $kickOffWorker = self::processPreWorkerHooks();

        LockUtil::unlockProcess();

        self::kickOffWorkerIfNeeded($kickOffWorker);

        // Close logs
        if ($package instanceof AbstractPackage) {
            DupLog::close();
        }

        if (self::$delayed_exit_and_kickoff) {
            self::$delayed_exit_and_kickoff = false;
            exit();
        }
    }

    /**
     * Determines if the initialization should be skipped.
     *
     * @return bool
     */
    private static function shouldSkipInit(): bool
    {
        $systemGlobal = SystemGlobalEntity::getInstance();

        // Skip processing if the package check was performed recently and clientside kickoff is disabled.
        return ((time() - $systemGlobal->package_check_ts < Constants::PACKAGE_CHECK_TIME_IN_SEC) &&
            !ClientSideKick::isClientSideKickoffEnabled());
    }

    /**
     * Updates the package check timestamp.
     *
     * @return void
     */
    private static function updatePackageCheckTimestamp(): void
    {
        $systemGlobal                   = SystemGlobalEntity::getInstance();
        $systemGlobal->package_check_ts = time();
        $systemGlobal->save();
    }

    /**
     * Processes any pending cancellations.
     *
     * @return void
     */
    private static function processPendingCancellations(): void
    {
        $pendingCancellations = DupPackage::getPendingCancellations();

        // Cancel any long-running processes.
        self::cancelLongRunning($pendingCancellations);

        if (empty($pendingCancellations)) {
            return;
        }

        foreach ($pendingCancellations as $packageId) {
            self::processPackageCancellation($packageId);
        }

        DupPackage::clearPendingCancellations();
    }

    /**
     * Processes cancellation for a specific package.
     *
     * @param int $packageId Package ID to be cancelled
     *
     * @return void
     */
    private static function processPackageCancellation(int $packageId): void
    {
        DupLog::trace("Processing cancellation for package: {$packageId}");
        $package = DupPackage::getById($packageId);
        if (!$package) {
            return;
        }

        if ($package->getStatus() != AbstractPackage::STATUS_STORAGE_PROCESSING) {
            $package->setStatus(AbstractPackage::STATUS_BUILD_CANCELLED);
            return;
        }

        $isDownloadInProgress = $package->isDownloadInProgress();
        $lastUploadInfo       = end($package->upload_infos);
        $lastDownload         = $lastUploadInfo->isDownloadFromRemote();
        $isUploadCancel       = !$isDownloadInProgress && !$lastDownload;

        $package->cancelAllUploads();

        if ($isUploadCancel && $package->hasAnyCompletedUpload()) {
            // Skip processStorages() so duplicator_package_transfer_completed doesn't fire on a cancelled transfer.
            $package->setStatus(AbstractPackage::STATUS_COMPLETE);
            return;
        }

        $package->processStorages();

        if ($isUploadCancel) {
            $package->setStatus(AbstractPackage::STATUS_STORAGE_CANCELLED);
            return;
        }

        DupPackage::deleteDefaultLocalFiles($package->getNameHash(), true);
        $package->setStatus(AbstractPackage::STATUS_COMPLETE);
    }

    /**
     * Processes pre-worker hooks if the current action is not the process worker.
     *
     * @return bool True if a package is running and a worker should be kicked off.
     */
    private static function processPreWorkerHooks(): bool
    {
        $action = ControllersManager::getInstance()->getAction();
        if ($action === false || $action !== 'duplicator_process_worker') {
            do_action('duplicator_runner_pre_process');
            return DupPackage::isPackageRunning();
        }
        return false;
    }

    /**
     * Kicks off the worker process if necessary.
     *
     * @param bool $kickOffWorker Indicates if a worker should be kicked off.
     *
     * @return void
     */
    private static function kickOffWorkerIfNeeded(bool $kickOffWorker): void
    {
        if ($kickOffWorker || self::$delayed_exit_and_kickoff) {
            self::kickOffWorker();
        } elseif (is_admin() && ControllersManager::getInstance()->isDuplicatorPage()) {
            DupLog::trace("************kicking off slug worker");
            self::kickOffWorker(true);
        }
    }

    /**
     * Checks active Backups for being stuck or running too long and adds them for canceling
     *
     * @param int[] $pending_cancellations List of Backup ids to be cancelled
     *
     * @return void
     */
    private static function cancelLongRunning(array &$pending_cancellations): void
    {
        if (!DupPackage::isPackageRunning()) {
            return;
        }

        $active_package = DupPackage::getNextActive();
        if ($active_package === null) {
            DupLog::trace("Active Backup returned null");
            return;
        }

        self::cancelMaxBuildTimeReached($pending_cancellations, $active_package);
        self::cancelMaxTransferTimeReached($pending_cancellations, $active_package);
    }

    /**
     * Checks if the active Backup has been building for too long and adds it for cancelling
     *
     * @param int[]           $pending_cancellations List of Backup ids to be cancelled
     * @param AbstractPackage $active_package        The active Backup
     *
     * @return void
     */
    private static function cancelMaxBuildTimeReached(array &$pending_cancellations, AbstractPackage $active_package): void
    {
        if ($active_package->getStatus() == AbstractPackage::STATUS_STORAGE_PROCESSING) {
            return;
        }

        $global                      = GlobalEntity::getInstance();
        $dGlobal                     = DynamicGlobalEntity::getInstance();
        $system_global               = SystemGlobalEntity::getInstance();
        $buildStarted                = $active_package->timer_start > 0;
        $active_package->timer_start = $buildStarted ? $active_package->timer_start : microtime(true);
        $elapsed_sec                 = $buildStarted ? microtime(true) - $active_package->timer_start : 0;
        $elapsed_minutes             = $elapsed_sec / 60;
        $addedForCancelling          = false;

        // If build has started & we are not uploading yet, we will consider the max build time.
        if ($global->max_package_runtime_in_min > 0 && $elapsed_minutes > $global->max_package_runtime_in_min) {
            if ($active_package->build_progress->current_build_mode != PackageArchive::BUILD_MODE_DUP_ARCHIVE) {
                $system_global->addQuickFix(
                    __('Backup was cancelled because it exceeded Max Build Time.', 'duplicator-pro'),
                    sprintf(
                        __(
                            'Click button to switch to the DupArchive engine. Please see this %1$sFAQ%2$s for other possible solutions.',
                            'duplicator-pro'
                        ),
                        '<a href="' . DUPLICATOR_DUPLICATOR_DOCS_URL . 'how-to-resolve-schedule-build-failures" target="_blank">',
                        '</a>'
                    ),
                    [
                        'global' => [
                            'archive_build_mode' => PackageArchive::BUILD_MODE_DUP_ARCHIVE,
                        ],
                    ]
                );
            } elseif ($global->max_package_runtime_in_min < self::DEFAULT_MAX_BUILD_TIME_IN_MIN) {
                $system_global->addQuickFix(
                    __('Backup was cancelled because it exceeded Max Build Time.', 'duplicator-pro'),
                    sprintf(
                        __(
                            'Click button to increase Max Build Time. Please see this %1$sFAQ%2$s for other possible solutions.',
                            'duplicator-pro'
                        ),
                        '<a href="' . DUPLICATOR_DUPLICATOR_DOCS_URL . 'how-to-resolve-schedule-build-failures" target="_blank">',
                        '</a>'
                    ),
                    [
                        'global' => [
                            'max_package_runtime_in_min' => self::DEFAULT_MAX_BUILD_TIME_IN_MIN,
                        ],
                    ]
                );
            }

            DupLog::infoTrace("Package {$active_package->getId()} has been going for $elapsed_minutes minutes so cancelling. ($elapsed_sec)");
            array_push($pending_cancellations, $active_package->getId());
            $addedForCancelling = true;
        }

        $currentStatus    = $active_package->getStatus();
        $isStuckCandidate = in_array($currentStatus, [
            AbstractPackage::STATUS_AFTER_SCAN,
            AbstractPackage::STATUS_PRE_PROCESS,
        ], true);
        $stateDuration    = $active_package->getStateDuration($currentStatus);
        $isStuck          = $isStuckCandidate && $stateDuration > self::PACKAGE_STUCK_TIME_IN_SEC;
        if (!$active_package->isClientSideKickoff() && $isStuck) {
            DupLog::trace("*** STUCK");
            $showDefault = true;
            if (isset($_SERVER['AUTH_TYPE']) && $_SERVER['AUTH_TYPE'] == 'Basic' && !$dGlobal->getValBool('basic_auth_enabled')) {
                $system_global->addQuickFix(
                    __('Set authentication username and password', 'duplicator-pro'),
                    __('Automatically set basic auth username and password', 'duplicator-pro'),
                    [
                        'special' => ['set_basic_auth' => 1],
                    ]
                );
                $showDefault = false;
            }

            if ($showDefault) {
                $system_global->addTextFix(
                    __('Communication to AJAX is blocked.', 'duplicator-pro'),
                    sprintf(
                        _x(
                            'See FAQ: %1$sWhy is the Backup build stuck at 5%%?%2$s',
                            '%1$s and %2$s represent open and closing a tags',
                            'duplicator-pro'
                        ),
                        '<a href="' . DUPLICATOR_DUPLICATOR_DOCS_URL . 'how-to-resolve-builds-getting-stuck-at-a-certain-point/" target="_blank">',
                        '</a>'
                    )
                );
            }

            DupLog::infoTrace("Package {$active_package->getId()} has been stuck for $stateDuration seconds so cancelling.");
            array_push($pending_cancellations, $active_package->getId());
            $addedForCancelling = true;
        }

        if ($addedForCancelling) {
            $active_package->buildFail(
                'Backup was cancelled because it exceeded Max Build Time.',
                false
            );
        } else {
            $active_package->save();
        }
    }

    /**
     * Checks if the active Backup has been transferring for too long and adds it for cancelling
     *
     * @param int[]           $pending_cancellations List of Backup ids to be cancelled
     * @param AbstractPackage $active_package        The active Backup
     *
     * @return void
     */
    private static function cancelMaxTransferTimeReached(array &$pending_cancellations, AbstractPackage $active_package): void
    {
        if ($active_package->getStatus() != AbstractPackage::STATUS_STORAGE_PROCESSING) {
            return;
        }

        $latestInfos = $active_package->getLatestUploadInfos();
        if (empty($latestInfos[$active_package->active_storage_id])) {
            return;
        }
        $uploadInfo = $latestInfos[$active_package->active_storage_id];
        if ($uploadInfo->hasCompleted()) {
            return;
        }

        // We consider the Backup is in "uploading state" if it's in STORAGE_PROCESSING status and
        // has more than one upload_infos (i.e. the default storage processing done)
        $global               = GlobalEntity::getInstance();
        $system_global        = SystemGlobalEntity::getInstance();
        $uploadStartedAt      = $uploadInfo->started_timestamp;
        $uploadStartedAt      = $uploadStartedAt > 0 ? $uploadStartedAt : microtime(true);
        $uploadElapsedSec     = microtime(true) - $uploadStartedAt;
        $uploadElapsedMinutes = $uploadElapsedSec / 60;

        // If we are uploading the Backup, we consider the max Backup transfer time.
        if ($global->max_package_transfer_time_in_min < $uploadElapsedMinutes) {
            $system_global->addQuickFix(
                __('Backup transfer was cancelled because it exceeded Max Transfer Time.', 'duplicator-pro'),
                sprintf(
                    __(
                        'Click button to increase Max Transfer Time. Please see this %1$sFAQ%2$s for other possible solutions.',
                        'duplicator-pro'
                    ),
                    '<a href="' . DUPLICATOR_DUPLICATOR_DOCS_URL . 'how-to-resolve-schedule-build-failures" target="_blank">',
                    '</a>'
                ),
                [
                    'global' => [
                        'max_package_transfer_time_in_min' => self::DEFAULT_MAX_BUILD_TIME_IN_MIN,
                    ],
                ]
            );

            DupLog::infoTrace(
                "Package {$active_package->getId()} has been transferring for
                $uploadElapsedMinutes minutes so cancelling. ($uploadElapsedSec)"
            );
            array_push($pending_cancellations, $active_package->getId());
        }
    }

    /**
     * Kick off worker
     *
     * @param bool $run_only_if_client If true then only kick off worker if the request came from the client
     *
     * @return void
     */
    public static function kickOffWorker(bool $run_only_if_client = false): void
    {
        $useClientSide = ClientSideKick::isClientSideKickoffEnabled();

        if (!$run_only_if_client || $useClientSide) {
            $calling_function_name = SnapUtil::getCallingFunctionName();
            DupLog::trace("Kicking off worker process as requested by $calling_function_name");

            if ($useClientSide) {
                DupLog::trace('KICKOFF: Client Side (script enqueued via ClientSideKick::init)');
            } else {
                $workerRequest = ClientSideKick::buildWorkerRequest(
                    ['now' => time()],
                    ['blocking' => false]
                );
                $ajax_url      = $workerRequest['url'];
                $args          = $workerRequest['args'];

                DupLog::trace('KICKOFF: Server Side - ' . $ajax_url);
                wp_remote_get($ajax_url, $args);
            }
        }
    }

    /**
     * Process schedules by cron
     *
     * @return void
     */
    public static function process(): void
    {
        if (!defined('WP_MAX_MEMORY_LIMIT')) {
            define('WP_MAX_MEMORY_LIMIT', '512M');
        }

        if (SnapUtil::isIniValChangeable('memory_limit')) {
            @ini_set('memory_limit', WP_MAX_MEMORY_LIMIT);
        }

        @set_time_limit(7200);
        SnapUtil::ignoreUserAbort(true);

        if (SnapUtil::isIniValChangeable('pcre.backtrack_limit')) {
            @ini_set('pcre.backtrack_limit', (string) PHP_INT_MAX);
        }

        if (SnapUtil::isIniValChangeable('default_socket_timeout')) {
            @ini_set('default_socket_timeout', '7200');
            // 2 Hours
        }

        if (ClientSideKick::isClientSideKickoffEnabled()) {
            DupLog::trace("PROCESS: From client");
            session_write_close();
        } else {
            DupLog::trace("PROCESS: From server");
        }

        if (!LockUtil::lockProcess()) {
            // File locked so another cron already running so just skip
            DupLog::trace("Process locked so skipping");
            return;
        }

        // Here we know that $acquired_lock == true
        do_action('duplicator_runner_pre_process');

        // Get package again after pre-process hooks as they might have created new packages
        $package = DupPackage::getNextActive(true);

        if ($package != null) {
            self::processPackage($package);
        }

        //$kick_off_worker = (DupPackage::getNextActive() != null);
        $kick_off_worker = DupPackage::isPackageRunning();

        LockUtil::unlockProcess();

        if ($kick_off_worker) {
            self::kickOffWorker();
        }
    }

    /**
     * Process Package
     *
     * @param AbstractPackage $package Package backup
     *
     * @return void
     */
    private static function processPackage(AbstractPackage $package): void
    {
        // Open logs
        DupLog::open($package->getNameHash());

        try {
            $dup_tests = self::getRequirementsTests();

            if ($dup_tests['Success'] == true) {
                $start_time = time();
                DupLog::trace("PACKAGE {$package->getId()}:PROCESSING. STATUS: {$package->getStatus()}");
                SnapUtil::ignoreUserAbort(true);
                if ($package->getStatus() <= AbstractPackage::STATUS_SCANNING) {
                    // Scan step built into Backup build - used by schedules - NOT manual build where scan is done in web service.
                    DupLog::trace("PACKAGE {$package->getId()}:SCANNING");
                    $fileScanDone = false;
                    if ($package->getStatus() < AbstractPackage::STATUS_SCANNING) {
                        DupLog::trace("PACKAGE {$package->getId()}: SCAN FIRST CHUNK");
                        $fileScanDone = $package->Archive->scanFiles(true);
                        $package->setStatus(AbstractPackage::STATUS_SCANNING);
                    } else {
                        DupLog::trace("PACKAGE {$package->getId()}: CONTINUE SCAN");
                        $fileScanDone = $package->Archive->scanFiles();
                    }

                    if ($fileScanDone) {
                        DupLog::trace("PACKAGE {$package->getId()}: SCAN COMPLETE. NEED TO VALIDATE");
                        $package->setStatus(AbstractPackage::STATUS_SCAN_VALIDATION);
                    }

                    $scan_time = time() - $start_time;
                    DupLog::trace("SCAN CHUNK TIME=$scan_time seconds");
                } elseif ($package->getStatus() <= AbstractPackage::STATUS_SCAN_VALIDATION) {
                    //After scanner runs validate the index file
                    DupLog::trace("PACKAGE {$package->getId()}: SCAN VALIDATION");
                    if ($package->Archive->validateIndexFile()) {
                        DupLog::trace("PACKAGE {$package->getId()}: SCAN VALIDATION PASSED");
                        $package->createScanReport();
                        $package->setStatus(AbstractPackage::STATUS_AFTER_SCAN);
                    } else {
                        DupLog::infoTrace("PACKAGE {$package->getId()}:SCAN VALIDATION FAILED");
                        $package->setStatus(AbstractPackage::STATUS_ERROR);
                    }

                    // Save the package after each scan chunk
                    $package->update();

                    $scan_time = time() - $start_time;
                    DupLog::trace("SCAN VALIDATION TIME=$scan_time seconds");
                } elseif ($package->getStatus() < AbstractPackage::STATUS_COPIEDPACKAGE) {
                    DupLog::trace("PACKAGE {$package->getId()}:BUILDING");
                    $package->runBuild();
                    $end_time   = time();
                    $build_time = $end_time - $start_time;
                    DupLog::trace("BUILD TIME=$build_time seconds");
                } elseif ($package->getStatus() < AbstractPackage::STATUS_COMPLETE) {
                    DupLog::trace("PACKAGE {$package->getId()}:STORAGE PROCESSING");
                    $package->setStatus(AbstractPackage::STATUS_STORAGE_PROCESSING);
                    $package->processStorages();
                    $end_time   = time();
                    $build_time = $end_time - $start_time;
                    DupLog::trace("STORAGE CHUNK PROCESSING TIME=$build_time seconds");
                    if ($package->getStatus() == AbstractPackage::STATUS_COMPLETE) {
                        DupLog::trace("PACKAGE {$package->getId()} COMPLETE");
                    } elseif ($package->getStatus() == AbstractPackage::STATUS_ERROR) {
                        DupLog::trace("PACKAGE {$package->getId()} IN ERROR STATE");
                    }

                    $packageCompleteStatuses = [
                        AbstractPackage::STATUS_COMPLETE,
                        AbstractPackage::STATUS_ERROR,
                    ];
                    if (in_array($package->getStatus(), $packageCompleteStatuses)) {
                        $info  = "\n";
                        $info .= "********************************************************************************\n";
                        $info .= "********************************************************************************\n";
                        $info .= DUPLICATOR____NAME . " PACKAGE CREATION OR MANUAL STORAGE TRANSFER END: " . @date("Y-m-d H:i:s") . "\n";
                        $info .= "NOTICE: Do NOT post to public sites or forums \n";
                        $info .= "********************************************************************************\n";
                        $info .= "********************************************************************************\n";
                        DupLog::infoTrace($info);
                    }
                }

                SnapUtil::ignoreUserAbort(false);
            } else {
                DupLog::open($package->getNameHash());

                if ($dup_tests['RES']['INSTALL'] == 'Fail') {
                    DupLog::info('Installer files still present on site. Remove using Tools > Stored Data > "Remove Installer Files".');
                }

                DupLog::error(__('Requirements Failed', 'duplicator-pro'), print_r($dup_tests, true));
                DupLog::traceError('Requirements didn\'t pass so can\'t perform backup!');
                $package->setStatus(AbstractPackage::STATUS_REQUIREMENTS_FAILED);
            }

            // Free index manager file lock
            $package->Archive->freeIndexManager();
        } catch (Exception $e) {
            DupLog::infoTraceException($e, 'Build failed');
            $message  = "Backup creation failed.\n"
            . " EXCEPTION message: " . $e->getMessage() . "\n";
            $message .= $e->getFile() . ' LINE: ' . $e->getLine() . "\n";
            $message .= $e->getTraceAsString();
            $package->buildFail($message, false);
        }

        // Close logs
        DupLog::close();
    }

    /**
     * Gets the requirements tests
     *
     * @return array<string,mixed>
     */
    private static function getRequirementsTests(): array
    {
        $dup_tests = BuildRequirements::getRequirments();
        if ($dup_tests['Success'] != true) {
            DupLog::traceObject('requirements', $dup_tests);
        }

        return $dup_tests;
    }
}
