<?php
/**
 * Database schema versioning.
 *
 * @package Website_LLMS_TXT
 */

if (!defined('ABSPATH')) {
    exit;
}

/**
 * The schema upgrade ladder for wp_llms_txt_cache.
 *
 * See docs/internals-class-llms-db.md for the ladder and lifecycle invariants.
 */
class LLMS_DB
{
    /**
     * Running the step twice at the same time produces the same result as
     * running it once, so no lock is taken. CREATE TABLE and ALTER TABLE
     * qualify.
     */
    const STEP_CONVERGENT = 'convergent';

    /**
     * Anything else. Serialised behind a lease lock, and the version is not
     * recorded unless the lease was still held when the step returned.
     */
    const STEP_EXCLUSIVE = 'exclusive';

    /**
     * Name of the read index, in one place because two things ask about it.
     */
    const READ_INDEX = 'llms_read';

    /**
     * Oldest plugin version whose generated file may be kept.
     *
     * Raising it makes every install regenerate. See the internals doc.
     */
    const ARTIFACT_REBUILD_SINCE = '8.5.4';

    /**
     * The five states the generated document can be in. See lifecycle_state(),
     * which is the only place any of them is decided.
     */
    const LIFECYCLE_CURRENT   = 'current';
    const LIFECYCLE_NONE      = 'none';
    const LIFECYCLE_OWED      = 'owed';
    const LIFECYCLE_IN_FLIGHT = 'in_flight';
    const LIFECYCLE_STALE     = 'stale';

    /**
     * Attempts at an owed rebuild that are made as soon as anything notices.
     */
    const REBUILD_FREE_ATTEMPTS = 2;

    /**
     * Seconds between automatic rebuild attempts once the free ones are spent.
     *
     * Never applies to anything a human pressed; those call update_llms_file()
     * directly.
     */
    const REBUILD_RETRY_AFTER = 300;

    /**
     * Seconds before the same fault may rewrite its record with a new detail.
     */
    const CONDITION_REFRESH_AFTER = 60;

    /**
     * Delete-and-rebuild cycles allowed inside ARTIFACT_CYCLE_WINDOW.
     *
     * It cannot be lower than 3. See the internals doc.
     */
    const ARTIFACT_CYCLE_LIMIT = 3;

    /**
     * The window the cycle count is measured over, and ages out of.
     *
     * Rolling, not a bucket that refills on the hour.
     */
    const ARTIFACT_CYCLE_WINDOW = 3600;

    /**
     * Whether this request has already counted an automatic rebuild attempt.
     *
     * @var bool
     */
    protected static $attempt_recorded_this_request = false;

    /**
     * Run every step this install has not run yet.
     *
     * @return bool True when the install is usable at the target version by the
     *              time we return.
     */
    public static function maybe_upgrade()
    {
        $installed = static::installed_version();
        $target    = static::target_version();

        // Before the ladder, not on any of this method's three exits.
        $baseline = static::ensure_baseline($installed >= $target);

        // After the baseline (it deletes cache rows) and before the ladder
        // (which can exit three ways). Not keyed on the schema version.
        if ($baseline) {
            static::maybe_clean_artifacts();
        }

        if ($installed >= $target) {
            return $baseline;
        }

        if (!$baseline) {
            // No table, and we could not make one. Running the steps now would
            // overwrite the accurate cache_table_missing record.
            return false;
        }

        $steps = static::steps();
        ksort($steps, SORT_NUMERIC);

        // LLMS_DB_VERSION has to name a real step. Withdraw a step by leaving a
        // no-op in its place, never by raising the constant past it.
        $last_step = empty($steps) ? 0 : (int) max(array_keys($steps));
        if ($last_step !== $target) {
            _doing_it_wrong(
                __METHOD__,
                'LLMS_DB_VERSION must match the highest registered ladder step.',
                '8.5.4'
            );
            static::record_condition('version_constant_mismatch', $target, 'last_registered_step=' . $last_step);
            return false;
        }

        foreach ($steps as $version => $step) {
            $version = (int) $version;

            if ($version <= $installed || $version > $target) {
                continue;
            }

            if (!static::run_step($version, $step)) {
                return false;
            }

            $installed = $version;
        }

        return true;
    }

    /**
     * Run one step, serialising it first unless it is convergent.
     *
     * @param int   $version Step number.
     * @param mixed $step    Step declaration.
     * @return bool True when the step ran and its version was recorded.
     */
    protected static function run_step($version, $step)
    {
        if (!is_array($step)
            || !isset($step['callback'])
            || !isset($step['concurrency'])
            || !is_callable($step['callback'])
        ) {
            _doing_it_wrong(
                __METHOD__,
                'Ladder step ' . (int) $version . ' must declare a callback and a concurrency mode.',
                '8.5.4'
            );
            static::record_condition('step_declaration_invalid', $version, '');
            return false;
        }

        if (self::STEP_CONVERGENT === $step['concurrency']) {
            // The version is written last on purpose: recording it first would
            // mark the migration done on an install where the step did nothing.
            try {
                $ran = (true === call_user_func($step['callback']));
            } catch (\Throwable $e) {
                static::record_condition(
                    'step_threw',
                    $version,
                    get_class($e) . ': ' . $e->getMessage()
                );
                return false;
            }

            if (!$ran) {
                static::record_condition('step_failed', $version, '');
                return false;
            }

            static::set_installed_version($version);
            static::clear_condition();
            return true;
        }

        if (self::STEP_EXCLUSIVE !== $step['concurrency']) {
            _doing_it_wrong(
                __METHOD__,
                'Ladder step ' . (int) $version . ' declares an unknown concurrency mode.',
                '8.5.4'
            );
            $declared = is_scalar($step['concurrency'])
                ? (string) $step['concurrency']
                : gettype($step['concurrency']);

            static::record_condition('step_concurrency_unknown', $version, $declared);
            return false;
        }

        $lock_name = static::option_name() . '_step_' . (int) $version;
        $token     = LLMS_Lock::acquire($lock_name);

        if (false === $token) {
            return false;
        }

        // try/finally so a step that throws still gives the lease back.
        $ran   = false;
        $threw = null;

        try {
            $ran = (true === call_user_func($step['callback'], $lock_name, $token));
        } catch (\Throwable $e) {
            // Swallowed on purpose: the ladder runs on init on every request.
            $ran   = false;
            $threw = get_class($e) . ': ' . $e->getMessage();
        } finally {
            // By identity, not by the token we started with: a step that
            // refreshed its lease holds a row whose value has moved on.
            $still_held = LLMS_Lock::release_identity($lock_name, $token);
        }

        if (!$ran || !$still_held) {
            if (null !== $threw) {
                static::record_condition('step_threw', $version, $threw);
            } elseif (!$ran) {
                static::record_condition('step_failed', $version, '');
            } else {
                static::record_condition('step_lease_lost', $version, '');
            }

            return false;
        }

        static::set_installed_version($version);
        static::clear_condition();
        return true;
    }

