<?php
namespace AutoPublishMissedSchedule;

use WP_PowerSuite\Core\AbstractModule;

class Module extends AbstractModule {

	private const MODULE_ID = 'auto-publish-missed-schedule';

	public const CRON_HOOK = 'wpps_auto_publish_missed_schedule';

	private const CRON_GUARD_KEY = 'wpps_missed_schedule_cron_guard';

	private const ADMIN_THROTTLE_KEY = 'wpps_missed_schedule_last_check';

	private const RUN_LOCK_KEY = 'wpps_missed_schedule_running';

	private const ADMIN_THROTTLE_SECONDS = 300;

	private const RUN_LOCK_SECONDS = 60;

	private const PUBLISH_PER_RUN = 10;

	private const FETCH_LIMIT = 25;

	/** @var list<string> */
	private const EXCLUDE_POST_TYPES = [
		'attachment',
		'revision',
		'nav_menu_item',
		'custom_css',
		'customize_changeset',
		'oembed_cache',
		'user_request',
		'wp_block',
		'wp_template',
		'wp_template_part',
		'wp_navigation',
	];

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Auto-Publish Posts with Missed Schedule', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return '1.3.1';
	}

	public function getDescription(): string {
		return __( 'Publishes overdue scheduled posts on an hourly WP-Cron job, with a throttled wp-admin backup when cron is unavailable.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'cron' ];
	}

	protected function registerHooks(): void {
		add_action( self::CRON_HOOK, [ $this, 'onCron' ] );
		$this->maybeScheduleCron();

		// Low-frequency backup while staff are in wp-admin (e.g. when system cron is misconfigured).
		add_action( 'admin_init', [ $this, 'onAdminInit' ], 99 );
	}

	public function onCron(): void {
		$this->runPublish();
	}

	public function onAdminInit(): void {
		if ( $this->isUnsuitableRequestContext() ) {
			return;
		}

		if ( get_transient( self::ADMIN_THROTTLE_KEY ) ) {
			return;
		}

		set_transient( self::ADMIN_THROTTLE_KEY, 1, self::ADMIN_THROTTLE_SECONDS );
		$this->runPublish();
	}

	private function runPublish(): void {
		if ( ! $this->acquireRunLock() ) {
			return;
		}

		try {
			$ids = $this->fetchMissedPostIds();
			if ( $ids === [] ) {
				return;
			}

			$published = 0;
			foreach ( $ids as $post_id ) {
				if ( $published >= self::PUBLISH_PER_RUN ) {
					break;
				}

				if ( get_post_status( $post_id ) !== 'future' ) {
					continue;
				}

				wp_publish_post( $post_id );
				++$published;
			}
		} finally {
			delete_transient( self::RUN_LOCK_KEY );
		}
	}

	/**
	 * Acquire the run lock without the get-then-set race of a plain
	 * get_transient()/set_transient() check: wp_cache_add() only succeeds
	 * when the key is not already present, so concurrent requests can't
	 * both pass the check before either one writes the lock.
	 */
	private function acquireRunLock(): bool {
		if ( wp_using_ext_object_cache() ) {
			return wp_cache_add( self::RUN_LOCK_KEY, 1, 'transient', self::RUN_LOCK_SECONDS );
		}

		if ( get_transient( self::RUN_LOCK_KEY ) ) {
			return false;
		}

		set_transient( self::RUN_LOCK_KEY, 1, self::RUN_LOCK_SECONDS );

		return true;
	}

	private function maybeScheduleCron(): void {
		if ( get_transient( self::CRON_GUARD_KEY ) ) {
			return;
		}

		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'hourly', self::CRON_HOOK );
		}

		set_transient( self::CRON_GUARD_KEY, 1, DAY_IN_SECONDS );
	}

	private function isUnsuitableRequestContext(): bool {
		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
			return true;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return true;
		}
		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return true;
		}
		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return true;
		}

		return false;
	}

	/** @return list<int> */
	private function fetchMissedPostIds(): array {
		global $wpdb;

		$exclude   = self::EXCLUDE_POST_TYPES;
		$in_places = implode( ',', array_fill( 0, count( $exclude ), '%s' ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$col = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				WHERE post_status = 'future'
				AND post_date_gmt <= %s
				AND post_type NOT IN ($in_places)
				ORDER BY post_date_gmt ASC
				LIMIT %d",
				array_merge( [ current_time( 'mysql', true ) ], $exclude, [ self::FETCH_LIMIT ] )
			)
		);

		return array_values( array_filter( array_map( 'absint', (array) $col ) ) );
	}
}
