<?php
/**
 * WPDB target lock manager.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Lock;

// phpcs:disable WordPress.DB -- Scheduler-owned custom tables require direct wpdb queries.
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.

use ScheduledChanges\Database\TableNames;

/**
 * Acquires expiring target locks atomically through the plugin lock table.
 */
final class WpdbLockManager implements LockManager {
	private \wpdb $wpdb;

	private string $table;

	public function __construct( ?\wpdb $database = null, ?TableNames $tables = null ) {
		$database = \ScheduledChanges\Support\WpdbConnection::resolve( $database );

		$this->wpdb  = $database;
		$this->table = ( $tables ?? new TableNames( $database ) )->get( 'locks' );
	}

	public function acquire( array $target_references, string $owner_token, int $ttl_seconds ): void {
		$references = $this->references( $target_references );
		$acquired   = array();

		try {
			foreach ( $references as $reference ) {
				$this->acquireOne( $reference, $owner_token, $ttl_seconds );
				$acquired[] = $reference;
			}
		} catch ( \Throwable $throwable ) {
			$this->release( $acquired, $owner_token );
			throw $throwable;
		}
	}

	public function renew( array $target_references, string $owner_token, int $ttl_seconds ): void {
		if ( $ttl_seconds <= 0 ) {
			throw new \InvalidArgumentException( 'Lock TTL must be positive.' );
		}

		$now     = gmdate( 'Y-m-d H:i:s' );
		$expires = gmdate( 'Y-m-d H:i:s', time() + $ttl_seconds );

		foreach ( $this->references( $target_references ) as $reference ) {
			$updated = $this->wpdb->update(
				$this->table,
				array(
					'renewed_at_utc' => $now,
					'expires_at_utc' => $expires,
				),
				array(
					'lock_key'    => $this->key( $reference ),
					'owner_token' => $owner_token,
				),
				array( '%s', '%s' ),
				array( '%s', '%s' )
			);

			if ( false === $updated ) {
				throw new \RuntimeException( 'Unable to renew target lock: ' . $this->wpdb->last_error );
			}

			$owner = $this->wpdb->get_var(
				$this->wpdb->prepare(
					"SELECT owner_token FROM {$this->table} WHERE lock_key = %s LIMIT 1",
					$this->key( $reference )
				)
			);

			if ( $owner !== $owner_token ) {
				throw new LockContentionException( $reference );
			}
		}
	}

	public function release( array $target_references, string $owner_token ): void {
		foreach ( $this->references( $target_references ) as $reference ) {
			$deleted = $this->wpdb->delete(
				$this->table,
				array(
					'lock_key'    => $this->key( $reference ),
					'owner_token' => $owner_token,
				),
				array( '%s', '%s' )
			);

			if ( false === $deleted ) {
				throw new \RuntimeException( 'Unable to release target lock: ' . $this->wpdb->last_error );
			}
		}
	}

	public function staleCount(): int {
		$sql = $this->wpdb->prepare(
			"SELECT COUNT(*) FROM {$this->table} WHERE expires_at_utc < %s",
			gmdate( 'Y-m-d H:i:s' )
		);

		return (int) $this->wpdb->get_var( $sql );
	}

	private function acquireOne( string $reference, string $owner_token, int $ttl_seconds ): void {
		if ( '' === trim( $owner_token ) || $ttl_seconds <= 0 ) {
			throw new \InvalidArgumentException( 'Lock owner and positive TTL are required.' );
		}

		$key     = $this->key( $reference );
		$now     = gmdate( 'Y-m-d H:i:s' );
		$expires = gmdate( 'Y-m-d H:i:s', time() + $ttl_seconds );
		$sql     = $this->wpdb->prepare(
			"INSERT INTO {$this->table}
				(lock_key, owner_token, target_reference, acquired_at_utc, renewed_at_utc, expires_at_utc)
			VALUES (%s, %s, %s, %s, %s, %s)
			ON DUPLICATE KEY UPDATE
				owner_token = IF(expires_at_utc <= %s OR owner_token = %s, %s, owner_token),
				target_reference = IF(expires_at_utc <= %s OR owner_token = %s, %s, target_reference),
				acquired_at_utc = IF(expires_at_utc <= %s, %s, acquired_at_utc),
				renewed_at_utc = IF(expires_at_utc <= %s OR owner_token = %s, %s, renewed_at_utc),
				expires_at_utc = IF(expires_at_utc <= %s OR owner_token = %s, %s, expires_at_utc)",
			$key,
			$owner_token,
			$reference,
			$now,
			$now,
			$expires,
			$now,
			$owner_token,
			$owner_token,
			$now,
			$owner_token,
			$reference,
			$now,
			$now,
			$now,
			$owner_token,
			$now,
			$now,
			$owner_token,
			$expires
		);
		$result  = $this->wpdb->query( $sql );

		if ( false === $result ) {
			throw new \RuntimeException( 'Unable to acquire target lock: ' . $this->wpdb->last_error );
		}

		$owner = $this->wpdb->get_var(
			$this->wpdb->prepare(
				"SELECT owner_token FROM {$this->table} WHERE lock_key = %s LIMIT 1",
				$key
			)
		);

		if ( $owner !== $owner_token ) {
			throw new LockContentionException( $reference );
		}
	}

	/**
	 * @param string[] $references Target references.
	 * @return string[]
	 */
	private function references( array $references ): array {
		$references = array_values(
			array_unique(
				array_filter(
					array_map( 'strval', $references ),
					static fn ( string $reference ): bool => '' !== trim( $reference )
				)
			)
		);
		sort( $references );

		return $references;
	}

	private function key( string $reference ): string {
		return 'target:' . hash( 'sha256', $reference );
	}
}