    /**
     * Schema version this install is currently at.
     *
     * @return int Zero for any install that predates 8.5.4.
     */
    public static function installed_version()
    {
        return (int) get_option(static::option_name(), 0);
    }

    /**
     * Whether the table really carries what the recorded version claims.
     *
     * Not for ordinary requests: two metadata queries. Settings screen only.
     *
     * @return array version, target, complete, missing.
     */
    public static function schema_status()
    {
        global $wpdb;

        $installed = static::installed_version();

        $out = array(
            'version'  => $installed,
            'target'   => static::target_version(),
            'complete' => true,
            'missing'  => array(),
        );

        if (!static::cache_table_exists()) {
            $out['complete'] = false;
            $out['missing'][] = 'cache table';

            return $out;
        }

        $table = $wpdb->prefix . 'llms_txt_cache';

        $suppressed = $wpdb->suppress_errors(true);
        $columns    = $wpdb->get_col("SHOW COLUMNS FROM `{$table}`");
        $indexes    = $wpdb->get_col("SHOW INDEX FROM `{$table}`", 2);
        $error      = $wpdb->last_error;
        $wpdb->suppress_errors($suppressed);

        if ('' !== $error) {
            $out['complete'] = false;
            $out['missing'][] = 'cache table (unreadable)';

            return $out;
        }

        $columns = array_map('strtolower', (array) $columns);
        $indexes = array_map('strtolower', (array) $indexes);

        $steps = static::steps();
        ksort($steps, SORT_NUMERIC);

        foreach ($steps as $version => $step) {
            if ((int) $version > $installed || !is_array($step) || empty($step['expects'])) {
                continue;
            }

            $expects = $step['expects'];

            if (!empty($expects['columns'])) {
                foreach ($expects['columns'] as $column) {
                    if (!in_array(strtolower($column), $columns, true)) {
                        $out['missing'][] = 'column ' . $column;
                    }
                }
            }

            if (!empty($expects['indexes'])) {
                foreach ($expects['indexes'] as $index) {
                    if (!in_array(strtolower($index), $indexes, true)) {
                        $out['missing'][] = 'index ' . $index;
                    }
                }
            }
        }

        $out['complete'] = empty($out['missing']);

        if ($out['complete']) {
            $autoloaded = wp_load_alloptions();
            $name       = static::condition_option_name();

            if (is_array($autoloaded) && isset($autoloaded[$name])) {
                $existing = maybe_unserialize($autoloaded[$name]);
                $code     = (is_array($existing) && isset($existing['code'])) ? (string) $existing['code'] : '';

                if (static::condition_survives_healthy_request($code)) {
                    static::clear_condition();
                }
            }
        }

        return $out;
    }

    /**
     * Schema version this copy of the plugin expects.
     *
     * @return int
     */
    protected static function target_version()
    {
        return (int) LLMS_DB_VERSION;
    }

    /**
     * Option holding the installed schema version.
     *
     * @return string
     */
    protected static function option_name()
    {
        return 'llms_db_version';
    }

    /**
     * The ladder itself, oldest step first.
     *
     * Step shape and the rules a step author must follow are in
     * docs/internals-class-llms-db.md.
     *
     * @return array
     */
    protected static function steps()
    {
        return array(
            1 => array(
                'callback'    => array(__CLASS__, 'step_create_cache_table'),
                'concurrency' => self::STEP_CONVERGENT,
                'expects'     => array(
                    'columns' => static::baseline_columns(),
                    'indexes' => array('PRIMARY'),
                ),
            ),
            2 => array(
                'callback'    => array(__CLASS__, 'step_add_read_index'),
                'concurrency' => self::STEP_CONVERGENT,
                'expects'     => array(
                    'indexes' => array(self::READ_INDEX),
                ),
            ),
            3 => array(
                'callback'    => array(__CLASS__, 'step_rebuild_generated_file'),
                'concurrency' => self::STEP_EXCLUSIVE,
                // Touches no schema, so the repair path must not report the
                // table as incomplete for it. See replay_convergent_steps().
                'schema'      => false,
            ),
        );
    }

    /**
     * Record the version reached.
     *
     * @param int $version Version number.
     * @return void
     */
    protected static function set_installed_version($version)
    {
        update_option(static::option_name(), (string) (int) $version, true);
    }

    /**
     * The baseline is there, whatever the ladder thinks it has already done.
     *
     * A repair path, not a ladder step: it records no version.
     *
     * @param bool $ladder_current Whether the ladder has nothing left to climb.
     *                             A condition record left over from earlier
     *                             trouble is only cleared when it has: a step
     *                             that is still failing is about to record one,
     *                             and clearing it first would move the recorded
     *                             start time forward on every request and write
     *                             to wp_options on every request with it.
     * @return bool True when the baseline is in place and carries the schema the
     *              recorded version claims.
     */
    protected static function ensure_baseline($ladder_current = true)
    {
        if (static::cache_table_exists()) {
            if ($ladder_current) {
                static::clear_condition_if_present();
            }

            return true;
        }

        if (!static::create_cache_table()) {
            static::record_condition('cache_table_missing', 1, 'recreate failed');
            return false;
        }

        $replay = static::replay_convergent_steps(static::installed_version());

        if (!empty($replay['failed'])) {
            $first  = (int) $replay['failed'][0];
            $detail = 'steps=' . implode(',', $replay['failed']);

            if (isset($replay['threw'][$first])) {
                $detail .= ' ' . $replay['threw'][$first];
            }

            static::record_condition('baseline_replay_failed', $first, $detail);
            return false;
        }

        if (!empty($replay['skipped'])) {
            static::record_condition(
                'baseline_replay_incomplete',
                (int) $replay['skipped'][0],
                'steps=' . implode(',', $replay['skipped'])
            );
            return true;
        }

        static::clear_condition();
        return true;
    }

