<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    core
 */

/**
 * Script to perform system scheduler hooks called by (usually) the real Cron.
 *
 * @param  PATH $caller File path of the cron_bridge.php script
 *
 * @ignore
 */
function cron_bridge_script(string $caller)
{
    header('X-Robots-Tag: noindex');

    // Read options
    $loop = false;
    $loop_wait_seconds = 3;
    $loop_max_seconds = 3595;
    if (is_cli()) {
        $cli_options = getopt('', ['limit_hooks:', 'verbose', 'query_mode', 'force', 'include_msn', 'loop', 'loop_wait_seconds:', 'loop_max_seconds:']);

        $_limit_hooks = isset($cli_options['limit_hooks']) ? $cli_options['limit_hooks'] : '';
        $verbose = isset($cli_options['verbose']);
        $query_mode = isset($cli_options['query_mode']);
        $force = isset($cli_options['force']);
        $include_msn = isset($cli_options['include_msn']);

        // Read looping options
        $loop = isset($cli_options['loop']);
        if (!@cms_empty_safe($cli_options['loop_wait_seconds'])) {
            $loop_wait_seconds = intval($cli_options['loop_wait_seconds']);
        }
        if (!@cms_empty_safe($cli_options['loop_max_seconds'])) {
            $loop_max_seconds = intval($cli_options['loop_max_seconds']);
        }
    } else {
        $_limit_hooks = get_param_string('limit_hooks', '');
        $verbose = (get_param_integer('verbose', 0) == 1);
        $query_mode = (get_param_integer('query_mode', 0) == 1);
        $force = (get_param_integer('force', 0) == 1);
        $include_msn = (get_param_integer('include_msn', 0) == 1);
    }
    if ($_limit_hooks == '') {
        $limit_hooks = null;
    } else {
        $limit_hooks = explode(',', $_limit_hooks);
        foreach ($limit_hooks as &$limit_hook) {
            $limit_hook = preg_replace('#\.php$#', '', $limit_hook);
        }
    }

    // In query mode, the software will just give advice on the system scheduler settings to use
    if ($query_mode) {
        header('Content-Type: text/plain; charset=' . get_charset());
        cms_ini_set('ocproducts.xss_detect', '0');
        require_code('files2');
        $php_path = find_php_path();
        // echo $php_path . ' -C -q -c ' . get_file_base() . '/.user.ini ' . $caller; // TODO: Does not work as it disables use of PHP extensions
        echo $php_path . ' -q ' . $caller;
        exit();
    }

    // Get ready
    if ($verbose) {
        cms_ini_set('ocproducts.xss_detect', '0');
        header('Content-Type: text/plain; charset=' . get_charset());
    }

    echo cron_run(
        $force,
        $verbose,
        $limit_hooks,
        $include_msn,
        $loop,
        $loop_wait_seconds,
        $loop_max_seconds
    );

    if (!headers_sent()) {
        header('Content-Type: text/plain; charset=' . get_charset());
    }
}

/**
 * Run scheduled tasks.
 *
 * @param  boolean $force Whether to forcefully run locked hooks
 * @param  boolean $verbose Whether to output additional information, mainly for debugging
 * @param  ?array $limit_hooks Only run the defined hooks (null: run all of them)
 * @param  boolean $include_msn Whether to also call the Cron bridges of custom shares
 * @param  boolean $loop Whether to loop execution
 * @param  integer $loop_wait_seconds The number of seconds to wait between loops
 * @param  integer $loop_max_seconds Terminate looping after this many seconds
 */
