<?php
/**
 * Per-profile operation idempotency lock for StoreIndex REST mutations.
 *
 * @package AIPT\Api\StoreIndex
 * @since 2.6.1
 */

namespace AIPT\Api\StoreIndex;

/**
 * OperationLock
 *
 * Short-lived transient mutex that prevents /build, /cancel, and /reset on
 * the same profile from racing each other (e.g. user double-clicks "Build
 * Now" or two browser tabs hit /reset simultaneously).
 *
 * This is INTENTIONALLY distinct from {@see IndexBuilder::acquireBatchLock()}:
 *
 * | Concern                | OperationLock                   | IndexBuilder batch lock         |
 * |------------------------|----------------------------------|---------------------------------|
 * | Key                    | profile_id + op label            | job_id (Action Scheduler hop)   |
 * | TTL                    | 30 s (one user round-trip)       | 5 min (one batch execution)     |
 * | Primitive              | Transient only                   | MySQL GET_LOCK + transient      |
 * | Scope                  | REST handler entry/exit          | Inside processBatch()           |
 * | Failure mode           | Return 409 to caller             | Return false, drop batch        |
 *
 * Reusing the heavier batch lock here would couple unrelated lifecycles and
 * conflate two different sets of failure semantics.
 */
final class OperationLock {

	/**
	 * Lock lifetime (seconds). Long enough to absorb one REST round-trip plus
	 * the time IndexBuilder needs to schedule the next AS hop, short enough
	 * that an aborted request doesn't permanently block the profile.
	 */
	public const TTL_SECONDS = 30;

	/**
	 * Profile this instance currently holds the lock for, or null when not
	 * held. Used to scope release() to whatever this instance acquired.
	 *
	 * @var int|null
	 */
	private $held_profile_id = null;

	/**
	 * Operation label this instance currently holds (e.g. 'build', 'cancel',
	 * 'reset').
	 *
	 * @var string|null
	 */
	private $held_op = null;

	/**
	 * Cryptographically random token written into the lock payload when this
	 * instance acquires it. release() verifies the on-cache token before
	 * deleting so an expired-then-re-acquired lock owned by ANOTHER instance
	 * is left intact.
	 *
	 * @var string|null
	 */
	private $held_token = null;

	/**
	 * Try to acquire the lock for ($profile_id, $op).
	 *
	 * Returns true when the lock was acquired by this instance OR when this
	 * instance already holds it for the same ($profile_id, $op) tuple
	 * (idempotent self-acquire). Returns false when a DIFFERENT instance —
	 * or this instance for a different tuple — already holds the lock.
	 *
	 * @param int    $profile_id Target profile.
	 * @param string $op         Operation label.
	 * @return bool True when the lock is now held by this instance.
	 */
	public function acquire( int $profile_id, string $op ): bool {

		if (
			$this->held_profile_id === $profile_id
			&& $this->held_op === $op
		) {
			return true;
		}

		$key     = self::keyFor( $profile_id );
		$token   = bin2hex( random_bytes( 8 ) );
		$payload = array(
			'op'         => $op,
			'ts'         => time(),
			'token'      => $token,
			'expires_at' => time() + self::TTL_SECONDS,
		);

		if ( ! \add_option( $key, $payload, '', 'no' ) ) {

			$existing = \get_option( $key );
			$expired  = is_array( $existing )
				&& isset( $existing['expires_at'] )
				&& (int) $existing['expires_at'] < time();
			if ( ! $expired ) {
				return false;
			}

			\delete_option( $key );
			if ( ! \add_option( $key, $payload, '', 'no' ) ) {
				return false;
			}
		}

		$this->held_profile_id = $profile_id;
		$this->held_op         = $op;
		$this->held_token      = $token;
		return true;
	}

	/**
	 * Release the lock if this instance currently holds it.
	 *
	 * Safe to call when the instance does not hold a lock (no-op). Does NOT
	 * release a lock held by a DIFFERENT instance — protects against a
	 * later handler accidentally clearing an earlier handler's mutex.
	 *
	 * @return void
	 */
	public function release(): void {
		if ( $this->held_profile_id === null ) {
			return;
		}

		$key     = self::keyFor( $this->held_profile_id );
		$current = \get_option( $key );
		if ( is_array( $current ) && ( $current['token'] ?? null ) === $this->held_token ) {
			\delete_option( $key );
		}

		$this->held_profile_id = null;
		$this->held_op         = null;
		$this->held_token      = null;
	}

	/**
	 * Auto-release on instance destruction so a thrown exception inside a
	 * REST handler can't leave the lock held until the transient expires.
	 */
	public function __destruct() {
		$this->release();
	}

	/**
	 * Is any operation currently locked on the given profile?
	 *
	 * @param int $profile_id Target profile.
	 * @return bool
	 */
	public static function isHeld( int $profile_id ): bool {
		$value = \get_option( self::keyFor( $profile_id ) );
		if ( ! is_array( $value ) || ! isset( $value['op'] ) ) {
			return false;
		}

		if ( isset( $value['expires_at'] ) && (int) $value['expires_at'] < time() ) {
			return false;
		}
		return true;
	}

	/**
	 * Which operation is currently locked on the given profile, if any.
	 *
	 * @param int $profile_id Target profile.
	 * @return string|null Operation label or null when not held.
	 */
	public static function currentOp( int $profile_id ): ?string {
		$value = \get_option( self::keyFor( $profile_id ) );
		if ( ! is_array( $value ) || ! isset( $value['op'] ) ) {
			return null;
		}
		if ( isset( $value['expires_at'] ) && (int) $value['expires_at'] < time() ) {
			return null;
		}
		return (string) $value['op'];
	}

	/**
	 * Compose the transient key for a given profile.
	 *
	 * @param int $profile_id Target profile.
	 * @return string Transient key.
	 */
	private static function keyFor( int $profile_id ): string {
		return 'aipt_storeindex_op_lock_' . $profile_id;
	}
}