    /**
     * Re-apply the schema a recreated table has lost.
     *
     * Only STEP_CONVERGENT steps are replayed. A step that changes the cache
     * table schema must therefore be STEP_CONVERGENT. See the internals doc.
     *
     * @param int $through Highest step number the install claims to have run.
     * @return array replayed, skipped, ignored and failed step numbers, plus
     *               threw keyed by step number for anything that raised a
     *               Throwable.
     */
    protected static function replay_convergent_steps($through)
    {
        $result = array(
            'replayed' => array(),
            'skipped'  => array(),
            'ignored'  => array(),
            'failed'   => array(),
            'threw'    => array(),
        );

        $through = (int) $through;
        if ($through < 1) {
            return $result;
        }

        $steps = static::steps();
        ksort($steps, SORT_NUMERIC);

        foreach ($steps as $version => $step) {
            $version = (int) $version;

            if ($version > $through) {
                break;
            }

            if (!is_array($step)
                || !isset($step['callback'])
                || !isset($step['concurrency'])
                || !is_callable($step['callback'])
                || self::STEP_CONVERGENT !== $step['concurrency']
            ) {
                if (is_array($step) && isset($step['schema']) && false === $step['schema']) {
                    $result['ignored'][] = $version;
                    continue;
                }

                $result['skipped'][] = $version;
                continue;
            }

            // No lock and no version write: writing the version here would let a
            // partial repair look like a completed ladder.
            try {
                $ran = (true === call_user_func($step['callback']));
            } catch (\Throwable $e) {
                $result['failed'][]          = $version;
                $result['threw'][$version]   = get_class($e) . ': ' . $e->getMessage();
                continue;
            }

            if ($ran) {
                $result['replayed'][] = $version;
            } else {
                $result['failed'][] = $version;
            }
        }

        return $result;
    }

    /**
     * Whether the cache table is there right now.
     *
     * @return bool
     */
    protected static function cache_table_exists()
    {
        global $wpdb;

        $table = $wpdb->prefix . 'llms_txt_cache';

        return ($table === $wpdb->get_var($wpdb->prepare(
            "SHOW TABLES LIKE %s",
            $table
        )));
    }

    /**
     * Step 1: the cache table exists.
     *
     * @return bool
     */
    protected static function step_create_cache_table()
    {
        return static::create_cache_table();
    }

    /**
     * Create the cache table if it is not there, and report on what is there
     * afterwards.
     *
     * The CREATE below is the baseline as of step 1. A later schema step must
     * NOT be copied into it; its one home is the step.
     *
     * @return bool
     */
    protected static function create_cache_table()
    {
        global $wpdb;

        if (static::cache_table_exists()) {
            return true;
        }

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        dbDelta(static::cache_table_create_sql($wpdb->prefix . 'llms_txt_cache'));

        // Not dbDelta's return value, which says nothing about whether the
        // statements worked.
        return static::cache_table_exists();
    }

