<?php

namespace Fotogenic_AI;

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

/**
 * Token-owned database lock for background and state mutations.
 *
 * The options-table unique key provides the atomic claim. Every mutation is
 * token-bound so an expired worker cannot alter a replacement owner's lock.
 */
final class Background_Lock {
    private static ?Background_Lock $instance = null;
    private const LOCK_TTL = 300;
    private const KEY_PREFIX = 'fotogenic_ai_finalize_lock_';

    private function __construct() {}

    public static function instance(): Background_Lock {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Try to acquire a lock and return its ownership token.
     *
     * @param string $key Lock key (for example, a run ID).
     * @param int    $ttl Lock lifetime in seconds.
     * @return string|false Ownership token, or false when another worker holds the lock.
     */
    public function acquire( string $key, int $ttl = self::LOCK_TTL ) {
        $key = $this->sanitize_key( $key );
        if ( '' === $key ) {
            return false;
        }

        return $this->acquire_option( $this->get_option_key( $key ), $ttl );
    }

    /**
     * Acquire a token-owned lock using an explicit plugin option name.
     *
     * This is shared by plugin services that use stable option-backed mutexes.
     *
     * @param string $option_key Plugin-owned option name.
     * @param int    $ttl        Lock lifetime in seconds.
     * @return string|false Ownership token, or false when another worker holds the lock.
     */
    public function acquire_option( string $option_key, int $ttl = self::LOCK_TTL ) {
        $option_key = $this->sanitize_option_key( $option_key );
        if ( '' === $option_key ) {
            return false;
        }

        $ttl = max( 5, min( HOUR_IN_SECONDS, $ttl ) );
        $token = wp_generate_uuid4();
        $lock = [
            'token'      => $token,
            'expires_at' => time() + $ttl,
        ];

        for ( $attempt = 0; $attempt < 3; $attempt++ ) {
            if ( $this->insert_lock( $option_key, $lock ) ) {
                return $token;
            }

            $existing = $this->read_option_direct( $option_key );
            if ( is_array( $existing ) ) {
                $existing_expiry = (int) ( $existing['expires_at'] ?? 0 );
                if ( $existing_expiry > time() ) {
                    return false;
                }
            }
            if ( null === $existing || false === $existing ) {
                continue;
            }
            if ( ! $this->compare_and_delete( $option_key, $existing ) ) {
                continue;
            }
        }

        return false;
    }

    /**
     * Release lock for the given key.
     *
     * @param string $key   Lock key.
     * @param string $token Ownership token returned by acquire().
     * @return bool True only when this owner released the lock.
     */
    public function release( string $key, string $token ): bool {
        $key = $this->sanitize_key( $key );
        if ( '' === $key ) {
            return false;
        }

        return $this->release_option( $this->get_option_key( $key ), $token );
    }

    /**
     * Release an explicitly named lock only when the token still owns it.
     */
    public function release_option( string $option_key, string $token ): bool {
        $option_key = $this->sanitize_option_key( $option_key );
        $token = trim( $token );
        if ( '' === $option_key || '' === $token ) {
            return false;
        }

        $existing = $this->read_option_direct( $option_key );
        if ( ! is_array( $existing ) || ! hash_equals( (string) ( $existing['token'] ?? '' ), $token ) ) {
            return false;
        }

        return $this->compare_and_delete( $option_key, $existing );
    }

    /**
     * Renew a lock lease only while the supplied token still owns it.
     */
    public function renew( string $key, string $token, int $ttl = self::LOCK_TTL ): bool {
        $key = $this->sanitize_key( $key );
        if ( '' === $key ) {
            return false;
        }

        return $this->renew_option( $this->get_option_key( $key ), $token, $ttl );
    }

    /**
     * Renew an explicitly named lock lease.
     */
    public function renew_option( string $option_key, string $token, int $ttl = self::LOCK_TTL ): bool {
        $option_key = $this->sanitize_option_key( $option_key );
        $token = trim( $token );
        if ( '' === $option_key || '' === $token ) {
            return false;
        }

        $existing = $this->read_option_direct( $option_key );
        if ( ! is_array( $existing ) || ! hash_equals( (string) ( $existing['token'] ?? '' ), $token ) ) {
            return false;
        }

        $ttl = max( 5, min( HOUR_IN_SECONDS, $ttl ) );
        $updated = $existing;
        $updated['expires_at'] = max( (int) ( $existing['expires_at'] ?? 0 ) + 1, time() + $ttl );

        return $this->compare_and_update( $option_key, $existing, $updated );
    }

    private function sanitize_key( string $key ): string {
        $key = trim( $key );
        if ( '' === $key ) {
            return '';
        }
        return hash( 'sha256', $key );
    }

    private function get_option_key( string $key ): string {
        return self::KEY_PREFIX . $key;
    }

    private function sanitize_option_key( string $option_key ): string {
        $option_key = trim( $option_key );
        if ( '' === $option_key || strlen( $option_key ) > 191 || 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $option_key ) ) {
            return '';
        }

        return $option_key;
    }

    private function insert_lock( string $option_key, array $lock ): bool {
        global $wpdb;

        $serialized = maybe_serialize( $lock );
        $autoload = function_exists( 'wp_autoload_values_to_autoload' ) ? 'off' : 'no';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- The unique option_name index is the mutex; INSERT IGNORE must report the winning claimant.
        $inserted = $wpdb->query(
            $wpdb->prepare(
                "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
                $option_key,
                $serialized,
                $autoload
            )
        );
        $this->sync_option_cache( $option_key );

        return 1 === (int) $inserted;
    }

    private function read_option_direct( string $option_key ) {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Ownership checks must bypass stale persistent and request-local option caches.
        $serialized = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
                $option_key
            )
        );
        if ( null === $serialized ) {
            return null;
        }

        return maybe_unserialize( $serialized );
    }

    private function compare_and_delete( string $option_key, $expected ): bool {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic ownership check prevents a stale worker from deleting a replacement lock.
        $deleted = $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s",
                $option_key,
                maybe_serialize( $expected )
            )
        );
        $this->sync_option_cache( $option_key );

        return 1 === (int) $deleted;
    }

    private function compare_and_update( string $option_key, $expected, $updated ): bool {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Token-bound compare-and-update prevents an expired owner from renewing a replacement lock.
        $changed = $wpdb->query(
            $wpdb->prepare(
                "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s",
                maybe_serialize( $updated ),
                $option_key,
                maybe_serialize( $expected )
            )
        );
        $this->sync_option_cache( $option_key );

        return 1 === (int) $changed;
    }

    private function sync_option_cache( string $option_key ): void {
        wp_cache_delete( $option_key, 'options' );
        wp_cache_delete( 'notoptions', 'options' );
    }
}
