<?php
/**
 * Cross-request lock.
 *
 * @package Website_LLMS_TXT
 */

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

/**
 * A mutual exclusion lock decided by the database, not by PHP.
 * See docs/internals-core-and-lock.md.
 */
class LLMS_Lock
{
    /**
     * Seconds after which a held lock is treated as abandoned.
     */
    const STALE_AFTER = 300;

    /**
     * Longest lock name we will accept.
     */
    const MAX_NAME_LENGTH = 191;

    /**
     * Take the lock.
     *
     * @param string $lock_name   Option name to claim. Must be unique to the job.
     * @param int    $stale_after Seconds before an existing lock may be stolen.
     * @return string|false The token to release with, or false if someone else holds it.
     */
    public static function acquire($lock_name, $stale_after = self::STALE_AFTER)
    {
        global $wpdb;

        if (!is_string($lock_name) || '' === $lock_name || strlen($lock_name) > self::MAX_NAME_LENGTH) {
            return false;
        }

        for ($attempt = 0; $attempt < 2; $attempt++) {
            $token = static::make_token();

            // Ownership is settled by the unique index on option_name, not in PHP.
            $wpdb->query($wpdb->prepare(
                "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
                $lock_name,
                $token
            ));
            $claimed = (1 === $wpdb->rows_affected);

            static::forget_cached_option($lock_name, true);

            if ($claimed) {
                return $token;
            }

            $held = static::held_token($lock_name);
            if (null === $held) {
                continue;
            }

            if (!static::is_stale($held, $stale_after)) {
                return false;
            }

            // Compare and swap, pinned to the exact value we read.
            $wpdb->query($wpdb->prepare(
                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
                $token,
                $lock_name,
                $held
            ));
            $stolen = (1 === $wpdb->rows_affected);

            static::forget_cached_option($lock_name, true);

            if ($stolen) {
                return $token;
            }
        }

        return false;
    }

    /**
     * Whether a token still names the holder of this lock.
     *
     * @param string $lock_name Option name.
     * @param string $token     Token from acquire() or from any refresh() of it.
     * @return bool
     */
    public static function owns($lock_name, $token)
    {
        $held = static::held_token($lock_name);

        return (null !== $held) && static::same_identity($held, $token);
    }

    /**
     * Give the lock up when the row is ours by identity.
     *
     * @param string $lock_name Option name.
     * @param string $token     Token from acquire() or from any refresh() of it.
     * @return bool True if this call released the lock.
     */
    public static function release_identity($lock_name, $token)
    {
        $held = static::held_token($lock_name);

        if (null === $held || !static::same_identity($held, $token)) {
            return false;
        }

        return static::release($lock_name, $held);
    }

    /**
     * The part of a token that does not change when the lease is extended.
     *
     * @param string $token Token or stored value.
     * @return string|false False for anything that is not shaped like a token.
     */
    protected static function identity($token)
    {
        if (!is_string($token) || '' === $token) {
            return false;
        }

        $at = strpos($token, ':');
        if (false === $at) {
            return false;
        }

        $stamp = substr($token, 0, $at);
        if ('' === $stamp || !ctype_digit($stamp)) {
            return false;
        }

        $identity = substr($token, $at + 1);

        return ('' === $identity) ? false : $identity;
    }

    /**
     * Whether two token values were issued to the same holder.
     *
     * @param string $a First value.
     * @param string $b Second value.
     * @return bool
     */
    protected static function same_identity($a, $b)
    {
        $ia = static::identity($a);
        $ib = static::identity($b);

        return (false !== $ia) && (false !== $ib) && ($ia === $ib);
    }

    /**
     * Extend the lease without giving up the lock.
     *
     * @param string $lock_name Option name previously acquired.
     * @param string $token     Token returned by acquire().
     * @return string|false The refreshed token, or false if the lock is no longer ours.
     */
    public static function refresh($lock_name, $token)
    {
        global $wpdb;

        if (!is_string($token) || '' === $token) {
            return false;
        }

        $suffix = strpos($token, ':');
        if (false === $suffix) {
            return false;
        }
        $new_token = time() . substr($token, $suffix);

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

        $wpdb->query($wpdb->prepare(
            "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
            $new_token,
            $lock_name,
            $token
        ));
        $refreshed = (1 === $wpdb->rows_affected);

        static::forget_cached_option($lock_name, true);

        return $refreshed ? $new_token : false;
    }

    /**
     * Give the lock up.
     *
     * @param string $lock_name Option name previously acquired.
     * @param string $token     Token returned by acquire().
     * @return bool True if this call released the lock.
     */
    public static function release($lock_name, $token)
    {
        global $wpdb;

        if (!is_string($token) || '' === $token) {
            return false;
        }

        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
            $lock_name,
            $token
        ));
        $released = (1 === $wpdb->rows_affected);

        // Only claim the row is gone when we are the ones who deleted it.
        static::forget_cached_option($lock_name, !$released);

        return $released;
    }

    /**
     * The token currently stored for a lock, or null when it is free.
     *
     * @param string $lock_name Option name.
     * @return string|null Null only when no row exists.
     */
    public static function held_token($lock_name)
    {
        global $wpdb;

        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
            $lock_name
        ));

        return (null === $row) ? null : (string) $row->option_value;
    }

    /**
     * Drop every cached answer about this option.
     *
     * @param string $lock_name Option name.
     * @param bool   $exists    Whether a row is known to exist after the write.
     * @return void
     */
    protected static function forget_cached_option($lock_name, $exists)
    {
        wp_cache_delete($lock_name, 'options');

        $notoptions = wp_cache_get('notoptions', 'options');
        if (!is_array($notoptions)) {
            $notoptions = array();
        }

        if ($exists) {
            unset($notoptions[$lock_name]);
        } else {
            $notoptions[$lock_name] = true;
        }

        wp_cache_set('notoptions', $notoptions, 'options');
    }

    /**
     * Build a token: the time it was issued, then something unique to this call.
     *
     * @return string
     */
    protected static function make_token()
    {
        return time() . ':' . wp_generate_uuid4();
    }

    /**
     * Whether a stored token is old enough to take.
     *
     * @param string $token       Value read from the options table.
     * @param int    $stale_after Seconds.
     * @return bool
     */
    protected static function is_stale($token, $stale_after)
    {
        $parts = explode(':', $token, 2);

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

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

        if ($stamped > (time() + $stale_after)) {
            return true;
        }

        return (time() - $stamped) > $stale_after;
    }
}
