<?php
/**
 * Durable local ledger for publication idempotency and recovery.
 *
 * @package Contentosapp_Studio
 * @since   1.0.1
 */

defined( 'ABSPATH' ) || exit;

class Costudio_Publication_Busy_Exception extends RuntimeException {}

class Costudio_Publication_Ledger {

	const TABLE = 'costudio_publication_attempts';
	private const CLAIM_STALE_SECONDS = 300;

	public static function origin_fingerprint(): string {
		$value = (string) get_option( 'costudio_site_fingerprint', '' );
		if ( '' === $value ) {
			$value = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : md5( home_url() . uniqid( '', true ) );
			update_option( 'costudio_site_fingerprint', $value, false );
		}
		return sanitize_text_field( $value );
	}

	public static function idempotency_key( string $site_key, string $production_uid ): string {
		return hash( 'sha256', self::origin_fingerprint() . '|' . $site_key . '|' . $production_uid );
	}

	public static function is_ready(): bool {
		$cache_key = 'costudio_pub_ledger_schema_ready';
		$cached    = wp_cache_get( $cache_key, 'costudio' );
		if ( false !== $cached ) {
			return 'InnoDB' === (string) $cached;
		}
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;
		// Direct query required: INFORMATION_SCHEMA cannot be accessed via WP abstractions.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$engine = $wpdb->get_var( $wpdb->prepare(
			'SELECT ENGINE FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s',
			$table
		) );
		wp_cache_set( $cache_key, (string) $engine, 'costudio', 60 );
		return 'InnoDB' === (string) $engine;
	}

	/**
	 * @return array{status:string,idempotency_key:string,claim_token:string,row:?array}
	 */
	public static function claim( int $production_id, string $production_uid, string $site_key, array $payload ): array {
		global $wpdb;
		if ( ! self::is_ready() ) {
			return [ 'status' => 'unavailable', 'idempotency_key' => '', 'claim_token' => '', 'row' => null ];
		}
		$table           = $wpdb->prefix . self::TABLE;
		$idempotency_key = self::idempotency_key( $site_key, $production_uid );
		$payload_json    = wp_json_encode( $payload );
		$payload_hash    = hash( 'sha256', (string) $payload_json );
		$claim_token     = wp_generate_password( 32, false, false );
		$now             = current_time( 'mysql', true );

		// Direct query required: INSERT IGNORE with custom table cannot use WP_Query or wpdb->insert.
		// $table is always $wpdb->prefix . self::TABLE — a plugin-controlled constant, no user input.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$inserted = $wpdb->query(
			$wpdb->prepare(
				'INSERT IGNORE INTO ' . $table . ' (idempotency_key,production_id,production_uid,site_key,origin_hash,payload_hash,payload_json,state,attempts,claim_token,claimed_at,created_at,updated_at) VALUES (%s,%d,%s,%s,%s,%s,%s,\'preparing\',1,%s,%s,%s,%s)', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$idempotency_key,
				$production_id,
				$production_uid,
				$site_key,
				hash( 'sha256', self::origin_fingerprint() ),
				$payload_hash,
				$payload_json,
				$claim_token,
				$now,
				$now,
				$now
			)
		);
		if ( 1 === (int) $inserted ) {
			self::log( 'publication.claimed', $production_id, [ 'idempotency_key' => $idempotency_key, 'attempt' => 1 ] );
			return [ 'status' => 'acquired', 'idempotency_key' => $idempotency_key, 'claim_token' => $claim_token, 'row' => self::find( $production_id ) ];
		}

		$row = self::find( $production_id );
		if ( ! $row || ! hash_equals( $idempotency_key, (string) $row['idempotency_key'] ) ) {
			return [ 'status' => 'conflict', 'idempotency_key' => $idempotency_key, 'claim_token' => '', 'row' => $row ];
		}
		if ( ! empty( $row['post_id'] ) && ! hash_equals( (string) $row['payload_hash'], $payload_hash ) ) {
			return [ 'status' => 'conflict', 'idempotency_key' => $idempotency_key, 'claim_token' => '', 'row' => $row ];
		}
		if ( 'succeeded' === $row['state'] ) {
			self::log( 'publication.replayed', $production_id, [ 'idempotency_key' => $idempotency_key, 'post_id' => (int) $row['post_id'] ] );
			return [ 'status' => 'existing', 'idempotency_key' => $idempotency_key, 'claim_token' => '', 'row' => $row ];
		}

		$claimed_at = ! empty( $row['claimed_at'] ) ? strtotime( (string) $row['claimed_at'] . ' UTC' ) : 0;
		if ( '' !== (string) $row['claim_token'] && $claimed_at > ( time() - self::CLAIM_STALE_SECONDS ) ) {
			return [ 'status' => 'busy', 'idempotency_key' => $idempotency_key, 'claim_token' => '', 'row' => $row ];
		}

		// Direct query required: conditional UPDATE with expressions (IF, IS NULL) cannot use wpdb->update.
		// $table is always $wpdb->prefix . self::TABLE — a plugin-controlled constant, no user input.
		// Cache invalidated below via wp_cache_delete.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE " . $table . " SET claim_token=%s, claimed_at=%s, attempts=attempts+1, payload_hash=%s, payload_json=%s, state=IF(post_id IS NULL OR post_id=0,'preparing','created'), error_message=NULL, updated_at=%s WHERE id=%d AND (claim_token=%s OR claimed_at IS NULL OR claimed_at < %s)", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$claim_token,
				$now,
				$payload_hash,
				$payload_json,
				$now,
				(int) $row['id'],
				(string) $row['claim_token'],
				gmdate( 'Y-m-d H:i:s', time() - self::CLAIM_STALE_SECONDS )
			)
		);
		if ( 1 === (int) $updated ) {
			wp_cache_delete( 'costudio_pub_ledger_' . $production_id, 'costudio' );
		}

		return [
			'status'          => 1 === (int) $updated ? 'acquired' : 'busy',
			'idempotency_key' => $idempotency_key,
			'claim_token'     => 1 === (int) $updated ? $claim_token : '',
			'row'             => self::find( $production_id ),
		];
	}

	public static function mark_creating( int $production_id, string $claim_token ): bool {
		return self::update_claimed( $production_id, $claim_token, [ 'state' => 'creating' ] );
	}

	public static function store_prepared_payload( int $production_id, string $claim_token, array $payload ): bool {
		return self::update_claimed( $production_id, $claim_token, [ 'payload_json' => wp_json_encode( $payload ) ] );
	}

	public static function checkpoint( int $production_id, string $claim_token, int $post_id, string $post_url, string $edit_url ): bool {
		$ok = self::update_claimed( $production_id, $claim_token, [
			'state'      => 'created',
			'post_id'    => $post_id,
			'post_url'   => esc_url_raw( $post_url ),
			'edit_url'   => esc_url_raw( $edit_url ),
			'created_post_at' => current_time( 'mysql', true ),
		] );
		if ( $ok ) {
			Costudio_Production::checkpoint_publication( $production_id, $post_id, $post_url, $edit_url );
			self::log( 'publication.checkpointed', $production_id, [ 'post_id' => $post_id ] );
		}
		return $ok;
	}

	public static function mark_finalizing( int $production_id, string $claim_token ): bool {
		return self::update_claimed( $production_id, $claim_token, [ 'state' => 'finalizing' ] );
	}

	public static function succeed( int $production_id, string $claim_token ): bool {
		$now = current_time( 'mysql', true );
		$ok = self::update_claimed( $production_id, $claim_token, [
			'state'        => 'succeeded',
			'job_status'   => 'completed',
			'stage'        => 'completed',
			'finished_at'  => $now,
			'completed_at' => $now,
			'heartbeat_at' => $now,
			'terminal_source' => 'publisher',
			'error_code'    => null,
			'error_message' => null,
			'worker_id'    => null,
			'lease_expires_at' => null,
			'claim_token'  => null,
			'claimed_at'   => null,
		] );
		if ( $ok ) {
			self::log( 'publication.succeeded', $production_id, [] );
		}
		return $ok;
	}

	public static function fail( int $production_id, string $claim_token, string $error ): bool {
		$now = current_time( 'mysql', true );
		$ok = self::update_claimed( $production_id, $claim_token, [
			'state'         => 'failed',
			'job_status'    => 'failed',
			'stage'         => 'failed',
			'finished_at'   => $now,
			'heartbeat_at'  => $now,
			'terminal_source' => 'publisher',
			'error_code'     => 'publisher_error',
			'worker_id'     => null,
			'lease_expires_at' => null,
			'error_message' => mb_substr( $error, 0, 2000 ),
			'claim_token'   => null,
			'claimed_at'    => null,
		] );
		if ( $ok ) {
			self::log( 'publication.failed', $production_id, [ 'error' => mb_substr( $error, 0, 300 ) ] );
		}
		return $ok;
	}

	public static function find( int $production_id ): ?array {
		$cache_key = 'costudio_pub_ledger_' . $production_id;
		$cached    = wp_cache_get( $cache_key, 'costudio' );
		if ( false !== $cached ) {
			return is_array( $cached ) ? $cached : null;
		}
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;
		// Direct query required: custom plugin table — no WP abstraction available.
		// $table is always $wpdb->prefix . self::TABLE — a plugin-controlled constant, no user input.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$row = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT * FROM ' . $table . ' WHERE production_id = %d LIMIT 1', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$production_id
			),
			ARRAY_A
		);
		$result = is_array( $row ) ? $row : null;
		wp_cache_set( $cache_key, $result ?? false, 'costudio', 5 );
		return $result;
	}

	public static function needs_resume( int $production_id ): bool {
		$row = self::find( $production_id );
		return is_array( $row ) && 'succeeded' !== (string) $row['state'];
	}

	private static function update_claimed( int $production_id, string $claim_token, array $data ): bool {
		global $wpdb;
		if ( '' === $claim_token ) {
			return false;
		}
		$data['updated_at'] = current_time( 'mysql', true );
		$where  = [ 'production_id' => $production_id, 'claim_token' => $claim_token ];
		// Direct query via wpdb->update — custom plugin table, no equivalent WP API.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$result = $wpdb->update( $wpdb->prefix . self::TABLE, $data, $where );
		if ( false !== $result ) {
			wp_cache_delete( 'costudio_pub_ledger_' . $production_id, 'costudio' );
		}
		return false !== $result;
	}

	private static function log( string $message, int $production_id, array $context ): void {
		if ( function_exists( 'costudio_log_debug' ) ) {
			$context['production_id'] = $production_id;
			costudio_log_debug( $message, $context );
		}
	}
}