    /**
     * The baseline CREATE, in one place.
     *
     * @param string $table Table name, already prefixed.
     * @return string
     */
    protected static function cache_table_create_sql($table)
    {
        global $wpdb;

        $charset_collate = $wpdb->get_charset_collate();

        return "CREATE TABLE $table (
            `post_id` BIGINT UNSIGNED NOT NULL PRIMARY KEY,
            `show` TINYINT NULL DEFAULT NULL,
            `status` VARCHAR(20) DEFAULT NULL,
            `type` VARCHAR(20) DEFAULT NULL,
            `title` TEXT DEFAULT NULL,
            `link` VARCHAR(255) DEFAULT NULL,
            `sku` VARCHAR(255) DEFAULT NULL,
            `price` VARCHAR(125) DEFAULT NULL,
            `excerpts` TEXT DEFAULT NULL,
            `overview` TEXT DEFAULT NULL,
            `meta` TEXT DEFAULT NULL,
            `content` LONGTEXT DEFAULT NULL,
            `published` DATETIME DEFAULT NULL,
            `modified` DATETIME DEFAULT NULL
        ) $charset_collate;";
    }

    /**
     * The columns the baseline table carries, read back out of the CREATE.
     *
     * @return string[] Lower case column names.
     */
    protected static function baseline_columns()
    {
        $sql     = static::cache_table_create_sql('llms_txt_cache');
        $matches = array();

        preg_match_all('/^[ \t]*`([A-Za-z0-9_]+)`/m', $sql, $matches);

        return isset($matches[1]) ? array_map('strtolower', $matches[1]) : array();
    }

    /**
     * Step 2: the read index.
     *
     * Check, act, check rather than a bare ALTER: MySQL has no ADD KEY IF NOT
     * EXISTS, so the answer comes from the second check.
     *
     * @return bool
     */
    protected static function step_add_read_index()
    {
        global $wpdb;

        $table = $wpdb->prefix . 'llms_txt_cache';
        $index = self::READ_INDEX;

        if (static::index_exists($table, $index)) {
            return true;
        }

        $suppressed = $wpdb->suppress_errors(true);
        // Identifiers, not values, so there is nothing prepare() could bind.
        // `show` is a reserved word, which is why every column is quoted.
        $wpdb->query(
            "ALTER TABLE `{$table}` ADD KEY `{$index}` (`type`, `status`, `show`, `published`)"
        );
        $wpdb->suppress_errors($suppressed);

        return static::index_exists($table, $index);
    }

    /**
     * Whether a named index is on the table right now.
     *
     * @param string $table Table name, already prefixed.
     * @param string $index Key name.
     * @return bool
     */
    protected static function index_exists($table, $index)
    {
        global $wpdb;

        $suppressed = $wpdb->suppress_errors(true);
        $found = $wpdb->get_var($wpdb->prepare(
            "SHOW INDEX FROM `{$table}` WHERE Key_name = %s",
            $index
        ));
        $error = $wpdb->last_error;
        $wpdb->suppress_errors($suppressed);

        if ('' !== $error) {
            return false;
        }

        return (null !== $found);
    }

    /**
     * Step 3: the 8.5.4 migration. The work lives in maybe_clean_artifacts().
     *
     * @param string $lock_name Lease held by the ladder. Unused: this is short.
     * @param string $token     Lease token. Unused, same reason.
     * @return bool
     */
    protected static function step_rebuild_generated_file($lock_name = '', $token = '')
    {
        if (static::maybe_clean_artifacts()) {
            return true;
        }

        // A blocked path and the cycle limit are stand-downs, not ladder
        // failures, and must report success: this step is STEP_EXCLUSIVE, so
        // reporting failure takes a lease on every request for ever. In-flight
        // and lease-not-taken still return false.
        return (static::artifact_blocked_recently() || static::artifact_cycle_limit_reached());
    }

    /**
     * Drop the cache rows for posts an anonymous visitor cannot read at all.
     *
     * @return bool True when the rows are gone.
     */
    protected static function purge_unreadable_cache_rows()
    {
        global $wpdb;

        $table = $wpdb->prefix . 'llms_txt_cache';

        $suppressed = $wpdb->suppress_errors(true);
        $wpdb->query(
            "DELETE c FROM `{$table}` c
             INNER JOIN {$wpdb->posts} p ON p.ID = c.post_id
             WHERE p.post_status <> 'publish' OR p.post_password <> ''"
        );
        $joined_error = $wpdb->last_error;

        if ('' === $joined_error) {
            $wpdb->suppress_errors($suppressed);
            return true;
        }

        $wpdb->query(
            "DELETE FROM `{$table}`
             WHERE post_id IN (
                 SELECT ID FROM {$wpdb->posts}
                 WHERE post_status <> 'publish' OR post_password <> ''
             )"
        );
        $fallback_error = $wpdb->last_error;
        $wpdb->suppress_errors($suppressed);

        return ('' === $fallback_error);
    }

    /**
     * Option that records a rebuild is owed.
     *
     * @return string
     */
    protected static function rebuild_option_name()
    {
        return 'llms_rebuild_pending';
    }

    /**
     * Ask for the generated file to be rebuilt, by both routes.
     *
     * The flag is a state, not a token: only a promoted document consumes it.
     * Its value carries the attempt record, "<attempts>:<unix time>". Asking for
     * a rebuild counts as an attempt, and the delay is derived here rather than
     * passed. See the internals doc.
     *
     * @param int|null $delay Seconds until the WP-Cron retry. Null, which is what
     *                        every caller in the plugin passes, means "whatever
     *                        the attempt record says it should be now".
     * @return void
     */
    public static function request_rebuild($delay = null)
    {
        // Before the delay is read, so that the delay reflects the attempt being
        // made rather than the one before it.
        static::note_rebuild_attempt();

        if (null === $delay) {
            $delay = static::rebuild_retry_delay();
        }

        static::schedule_rebuild($delay);
    }

    /**
     * Book the scheduled route, without moving one that would already arrive in
     * time.
     *
     * An existing booking is left alone unless this call wants the rebuild
     * SOONER, including one already in the past. Re-booking unconditionally
     * starves the event on a site with ordinary traffic.
     *
     * @param int $delay Seconds from now.
     * @return void
     */
    protected static function schedule_rebuild($delay)
    {
        $when   = time() + max(1, (int) $delay);
        $booked = wp_next_scheduled('llms_update_llms_file_cron');

        if (false !== $booked && (int) $booked <= $when) {
            return;
        }

        if (false !== $booked) {
            wp_clear_scheduled_hook('llms_update_llms_file_cron');
        }

        wp_schedule_single_event($when, 'llms_update_llms_file_cron');
    }

    /**
     * Whether a rebuild is still owed.
     *
     * A bootstrap-snapshot read is safe here because being wrong is not
     * destructive. Do not change it to get_option(). See the internals doc.
     *
     * @return bool
     */
    public static function rebuild_pending()
    {
        $autoloaded = wp_load_alloptions();

        return (is_array($autoloaded) && isset($autoloaded[static::rebuild_option_name()]));
    }

    /**
     * How many automatic attempts this owed rebuild has already had recently.
     *
     * @return int
     */
    protected static function rebuild_attempts()
    {
        $parts = static::rebuild_record();

        if (null === $parts) {
            return 0;
        }

        if ($parts[1] > 0 && (time() - $parts[1]) >= static::REBUILD_RETRY_AFTER) {
            return 0;
        }

        return $parts[0];
    }

    /**
     * When the last automatic attempt was made, 0 when there has been none.
     *
     * @return int
     */
    protected static function rebuild_last_attempt()
    {
        $parts = static::rebuild_record();

        return (null === $parts) ? 0 : $parts[1];
    }

    /**
     * The stored flag, parsed.
     *
     * A bare "1", which older builds wrote, reads as "owed, never attempted".
     *
     * @return int[]|null [attempts, last attempt] or null when nothing is owed.
     */
    protected static function rebuild_record()
    {
        $autoloaded = wp_load_alloptions();
        $name       = static::rebuild_option_name();

        if (!is_array($autoloaded) || !isset($autoloaded[$name])) {
            return null;
        }

        $parts = explode(':', (string) $autoloaded[$name], 2);

        if (2 !== count($parts)) {
            return array(0, 0);
        }

        return array((int) $parts[0], (int) $parts[1]);
    }

    /**
     * May this request make an automatic attempt at the owed rebuild, and record
     * that it did.
     *
     * @return bool
     */
    public static function claim_rebuild_attempt()
    {
        if (!static::rebuild_pending()) {
            return false;
        }

        $attempts = static::rebuild_attempts();

        if ($attempts >= static::REBUILD_FREE_ATTEMPTS
            && (time() - static::rebuild_last_attempt()) < static::REBUILD_RETRY_AFTER
        ) {
            return false;
        }

        update_option(
            static::rebuild_option_name(),
            ($attempts + 1) . ':' . time(),
            true
        );

        static::$attempt_recorded_this_request = true;

        return true;
    }

    /**
     * How long the automatic routes should wait before the next attempt.
     *
     * @return int Seconds.
     */
    protected static function rebuild_retry_delay()
    {
        return (static::rebuild_attempts() >= static::REBUILD_FREE_ATTEMPTS)
            ? static::REBUILD_RETRY_AFTER
            : 30;
    }

    /**
     * Make sure the scheduled route to an owed rebuild exists, without moving one
     * that already does.
     *
     * @return void
     */
    public static function ensure_rebuild_scheduled()
    {
        if (!static::rebuild_pending()) {
            return;
        }

        if (wp_next_scheduled('llms_update_llms_file_cron')) {
            return;
        }

        wp_schedule_single_event(time() + static::rebuild_retry_delay(), 'llms_update_llms_file_cron');
    }

    /**
     * A run promoted a document. Record it, and nothing is owed any more.
     *
     * Must stay where update_llms_file() calls it: the moment both destinations
     * are settled and before anything third party runs.
     *
     * @return void
     */
    public static function note_document_promoted()
    {
        static::stamp_artifacts();

        if (static::rebuild_pending()) {
            delete_option(static::rebuild_option_name());
        }

        wp_clear_scheduled_hook('llms_update_llms_file_cron');

        static::forget_artifact_block_if_clear();
    }

    /**
     * Retire the undeletable-artifact record once the path really has gone.
     *
     * Checked against the filesystem rather than assumed: a successful promote
     * does NOT prove the survivor is gone. See the internals doc.
     *
     * @return void
     */
    protected static function forget_artifact_block_if_clear()
    {
        $block = static::artifact_block();

        if (null === $block) {
            return;
        }

        if (!function_exists('llms_served_file_paths')) {
            return;
        }

        // The question is "is this still a foreign document", not "has the path
        // gone": the promote usually put our own new document back at it.
        $uploads = wp_get_upload_dir();
        $basedir = isset($uploads['basedir']) ? trailingslashit($uploads['basedir']) : '';
        $fresh   = null;

        if ('' !== $basedir) {
            foreach (llms_served_file_paths() as $served) {
                if (0 === strpos($served, $basedir) && file_exists($served)) {
                    $fresh = @md5_file($served);
                    break;
                }
            }
        }

        foreach ($block['paths'] as $path) {
            clearstatcache(true, $path);

            if (!file_exists($path)) {
                continue;
            }

            if (null !== $fresh && false !== $fresh && @md5_file($path) === $fresh) {
                continue;
            }

            // Still a document we did not write, at a path we could not clear.
            return;
        }

        static::clear_artifact_blocked();
    }

    /**
     * A run ended without a document and has removed the destinations.
     *
     * @return void
     */
    public static function note_document_absent()
    {
        static::stamp_artifacts();

        static::request_rebuild();
    }

    /**
     * Advance the automatic-retry counter, once per request.
     *
     * The request-scoped guard cannot leave the obligation unrecorded: it is
     * only set by this method or claim_rebuild_attempt(), and both write the
     * flag before setting it.
     *
     * @return void
     */
    protected static function note_rebuild_attempt()
    {
        if (static::$attempt_recorded_this_request) {
            return;
        }

        update_option(
            static::rebuild_option_name(),
            (static::rebuild_attempts() + 1) . ':' . time(),
            true
        );

        static::$attempt_recorded_this_request = true;
    }

    /**
     * Option recording which plugin version left which file on disk.
     *
     * @return string
     */
    protected static function artifact_option_name()
    {
        return 'llms_artifact_stamp';
    }

    /**
     * The running plugin version, or 0 where the constant is not defined.
     *
     * @return string
     */
    protected static function plugin_version()
    {
        // WEBSITE_LLMS_TXT_VERSION, never LLMS_VERSION: on a site that also runs
        // LifterLMS the old name is LifterLMS's version.
        return defined('WEBSITE_LLMS_TXT_VERSION') ? (string) WEBSITE_LLMS_TXT_VERSION : '0';
    }

    /**
     * Record that this version of the plugin left what is on disk now.
     *
     * @return void
     */
    public static function stamp_artifacts()
    {
        if (!function_exists('llms_generated_artifact_fingerprint')) {
            return;
        }

        update_option(
            static::artifact_option_name(),
            static::plugin_version() . ' ' . llms_generated_artifact_fingerprint(),
            true
        );
    }

    /**
     * The stamp as stored, or null when there is none.
     *
     * THIS IS A SNAPSHOT, NOT A LIVE READ. Nothing destructive may act on it;
     * lifecycle_state() confirms against the database before answering `stale`.
     * See fresh_artifact_stamp() and the internals doc.
     *
     * @return string|null
     */
    protected static function stored_artifact_stamp()
    {
        $autoloaded = wp_load_alloptions();
        $name       = static::artifact_option_name();

        if (!is_array($autoloaded) || !isset($autoloaded[$name])) {
            return null;
        }

        return (string) $autoloaded[$name];
    }

    /**
     * The stamp as it is in the database right now, bypassing every cache.
     *
     * $wpdb directly, because neither wp_load_alloptions() nor get_option() is
     * live for an autoloaded option. Three answers, not two: "no row" and
     * "could not ask" must stay apart. See the internals doc.
     *
     * @return string|null|false The stamp; null when the row genuinely does not
     *                           exist; false when the question could not be
     *                           asked at all.
     */
    protected static function fresh_artifact_stamp()
    {
        global $wpdb;

        if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_var')) {
            return false;
        }

        $suppressed = $wpdb->suppress_errors(true);
        $value      = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
                static::artifact_option_name()
            )
        );
        $error = $wpdb->last_error;
        $wpdb->suppress_errors($suppressed);

        if ('' !== $error) {
            // The error is the only thing separating "failed" from "no row".
            return false;
        }

        return (null === $value) ? null : (string) $value;
    }

    /**
     * Does this stamp describe this disk.
     *
     * @param string|null $stored      A stamp, "<plugin version> <fingerprint>".
     * @param string      $fingerprint What is on disk now.
     * @return bool
     */
    protected static function stamp_describes($stored, $fingerprint)
    {
        if (null === $stored) {
            return false;
        }

        $parts   = explode(' ', $stored, 2);
        $version = $parts[0];
        $digest  = isset($parts[1]) ? $parts[1] : null;

        return (null !== $digest
            && $digest === $fingerprint
            && version_compare($version, self::ARTIFACT_REBUILD_SINCE, '>='));
    }

    /**
     * Where this site stands with its generated document. One decision, in one
     * place, for every caller.
     *
     * `stale` is the only state that deletes anything. The five states and the
     * ordering rules are in docs/internals-class-llms-db.md.
     *
     * @return string One of the LIFECYCLE_* constants.
     */
    public static function lifecycle_state()
    {
        if (!function_exists('llms_generated_artifact_fingerprint')) {
            // in_flight means "not mine to act on", the safe answer when the
            // question cannot be asked.
            return self::LIFECYCLE_IN_FLIGHT;
        }

        $stored      = static::stored_artifact_stamp();
        $fingerprint = llms_generated_artifact_fingerprint();

        if (static::stamp_describes($stored, $fingerprint)) {
            return static::settled_state($fingerprint);
        }

        if (static::generation_in_flight()) {
            return self::LIFECYCLE_IN_FLIGHT;
        }

        // ABOUT TO ANSWER `stale`, THE ONLY STATE THAT DELETES ANYTHING. Confirm
        // against the database first: fingerprint, live stamp, fingerprint
        // again. Do not act on the bootstrap snapshot here. See the internals
        // doc for why the order is the argument.
        $fresh = static::fresh_artifact_stamp();

        if (false === $fresh) {
            // "I could not check" must never be read as "I checked, and it is
            // not mine".
            return self::LIFECYCLE_IN_FLIGHT;
        }

        if ($fresh === $stored) {
            // Null on both sides lands here too: an install that has never
            // stamped anything must still be able to reach stale.
            return self::LIFECYCLE_STALE;
        }

        $confirm = llms_generated_artifact_fingerprint();

        if ($confirm !== $fingerprint) {
            return self::LIFECYCLE_IN_FLIGHT;
        }

        if (static::stamp_describes($fresh, $confirm)) {
            // Confirmed, so the rebuild flag is read the same way.
            return static::settled_state($confirm, true);
        }

        return self::LIFECYCLE_STALE;
    }

    /**
     * The answer when the stamp and the disk agree: which of the three settled
     * states this site is in.
     *
     * $confirmed is not a refinement, it closes a second loop: a snapshot too
     * old to carry the new stamp is also too old to have lost the rebuild flag,
     * so on that path the flag must be read live too. See the internals doc.
     *
     * @param string $fingerprint What is on disk.
     * @param bool   $confirmed   True when the snapshot is known to be stale.
     * @return string
     */
    protected static function settled_state($fingerprint, $confirmed = false)
    {
        if ($confirmed) {
            $pending = static::fresh_rebuild_pending();

            if (null === $pending) {
                // Could not ask. Same rule as the stamp read: defer rather than
                // answer from evidence we do not have.
                return self::LIFECYCLE_IN_FLIGHT;
            }
        } else {
            $pending = static::rebuild_pending();
        }

        if ($pending) {
            return self::LIFECYCLE_OWED;
        }

        return ('' === $fingerprint) ? self::LIFECYCLE_NONE : self::LIFECYCLE_CURRENT;
    }

    /**
     * Whether a rebuild is owed according to the database rather than according
     * to this request's bootstrap snapshot.
     *
     * Only for settled_state()'s confirmed path. IT MUST NOT FALL BACK TO THE
     * SNAPSHOT: that is the read this method exists to distrust, and falling
     * back to it books a full crawl. Return null and let the caller defer.
     *
     * @return bool|null True or false when the database answered; null when it
     *                   could not be asked.
     */
    protected static function fresh_rebuild_pending()
    {
        global $wpdb;

        if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_var')) {
            return null;
        }

        $suppressed = $wpdb->suppress_errors(true);
        $value      = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
                static::rebuild_option_name()
            )
        );
        $error = $wpdb->last_error;
        $wpdb->suppress_errors($suppressed);

        if ('' !== $error) {
            return null;
        }

        return (null !== $value);
    }

    /**
     * Remove a generated file this version of the plugin did not write, and ask
     * for it to be rebuilt.
     *
     * Deliberately keyed on the artifact stamp and not on llms_db_version, which
     * cannot see a rollback. The decision itself is lifecycle_state()'s, once,
     * for every caller. See the internals doc.
     *
     * @return bool True when nothing is owed by the time this returns.
     */
    public static function maybe_clean_artifacts()
    {
        if (!function_exists('llms_generated_artifact_fingerprint')) {
            return false;
        }

        $state = static::lifecycle_state();

        if (self::LIFECYCLE_OWED === $state) {
            static::ensure_rebuild_scheduled();

            return true;
        }

        if (self::LIFECYCLE_CURRENT === $state || self::LIFECYCLE_NONE === $state) {
            return true;
        }

        if (self::LIFECYCLE_IN_FLIGHT === $state) {
            // Deferred, not abandoned: nothing is recorded and nothing stamped.
            return false;
        }

        // Stale. A served path this process cannot remove would otherwise run a
        // purge query and take a lease on every request, for ever.
        if (static::artifact_blocked_recently()) {
            return false;
        }

        // The hard stop on the cycle itself, and the only brake here that a
        // SUCCESSFUL rebuild does not clear. Nothing is stamped on the way out:
        // stamping would adopt a document we did not write.
        if (static::artifact_cycle_limit_reached()) {
            return false;
        }

        // Serialised. A request that cannot take the lease does nothing and
        // records nothing, so this is not a failure state.
        $lock  = static::artifact_lock_name();
        $token = LLMS_Lock::acquire($lock);

        if (false === $token) {
            return false;
        }

        try {
            $cleaned = static::clean_artifacts();
        } catch (\Throwable $e) {
            static::record_condition('artifact_cleanup_threw', 0, get_class($e) . ': ' . $e->getMessage());
            $cleaned = false;
        } finally {
            LLMS_Lock::release_identity($lock, $token);
        }

        return $cleaned;
    }

    /**
     * Lease name for the artifact cleanup.
     *
     * @return string
     */
    protected static function artifact_lock_name()
    {
        return 'llms_artifact_lock';
    }

    /**
     * Whether a generation holds its lease right now.
     *
     * @return bool
     */
    protected static function generation_in_flight()
    {
        if (!class_exists('LLMS_Lock')) {
            return false;
        }

        $held = LLMS_Lock::held_token(static::generation_lock_name());

        if (!is_string($held)) {
            return false;
        }

        $parts = explode(':', $held, 2);

        if (2 !== count($parts) || '' === $parts[0] || !ctype_digit($parts[0])) {
            return false;
        }

        $stamped = (int) $parts[0];

        // Further ahead than the threshold is a row nobody will release, not
        // clock skew, and LLMS_Lock::is_stale() agrees.
        if ($stamped > (time() + LLMS_Lock::STALE_AFTER)) {
            return false;
        }

        $age = time() - $stamped;

        // A future stamp inside the threshold is clock skew between two nodes;
        // the age is negative there, so it reads as in flight, which it is.
        return ($age <= LLMS_Lock::STALE_AFTER);
    }

    /**
     * Lease name the generator takes for a run.
     *
     * LLMS_Generator::$lock_name has to match.
     *
     * @return string
     */
    protected static function generation_lock_name()
    {
        return 'llms_generation_lock';
    }

    /**
     * Do the cleanup itself.
     *
     * Order is deliberate: DELETE first (the only part that can fail on a
     * database we do not control), then the file, then the rebuild request, then
     * the stamp. "Are the artifacts gone" and "is a rebuild owed" are two
     * questions and must not share one test. See the internals doc.
     *
     * @return bool
     */
    protected static function clean_artifacts()
    {
        if (!static::purge_unreadable_cache_rows()) {
            return false;
        }

        if (!function_exists('llms_delete_generated_files')) {
            return false;
        }

        // Both read before the delete and before the re-stamp, because both of
        // those destroy the evidence.
        $claimed = static::stamp_claims_a_document();

        $present = static::present_served_paths();

        $survivors = array();
        $deleted   = (int) llms_delete_generated_files($survivors);

        // Count the cycle here, once, and NOT on the success path: a successful
        // rebuild clears every other brake in this class, and the runaway this
        // guards against is made of successes. Nothing a successful rebuild does
        // may touch this count. See the internals doc.
        $cycled = ($deleted > 0 || $claimed || !empty($survivors));

        if ($cycled) {
            static::note_artifact_cycle($present);
        }

        if ($deleted > 0 || $claimed) {
            static::request_rebuild();
        }

        if (!empty($survivors)) {
            // DO NOT STAMP on this branch. The stamp records what this plugin
            // deliberately left on disk, and we did not leave this; stamping it
            // adopts a foreign document and the cleanup never looks again.
            // Leaving the stamp disagreeing keeps the state stale, which is what
            // retries this. A known residual is recorded in the internals doc.
            static::request_rebuild();

            static::note_artifact_blocked($survivors);

            return false;
        }

        static::clear_artifact_blocked();

        static::stamp_artifacts();

        return true;
    }

    /**
     * The served paths that exist right now.
     *
     * @return string[]
     */
    protected static function present_served_paths()
    {
        $present = array();

        if (!function_exists('llms_served_file_paths')) {
            return $present;
        }

        foreach (llms_served_file_paths() as $path) {
            if (file_exists($path)) {
                $present[] = (string) $path;
            }
        }

        return $present;
    }

    /**
     * Option holding the recent delete-and-rebuild cycles.
     *
     * ITS OWN OPTION, deliberately, so nothing on the success path can clear it.
     * Do not move it into the shared condition slot. autoload = no.
     *
     * @return string
     */
    protected static function artifact_cycle_option_name()
    {
        return 'llms_artifact_cycles';
    }

    /**
     * The stored cycle record, normalised, with anything outside the window
     * already dropped.
     *
     * The window is applied on read, not on write, which is what makes it
     * rolling.
     *
     * @return array ['at' => int[], 'paths' => string[]]
     */
    protected static function artifact_cycle_record()
    {
        $stored = get_option(static::artifact_cycle_option_name());
        $now    = time();
        $at     = array();
        $paths  = array();

        if (is_array($stored)) {
            if (isset($stored['at']) && is_array($stored['at'])) {
                foreach ($stored['at'] as $stamp) {
                    $stamp = (int) $stamp;

                    if ($stamp > 0 && $stamp <= $now && ($now - $stamp) < static::ARTIFACT_CYCLE_WINDOW) {
                        $at[] = $stamp;
                    }
                }
            }

            if (isset($stored['paths']) && is_array($stored['paths'])) {
                $paths = array_values(array_map('strval', $stored['paths']));
            }
        }

        sort($at, SORT_NUMERIC);

        return array('at' => $at, 'paths' => $paths);
    }

    /**
     * Record that a delete-and-request-rebuild cycle has just been performed.
     *
     * @param string[] $paths The served paths that were on disk for this cycle.
     * @return void
     */
    protected static function note_artifact_cycle($paths)
    {
        $record = static::artifact_cycle_record();
        $at     = $record['at'];

        $at[] = time();

        // Keep the most recent, so a clock jump or a hand-edited option cannot
        // leave this list unbounded.
        if (count($at) > (static::ARTIFACT_CYCLE_LIMIT + 1)) {
            $at = array_slice($at, -(static::ARTIFACT_CYCLE_LIMIT + 1));
        }

        update_option(
            static::artifact_cycle_option_name(),
            array(
                'at'    => array_values($at),
                'paths' => array_values(array_map('strval', (array) $paths)),
            ),
            false
        );
    }

    /**
     * Has this site cycled too often to keep cycling.
     *
     * THE FIRST CYCLE IS NEVER REFUSED. The migration this mechanism exists for
     * is one cycle, and a limiter that stood down before it ran would leave the
     * pre-fix file in place. See the internals doc.
     *
     * @return bool
     */
    protected static function artifact_cycle_limit_reached()
    {
        $record = static::artifact_cycle_record();

        return (count($record['at']) >= static::ARTIFACT_CYCLE_LIMIT);
    }

    /**
     * The cycling record for anything that needs to show it, or null when the
     * site is not standing down.
     *
     * @return array|null ['since' => int, 'last' => int, 'count' => int,
     *                    'until' => int, 'paths' => string[]], or null.
     */
    public static function artifact_cycling()
    {
        $record = static::artifact_cycle_record();

        if (count($record['at']) < static::ARTIFACT_CYCLE_LIMIT) {
            return null;
        }

        $first = $record['at'][0];
        $last  = $record['at'][count($record['at']) - 1];

        return array(
            'since' => $first,
            'last'  => $last,
            'count' => count($record['at']),
            'until' => $first + static::ARTIFACT_CYCLE_WINDOW,
            'paths' => $record['paths'],
        );
    }

    /**
     * Option recording when the cleanup last found a served path it could not
     * remove.
     *
     * Deliberately NOT the condition record: record_condition() does not refresh
     * its timestamp for an unchanged fault, so a rate limit read from it would
     * open once and never close. autoload = no.
     *
     * @return string
     */
    protected static function artifact_block_option_name()
    {
        return 'llms_artifact_undeletable_at';
    }

    /**
     * Record that the cleanup could not clear a served path.
     *
     * @return void
     */
    protected static function note_artifact_blocked($paths)
    {
        update_option(
            static::artifact_block_option_name(),
            array(
                'at'    => time(),
                'paths' => array_values(array_map('strval', (array) $paths)),
            ),
            false
        );
    }

    /**
     * The current block, for anything that needs to show it.
     *
     * DELIBERATELY NOT a condition record. This is a statement about the
     * filesystem, which a healthy database does not disprove, and five separate
     * places clear llms_db_condition. See the internals doc.
     *
     * @return array|null ['at' => int, 'paths' => string[]], or null when clear.
     */
    public static function artifact_block()
    {
        $stored = get_option(static::artifact_block_option_name());

        if (!is_array($stored) || !isset($stored['at']) || empty($stored['paths'])) {
            return null;
        }

        return array(
            'at'    => (int) $stored['at'],
            'paths' => array_values((array) $stored['paths']),
        );
    }

    /**
     * Forget any previous block, because the cleanup has just succeeded.
     *
     * @return void
     */
    protected static function clear_artifact_blocked()
    {
        if (false !== get_option(static::artifact_block_option_name(), false)) {
            delete_option(static::artifact_block_option_name());
        }
    }

    /**
     * Whether the cleanup was blocked recently enough that retrying now is
     * only cost.
     *
     * @return bool
     */
    protected static function artifact_blocked_recently()
    {
        $block = static::artifact_block();

        if (null === $block) {
            return false;
        }

        $at = $block['at'];

        if ($at <= 0 || $at > time()) {
            return false;
        }

        return (time() - $at) < static::REBUILD_RETRY_AFTER;
    }

    /**
     * Whether the stamp says we left a document on disk.
     *
     * A non-empty fingerprint is the only thing that separates "the file went
     * missing" from "this install has never had one".
     *
     * @return bool False when there is no stamp at all, which is every install
     *              arriving from 8.5.3.
     */
    protected static function stamp_claims_a_document()
    {
        $stored = static::stored_artifact_stamp();

        if (null === $stored) {
            return false;
        }

        $parts = explode(' ', $stored, 2);

        return (isset($parts[1]) && '' !== $parts[1]);
    }

    /**
     * Option holding the last thing that went wrong.
     *
     * @return string
     */
    protected static function condition_option_name()
    {
        return 'llms_db_condition';
    }

    /**
     * Write down, in a shape a machine can read, that the ladder is not well.
     *
     * Autoloaded on purpose. `time` is when this code and step were first seen,
     * `updated` the last. A changed detail alone is throttled to once per
     * CONDITION_REFRESH_AFTER; a changed code or step never is.
     *
     * @param string $code    Machine-readable condition.
     * @param int    $version Step the condition belongs to.
     * @param string $detail  Free text for a human reading the record.
     * @return void
     */
    protected static function record_condition($code, $version, $detail)
    {
        $existing = get_option(static::condition_option_name());
        $now      = time();

        $same_fault = (is_array($existing)
            && isset($existing['code'], $existing['step'])
            && $code === $existing['code']
            && (int) $version === (int) $existing['step']);

        $first_seen = $now;

        if ($same_fault) {
            if (isset($existing['detail']) && (string) $detail === (string) $existing['detail']) {
                return;
            }

            if (isset($existing['updated'])) {
                $last_write = (int) $existing['updated'];
            } elseif (isset($existing['time'])) {
                $last_write = (int) $existing['time'];
            } else {
                $last_write = 0;
            }

            // $last_write <= $now so a record stamped in the future refreshes at
            // once instead of being throttled until the clock catches up.
            if ($last_write <= $now && ($now - $last_write) < self::CONDITION_REFRESH_AFTER) {
                return;
            }

            if (isset($existing['time'])) {
                $first_seen = (int) $existing['time'];
            }
        }

        update_option(
            static::condition_option_name(),
            array(
                'code'      => (string) $code,
                'step'      => (int) $version,
                'time'      => $first_seen,
                'updated'   => $now,
                'target'    => static::target_version(),
                'installed' => static::installed_version(),
                'detail'    => (string) $detail,
            ),
            true
        );
    }

    /**
     * Clear the record, because the thing it describes is over.
     *
     * @return void
     */
    protected static function clear_condition()
    {
        delete_option(static::condition_option_name());
    }

    /**
     * Clear the record on the request where there was nothing to do.
     *
     * See condition_survives_healthy_request().
     *
     * @return void
     */
    protected static function clear_condition_if_present()
    {
        $autoloaded = wp_load_alloptions();
        $name       = static::condition_option_name();

        if (!is_array($autoloaded) || !isset($autoloaded[$name])) {
            return;
        }

        $existing = maybe_unserialize($autoloaded[$name]);
        $code     = (is_array($existing) && isset($existing['code'])) ? (string) $existing['code'] : '';

        if (static::condition_survives_healthy_request($code)) {
            return;
        }

        static::clear_condition();
    }

    /**
     * Whether an ordinary healthy request leaves this record alone.
     *
     * @param string $code Condition code.
     * @return bool
     */
    protected static function condition_survives_healthy_request($code)
    {
        // Schema records only. Every consumer treats "the schema is fine" as
        // disproving the record, so nothing about the filesystem belongs here.
        return in_array(
            $code,
            array('baseline_replay_incomplete', 'baseline_replay_failed'),
            true
        );
    }
}