function cron_run(bool $force = false, bool $verbose = false, ?array $limit_hooks = null, bool $include_msn = false, bool $loop = false, int $loop_wait_seconds = 3, int $loop_max_seconds = 3595) : string
{
    require_code('failure');
    require_code('version');

    $ret = [];

    // Check global locking, but only in looping mode
    if ($loop) {
        if ((get_value_newer_than('cron_currently_running', time() - 60 * 60/*large 1 hour timeout in case an instance is stuck doing something very slow*/, true) === '1') && (!$force)) {
            if ($verbose) {
                $ret[] = loggable_date() . ' Cron is still locked';
            }
            return implode("\n", $ret);
        }
        set_value('cron_currently_running', '1', true);
    }

    // Upgrade pending?
    $version_files = float_to_raw_string(cms_version_number(), 10, true);
    $version_db = strval(cms_version_time_db());
    if ((get_value('version') != $version_files) || (get_value('cns_version') != $version_files) || (get_value('db_version', '', true) != $version_db)) {
        if ($verbose) {
            $ret[] = loggable_date() . ' An upgrade is pending, cannot run';
        }
        //return implode("\n", $ret);
    }

    // Starting logging
    $_log_file = get_custom_file_base() . '/data_custom/cron.log';
    $log_file = null;
    $log_message = loggable_date() . '  (CRON STARTING)' . "\n";
    if ($verbose) {
        echo $log_message;
    }
    if (is_file($_log_file)) {
        require_code('files');
        $log_file = cms_fopen_text_write($_log_file, true, 'ab');
        fwrite($log_file, $log_message);
        flock($log_file, LOCK_UN);
    }

    // For multi-site installs, run for each install
    if ($include_msn) {
        global $CURRENT_SHARE_USER, $SITE_INFO;
        if (($CURRENT_SHARE_USER === null) && (!empty($SITE_INFO['custom_share_domain']))) {
            require_code('files');

            foreach ($SITE_INFO as $key => $val) {
                if (substr($key, 0, 12) == 'custom_user_') {
                    $url = preg_replace('#://[\w\.]+#', '://' . substr($key, 12) . '.' . $SITE_INFO['custom_share_domain'], get_base_url()) . '/data/cron_bridge.php';
                    require_code('global3');
                    http_get_contents($url, ['timeout' => 180.0]);
                }
            }
        }
    }

    // Hook details
    require_code('zones');
    $cron_hooks = find_all_hook_obs('systems', 'cron', 'Hook_cron_');
    ksort($cron_hooks);

    // FUDGE: Background tasks should run second to first
    if (array_key_exists('tasks', $cron_hooks)) {
        $cron_hook = $cron_hooks['tasks'];
        unset($cron_hooks['tasks']);
        $cron_hooks = ['tasks' => $cron_hook] + $cron_hooks;
    }

    // FUDGE: Health checks should run first
    if (array_key_exists('health_check', $cron_hooks)) {
        $cron_hook = $cron_hooks['health_check'];
        unset($cron_hooks['health_check']);
        $cron_hooks = ['health_check' => $cron_hook] + $cron_hooks;
    }

    // FUDGE: Mail queue should run second to last
    if (array_key_exists('mail_queue', $cron_hooks)) {
        $x = $cron_hooks['mail_queue'];
        unset($cron_hooks['mail_queue']);
        $cron_hooks = $cron_hooks + ['mail_queue' => $x];
    }

    // FUDGE: Newsletter drip send should run last
    if (array_key_exists('newsletter_drip_send', $cron_hooks)) {
        $x = $cron_hooks['newsletter_drip_send'];
        unset($cron_hooks['newsletter_drip_send']);
        $cron_hooks = $cron_hooks + ['newsletter_drip_send' => $x];
    }

    $cron_hooks_info = [];

    // Load progression data
    $cron_progression = list_to_map('c_hook', $GLOBALS['SITE_DB']->query_select('cron_progression', ['*']));

    // So the block knows the system scheduler has run
    if (intval(get_value('last_cron')) < time() - 60 * 60 * 12) {
        delete_cache_entry('main_staff_checklist');
    }

    $time_elapsed = 0;
    do {
        // Logging of timings
        if ($limit_hooks === null) {
            set_value('last_cron', strval(time()));
        }
        set_value('last_cron_started', strval(time()), true);

        // Call the hooks which do the real work
        foreach ($cron_hooks as $hook => $object) {
            // Inclusion-listed?
            if (($limit_hooks !== null) && (!in_array($hook, $limit_hooks))) {
                if ($verbose) {
                    $ret[] = loggable_date() . ' Skipping ' . $hook . ' as limit_hooks was defined';
                }
                unset($cron_hooks[$hook]);
                continue;
            }

            // Find Cron-progression data
            if (isset($cron_progression[$hook])) {
                // Manually disabled?
                if ($cron_progression[$hook]['c_enabled'] == 0) {
                    if ($verbose) {
                        $ret[] = loggable_date() . ' Skipping ' . $hook . ' as it was manually disabled';
                    }
                    unset($cron_hooks[$hook]);
                    continue;
                }

                $last_run = $cron_progression[$hook]['c_last_run_time'];
            } else {
                $last_run = null;
            }

            // Not available?
            if (array_key_exists($hook, $cron_hooks_info)) {
                $info = $cron_hooks_info[$hook];
            } else {
                $info = $object->info($last_run, true); // Need to do this so that any calculations done in here can then be used by ->run()
                if ($info === null) {
                    if ($verbose) {
                        $ret[] = loggable_date() . ' Skipping ' . $hook . ' as it did not return any info (perhaps it is not meant to run)';
                    }
                    unset($cron_hooks[$hook]);
                    continue;
                }
                if (!isset($cron_progression[$hook]) && ($info['enabled_by_default'] === false)) { // New hook must be manually enabled
                    if ($verbose) {
                        $ret[] = loggable_date() . ' Skipping ' . $hook . ' as it is disabled by default and has not yet been enabled';
                    }
                    unset($cron_hooks[$hook]);
                    continue;
                }
                $cron_hooks_info[$hook] = $info;
            }

            // Is it time to run it?
            if (($limit_hooks === null) || ($limit_hooks !== [$hook])) { // Only if not directly requested
                if (($last_run !== null) && ($last_run + $info['minutes_between_runs'] * 60 > time())) {
                    if ($verbose) {
                        $ret[] = loggable_date() . ' Not yet time to run ' . $hook;
                    }

                    continue;
                }
            }

            // Run, with basic locking support
            if ((get_value_newer_than('cron_currently_running__' . $hook, time() - 60 * 60 * 24/*huge 24 hour timeout in case a particular hook is badly broken and we do not want frequent trip ups*/, true) !== '1') || ($force)) {
                // Update log to say starting
                $log_message = loggable_date() . '  STARTING ' . $hook . ' (' . $info['label'] . ')' . "\n";
                if ($verbose) {
                    $ret[] = $log_message;
                }
                if ($log_file !== null) {
                    flock($log_file, LOCK_EX);
                    fseek($log_file, 0, SEEK_END);
                    fwrite($log_file, $log_message);
                    flock($log_file, LOCK_UN);
                }

                // Lock
                set_value('cron_currently_running__' . $hook, '1', true);

                // Run, with timing and error catching
                $last_error = '';
                $time_before = time();
                if ($verbose) {
                    $object->run($last_run);
                } else {
                    set_throw_errors(true);
                    try {
                        $object->run($last_run);
                    } catch (Exception $e) {
                        $last_error = $e->getMessage();
                    }
                    set_throw_errors(false);
                }
                $time_after = time();
                $time_elapsed += ($time_after - $time_before);

                // Reset time limit (hook may have overwritten / we provide same max time for each hook)
                cms_extend_time_limit(TIME_LIMIT_EXTEND__CRAWL);

                // Update cron_progression table
                if (isset($cron_progression[$hook])) {
                    $cron_progression[$hook]['c_last_run_time'] = time();
                    $cron_progression[$hook]['c_last_execution_secs'] = $time_after - $time_before;
                    $cron_progression[$hook]['c_last_error'] = $last_error;

                    $GLOBALS['SITE_DB']->query_update(
                        'cron_progression',
                        [
                            'c_hook' => $hook,
                            'c_last_run_time' => time(),
                            'c_last_execution_secs' => $time_after - $time_before,
                            'c_last_error' => $last_error,
                        ],
                        [
                            'c_hook' => $hook,
                        ],
                        '',
                        1
                    );
                } else {
                    $cron_progression[$hook] = [
                        'c_hook' => $hook,
                        'c_last_run_time' => time(),
                        'c_last_execution_secs' => $time_after - $time_before,
                        'c_last_error' => $last_error,
                        'c_enabled' => 1,
                    ];
                    $GLOBALS['SITE_DB']->query_insert_or_replace('cron_progression', $cron_progression[$hook], ['c_hook' => $hook]);
                }

                // Unlock
                delete_value('cron_currently_running__' . $hook, true);

                // Update log to say finished
                $log_message = loggable_date() . '  FINISHED ' . $hook . ' (' . $info['label'] . ')' . "\n";
                if ($verbose) {
                    $ret[] = $log_message;
                }
                if ($log_file !== null) {
                    flock($log_file, LOCK_EX);
                    fseek($log_file, 0, SEEK_END);
                    fwrite($log_file, $log_message);
                    flock($log_file, LOCK_UN);
                }
            } else {
                // Update log to say locked
                $log_message = loggable_date() . '  WAS LOCKED ' . $hook . ' (' . $info['label'] . ')' . "\n";
                if ($verbose) {
                    $ret[] = $log_message;
                }
                if ($log_file !== null) {
                    flock($log_file, LOCK_EX);
                    fseek($log_file, 0, SEEK_END);
                    fwrite($log_file, $log_message);
                    flock($log_file, LOCK_UN);
                }
            }

            // Safety limit
            if ($time_elapsed >= 20) {
                $log_message = loggable_date() . ' ENDING EARLY; we reached the execution time limit of 20 seconds.' . "\n";
                if ($verbose) {
                    $ret[] = $log_message;
                }
                if ($log_file !== null) {
                    flock($log_file, LOCK_EX);
                    fseek($log_file, 0, SEEK_END);
                    fwrite($log_file, $log_message);
                    flock($log_file, LOCK_UN);
                }
                break;
            }
        }

        // Logging of timings
        set_value('last_cron_finished', strval(time()), true);

        if ($loop) {
            $loop_ending = (time() - $_SERVER['REQUEST_TIME'] >= $loop_max_seconds);

            if ($loop_ending) {
                $log_message = loggable_date() . '  REACHED END OF LOOPING' . "\n";
            } else {
                $log_message = loggable_date() . '  PAUSED FOR ' . integer_format($loop_wait_seconds) . ' SECONDS BEFORE LOOPING' . "\n";
            }
            if ($verbose) {
                $ret[] = $log_message;
            }
            if ($log_file !== null) {
                flock($log_file, LOCK_EX);
                fseek($log_file, 0, SEEK_END);
                fwrite($log_file, $log_message);
                flock($log_file, LOCK_UN);
            }

            if ($loop_ending) {
                break;
            }

            if ($loop_wait_seconds != 0) {
                if (php_function_allowed('sleep')) {
                    sleep($loop_wait_seconds);
                } elseif (php_function_allowed('usleep')) {
                    usleep($loop_wait_seconds * 1000000);
                } else {
                    $log_message = loggable_date() . '  PHP sleep and usleep functions are missing so loop_wait_seconds will be ignored' . "\n";
                    if ($verbose) {
                        echo $log_message;
                    }
                    if ($log_file !== null) {
                        flock($log_file, LOCK_EX);
                        fseek($log_file, 0, SEEK_END);
                        fwrite($log_file, $log_message);
                        flock($log_file, LOCK_UN);
                    }
                }
            }

            $kill_cron_looping = get_value('kill_cron_looping', '0', true);
            clearstatcache(false, get_file_base() . '/sources/version.php');
            if (($kill_cron_looping == '1') || (filemtime(get_file_base() . '/sources/version.php') > $_SERVER['REQUEST_TIME'])) {
                $log_message = loggable_date() . '  LOOPING KILL SIGNAL DETECTED' . "\n";
                if ($verbose) {
                    echo $log_message;
                }
                if ($log_file !== null) {
                    flock($log_file, LOCK_EX);
                    fseek($log_file, 0, SEEK_END);
                    fwrite($log_file, $log_message);
                    flock($log_file, LOCK_UN);
                }

                // The software has sent itself a signal that it needs to stop looping over Cron
                delete_value('kill_cron_looping');
                break;
            }
        }
    } while ($loop); // Don't manually set $loop to false to terminate, do a break - we still rely on $loop being correct below

    if ($loop) {
        // Unlock
        delete_value('cron_currently_running', true);
    }

    // Ending logging
    $log_message = loggable_date() . '  (CRON ENDING)' . "\n";
    if ($verbose) {
        echo $log_message;
    }
    if ($log_file !== null) {
        flock($log_file, LOCK_EX);
        fseek($log_file, 0, SEEK_END);
        fwrite($log_file, $log_message);
        flock($log_file, LOCK_UN);

        fclose($log_file);
    }

    return implode("\n", $ret);
}
