<?php
/**
 * Recoverable scan lock.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Scan;

/**
 * Prevents concurrent scans and permits recovery after expiration.
 */
final class ScanLock {
	/** @var LockStore */
	private $store;
	/** @var callable */
	private $clock;

	/**
	 * @param LockStore     $store Lock storage.
	 * @param callable|null $clock Unix time reader.
	 */
	public function __construct( LockStore $store, $clock = null ) {
		$this->store = $store;
		$this->clock = null !== $clock ? $clock : 'time';
	}

	/**
	 * @param string $owner       Unique owner.
	 * @param int    $ttl_seconds Lock lifetime.
	 * @return bool
	 */
	public function acquire( $owner, $ttl_seconds = 900 ) {
		$now     = (int) call_user_func( $this->clock );
		$stored  = $this->store->get();
		$current = $this->decode( $stored );
		if ( $current && $current['expires'] > $now ) {
			return hash_equals( $current['owner'], $owner );
		}
		if ( $current || null !== $stored ) {
			$this->store->delete();
		}
		return $this->store->add( $this->encode( $owner, $now + max( 30, $ttl_seconds ) ) );
	}

	/**
	 * @param string $owner       Owner.
	 * @param int    $ttl_seconds New lifetime.
	 * @return bool
	 */
	public function heartbeat( $owner, $ttl_seconds = 900 ) {
		$current = $this->decode( $this->store->get() );
		if ( ! $current || ! hash_equals( $current['owner'], $owner ) ) {
			return false;
		}
		$expires = (int) call_user_func( $this->clock ) + max( 30, $ttl_seconds );
		if ( $this->store->update( $this->encode( $owner, $expires ) ) ) {
			return true;
		}
		$confirmed = $this->decode( $this->store->get() );
		return $confirmed && hash_equals( $confirmed['owner'], $owner ) && $confirmed['expires'] >= $expires;
	}

	/**
	 * @param string $owner Owner.
	 * @return bool
	 */
	public function release( $owner ) {
		$current = $this->decode( $this->store->get() );
		if ( ! $current || ! hash_equals( $current['owner'], $owner ) ) {
			return false;
		}
		return $this->store->delete();
	}

	/**
	 * Release the currently observed owner while preserving a replacement lock.
	 *
	 * @return bool
	 */
	public function release_current() {
		$current = $this->decode( $this->store->get() );
		return $current ? $this->release( $current['owner'] ) : false;
	}

	/** @return string */
	private function encode( string $owner, int $expires ): string {
		// This domain service intentionally remains independent of WordPress functions.
		return (string) json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
			array(
				'owner'   => $owner,
				'expires' => (int) $expires,
			)
		);
	}

	/** @return array{owner:string,expires:int}|null */
	private function decode( ?string $value ): ?array {
		if ( ! is_string( $value ) || '' === $value ) {
			return null;
		}
		$data = json_decode( $value, true );
		if ( ! is_array( $data ) || ! isset( $data['owner'], $data['expires'] ) || ! is_string( $data['owner'] ) ) {
			return null;
		}
		return array(
			'owner'   => $data['owner'],
			'expires' => (int) $data['expires'],
		);
	}
}
