<?php

namespace Fotogenic_AI;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Scheduler abstraction: Action Scheduler if available, else WP-Cron.
 * Schedules background finalization; idempotent per run_id.
 */
final class Background_Scheduler {
    private static ?Background_Scheduler $instance = null;
    public const HOOK_FINALIZE = 'fotogenic_ai_finalize_run';
    public const HOOK_HOUSEKEEPING = 'fotogenic_ai_background_housekeeping';
    public const GROUP = 'fotogenic_ai';
    private const DEFAULT_DELAY = 10;
    private const HOUSEKEEPING_GATE_TRANSIENT = 'fotogenic_ai_background_housekeeping_gate';
    private const HOUSEKEEPING_INITIAL_DELAY = 10;
    private const HOUSEKEEPING_INTERVAL = 60;
    private const RECOVERY_OPTION = 'fotogenic_ai_finalize_schedule_recovery';
    private const RECOVERY_LOCK_KEY = 'finalize-schedule-recovery';
    private const RECOVERY_BATCH_SIZE = 10;
    private const MAX_RECOVERY_ENTRIES = 500;
    private const RECONCILE_OPTION = 'fotogenic_ai_finalize_reconcile_state';
	private const RECONCILE_LOCK_KEY = 'finalize-run-reconciliation';
	private const RECONCILE_BATCH_SIZE = 20;
	private const RECONCILE_REQUEST_BATCH_SIZE = 1;
	private const RECONCILE_INTERVAL = 60;
    private bool $registered = false;

    private function __construct() {}

    public static function instance(): Background_Scheduler {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Register housekeeping without scanning lifecycle tables during bootstrap.
     */
    public function register(): void {
        if ( $this->registered || ! Background_Runtime_State::is_enabled() ) {
            return;
        }
        $this->registered = true;

        add_action( self::HOOK_HOUSEKEEPING, [ $this, 'handle_housekeeping' ] );
        if ( false !== get_transient( self::HOUSEKEEPING_GATE_TRANSIENT ) ) {
            return;
        }

        if ( $this->schedule_housekeeping( self::HOUSEKEEPING_INITIAL_DELAY ) ) {
            set_transient( self::HOUSEKEEPING_GATE_TRANSIENT, 1, self::HOUSEKEEPING_INTERVAL );
        }
        if ( ! Background_Runtime_State::is_enabled() ) {
            $this->deactivation_cleanup();
        }
    }

    /**
     * Remove scheduler-owned hooks and bootstrap state during plugin deactivation.
     */
    public function deactivation_cleanup(): void {
        remove_action( self::HOOK_HOUSEKEEPING, [ $this, 'handle_housekeeping' ] );
        $this->registered = false;

        delete_transient( self::HOUSEKEEPING_GATE_TRANSIENT );
        wp_unschedule_hook( self::HOOK_FINALIZE );
        wp_unschedule_hook( self::HOOK_HOUSEKEEPING );
		$this->deactivation_action_scheduler_cleanup();
    }

    public function activation_cleanup(): void {
        delete_transient( self::HOUSEKEEPING_GATE_TRANSIENT );
    }

    /**
     * Remove scheduler-owned Action Scheduler work once its store is ready.
     */
    public function deactivation_action_scheduler_cleanup(): void {
        if ( ! $this->has_action_scheduler() || ! function_exists( 'as_unschedule_all_actions' ) ) {
            return;
        }
        as_unschedule_all_actions( self::HOOK_FINALIZE );
        as_unschedule_all_actions( self::HOOK_HOUSEKEEPING );
    }

    /**
     * Run one bounded recovery pass and ensure a successor is scheduled.
     */
    public function handle_housekeeping(): void {
        if ( ! Background_Runtime_State::is_enabled() ) {
            delete_transient( self::HOUSEKEEPING_GATE_TRANSIENT );
            return;
        }
        delete_transient( self::HOUSEKEEPING_GATE_TRANSIENT );
        try {
            $this->recover_failed_schedules();
            $this->reconcile_active_runs();
        } finally {
            if (
                Background_Runtime_State::is_enabled()
                && $this->schedule_housekeeping( self::HOUSEKEEPING_INTERVAL )
            ) {
                set_transient( self::HOUSEKEEPING_GATE_TRANSIENT, 1, self::HOUSEKEEPING_INTERVAL );
            }
            if ( ! Background_Runtime_State::is_enabled() ) {
                $this->deactivation_cleanup();
            }
        }
    }

    /**
     * Whether Action Scheduler is available (e.g. via WooCommerce).
     *
     * @return bool
     */
    public function has_action_scheduler(): bool {
		if ( ! function_exists( 'as_schedule_single_action' ) ) {
			return false;
		}
		if ( class_exists( '\\ActionScheduler' ) && method_exists( '\\ActionScheduler', 'is_initialized' ) ) {
			return (bool) \ActionScheduler::is_initialized();
		}
		return did_action( 'action_scheduler_init' ) > 0;
    }

    private function schedule_housekeeping( int $delay ): bool {
        if ( ! Background_Runtime_State::is_enabled() ) {
            return false;
        }
        $delay = max( 1, min( 300, $delay ) );
        $timestamp = time() + $delay;
        $args = [];

        if ( $this->has_action_scheduler() ) {
            $supports_pending_query = function_exists( 'as_get_scheduled_actions' );
            if ( $supports_pending_query && $this->has_pending_action( self::HOOK_HOUSEKEEPING, $args ) ) {
                return true;
            }

            $action_id = as_schedule_single_action( $timestamp, self::HOOK_HOUSEKEEPING, $args, self::GROUP, ! $supports_pending_query );
            if ( is_numeric( $action_id ) && $action_id > 0 ) {
                return true;
            }
            if ( $supports_pending_query && $this->has_pending_action( self::HOOK_HOUSEKEEPING, $args ) ) {
                return true;
            }
        }

        if ( wp_next_scheduled( self::HOOK_HOUSEKEEPING, $args ) ) {
            return true;
        }
        return false !== wp_schedule_single_event( $timestamp, self::HOOK_HOUSEKEEPING, $args );
    }

    /**
     * Schedule a single finalization run. Idempotent per run_id (unique).
     *
     * @param string $run_id  Run ID.
     * @param int    $user_id User ID (ownership).
     * @param int    $delay   Delay in seconds until first run (default 10).
     * @return bool True if scheduled or already pending, false on failure.
     */
    public function schedule_finalize( string $run_id, int $user_id, int $delay = self::DEFAULT_DELAY ): bool {
        $run_id = trim( $run_id );
        if ( '' === $run_id || $user_id <= 0 || ! Background_Runtime_State::is_enabled() ) {
            return false;
        }
        $delay = max( 1, min( 300, $delay ) );
        $timestamp = time() + $delay;
        $args = [ 'run_id' => $run_id, 'user_id' => $user_id ];

        if ( $this->has_action_scheduler() ) {
            $supports_pending_query = function_exists( 'as_get_scheduled_actions' );
            if ( $supports_pending_query && $this->has_pending_action( self::HOOK_FINALIZE, $args ) ) {
                $this->clear_recovery( $run_id );
                return $this->confirm_finalize_schedule( $run_id, $user_id );
            }

            // AS uniqueness includes the action currently running, so it cannot create its successor.
            $action_id = as_schedule_single_action( $timestamp, self::HOOK_FINALIZE, $args, self::GROUP, ! $supports_pending_query );
            if ( is_numeric( $action_id ) && $action_id > 0 ) {
                $this->clear_recovery( $run_id );
                return $this->confirm_finalize_schedule( $run_id, $user_id );
            }
            if ( $supports_pending_query && $this->has_pending_action( self::HOOK_FINALIZE, $args ) ) {
                $this->clear_recovery( $run_id );
                return $this->confirm_finalize_schedule( $run_id, $user_id );
            }
        }

        $scheduled = $this->schedule_wp_cron( $run_id, $user_id, $timestamp );
        if ( $scheduled ) {
            $this->clear_recovery( $run_id );
            return $this->confirm_finalize_schedule( $run_id, $user_id );
        }
        if ( ! Background_Runtime_State::is_enabled() ) {
            return false;
        }

        $this->record_schedule_failure( $run_id, $user_id, $delay );
        Logger::instance()->error( 'finalize_schedule_failed', [
            'run_id'  => $run_id,
            'user_id' => $user_id,
        ] );
        return false;
    }

    /**
     * Unschedule next finalization for this run_id (e.g. when run is finalized).
     *
     * @param string $run_id  Run ID.
     * @param int    $user_id User ID (must match scheduled args).
     * @return void
     */
    public function unschedule_finalize( string $run_id, int $user_id ): void {
        $run_id = trim( $run_id );
        if ( '' === $run_id ) {
            return;
        }
        $args = [ 'run_id' => $run_id, 'user_id' => $user_id ];
        $this->clear_recovery( $run_id );

        if ( $this->has_action_scheduler() && function_exists( 'as_unschedule_action' ) ) {
            as_unschedule_action( self::HOOK_FINALIZE, $args, self::GROUP );
        }

        wp_clear_scheduled_hook( self::HOOK_FINALIZE, [ $run_id, $user_id ] );
    }

    /**
     * Retry schedule requests that could not be persisted previously.
     */
    public function recover_failed_schedules(): void {
        if ( ! Background_Runtime_State::is_enabled() ) {
            return;
        }
        $queue = $this->read_option_direct( self::RECOVERY_OPTION );
        $queue = is_array( $queue ) ? $queue : [];

        $processed = 0;
        foreach ( $queue as $entry ) {
            if ( $processed >= self::RECOVERY_BATCH_SIZE ) {
                break;
            }
            if ( ! is_array( $entry ) || (int) ( $entry['next_attempt_at'] ?? 0 ) > time() ) {
                continue;
            }

            $run_id = trim( (string) ( $entry['run_id'] ?? '' ) );
            $user_id = (int) ( $entry['user_id'] ?? 0 );
            if ( '' === $run_id || $user_id <= 0 || ! $this->run_needs_finalization( $run_id, $user_id ) ) {
                $this->clear_recovery( $run_id );
                continue;
            }

            $processed++;
            $this->schedule_finalize( $run_id, $user_id, 5 );
        }
    }

    private function has_pending_action( string $hook, array $args ): bool {
        if ( ! function_exists( 'as_get_scheduled_actions' ) ) {
            return false;
        }

        $action_ids = as_get_scheduled_actions(
            [
                'hook'     => $hook,
                'args'     => $args,
                'group'    => self::GROUP,
                'status'   => 'pending',
                'per_page' => 1,
            ],
            'ids'
        );
        return ! empty( $action_ids );
    }

    private function schedule_wp_cron( string $run_id, int $user_id, int $timestamp ): bool {
        $cron_args = [ $run_id, $user_id ];
        if ( wp_next_scheduled( self::HOOK_FINALIZE, $cron_args ) ) {
            return true;
        }
        return false !== wp_schedule_single_event( $timestamp, self::HOOK_FINALIZE, $cron_args );
    }

    private function confirm_finalize_schedule( string $run_id, int $user_id ): bool {
        if ( Background_Runtime_State::is_enabled() ) {
            return true;
        }

        $args = [ 'run_id' => $run_id, 'user_id' => $user_id ];
        if ( $this->has_action_scheduler() && function_exists( 'as_unschedule_action' ) ) {
            as_unschedule_action( self::HOOK_FINALIZE, $args, self::GROUP );
        }
        wp_clear_scheduled_hook( self::HOOK_FINALIZE, [ $run_id, $user_id ] );
        return false;
    }

    private function run_needs_finalization( string $run_id, int $user_id ): bool {
        $row = Logger::instance()->get_run_row( $run_id );
        if ( empty( $row ) || (int) ( $row['user_id'] ?? 0 ) !== $user_id ) {
            return false;
        }
		return $this->run_requires_finalizer_recovery( $row );
    }

	/**
	 * Keep no-job active starts under request reconciliation ownership. The job
	 * finalizer owns accepted jobs and terminal credit-settlement recovery only.
	 */
	private function run_requires_finalizer_recovery( array $row ): bool {
		$status = sanitize_key( (string) ( $row['final_status'] ?? '' ) );
		if ( 'unresolved' === $status ) {
			return $this->row_needs_credit_settlement( $row );
		}
		if ( in_array( $status, [ 'completed', 'partial', 'failed', 'timeout', 'stale', 'stopped' ], true ) ) {
			return $this->row_needs_credit_settlement( $row );
		}
		return $this->run_has_job_ids( $row['job_ids'] ?? [] );
	}

	private function row_needs_credit_settlement( array $row ): bool {
		$settlement = Workflow_Credit_Settlement::normalize_state( $row['credit_settlement_state'] ?? null );
		return [] !== $settlement && in_array( (string) ( $settlement['status'] ?? '' ), [ 'pending', 'settling' ], true );
	}

    private function record_schedule_failure( string $run_id, int $user_id, int $delay ): void {
        if ( '' === $run_id || $user_id <= 0 ) {
            return;
        }
        $queue_saturated = false;
        $this->mutate_recovery_queue(
            static function ( array $queue ) use ( $run_id, $user_id, $delay, &$queue_saturated ): array {
                $key = hash( 'sha256', $run_id );
                if ( ! isset( $queue[ $key ] ) && count( $queue ) >= self::MAX_RECOVERY_ENTRIES ) {
                    $queue_saturated = true;
                    return $queue;
                }
                $previous = is_array( $queue[ $key ] ?? null ) ? $queue[ $key ] : [];
                $attempts = max( 0, (int) ( $previous['attempts'] ?? 0 ) ) + 1;
                $retry_delay = max( 10, min( 300, max( $delay, $attempts * 15 ) ) );
                $queue[ $key ] = [
                    'run_id'          => $run_id,
                    'user_id'         => $user_id,
                    'attempts'        => $attempts,
                    'next_attempt_at' => time() + $retry_delay,
                    'updated_at'      => time(),
                ];
                return $queue;
            }
        );
        if ( $queue_saturated ) {
            Logger::instance()->error( 'finalize_schedule_recovery_queue_saturated', [
                'run_id' => $run_id,
            ] );
        }
    }

    private function clear_recovery( string $run_id ): void {
        if ( '' === $run_id ) {
            return;
        }
        $this->mutate_recovery_queue(
            static function ( array $queue ) use ( $run_id ): array {
                unset( $queue[ hash( 'sha256', $run_id ) ] );
                return $queue;
            }
        );
    }

    private function mutate_recovery_queue( callable $mutation ): void {
        $lock = Background_Lock::instance();
        $token = false;
        for ( $attempt = 0; $attempt < 20; $attempt++ ) {
            $token = $lock->acquire( self::RECOVERY_LOCK_KEY, 10 );
            if ( false !== $token ) {
                break;
            }
            usleep( 10000 );
        }
        if ( false === $token ) {
            Logger::instance()->error( 'finalize_schedule_recovery_lock_failed' );
            return;
        }

        try {
            $queue = $this->read_option_direct( self::RECOVERY_OPTION );
            $queue = is_array( $queue ) ? $queue : [];
            $queue = $mutation( $queue );
            if ( ! $lock->renew( self::RECOVERY_LOCK_KEY, $token, 10 ) ) {
                Logger::instance()->error( 'finalize_schedule_recovery_lock_lost' );
                return;
            }
            if ( ! $this->persist_option_direct( self::RECOVERY_OPTION, $queue ) ) {
                Logger::instance()->error( 'finalize_schedule_recovery_persist_failed' );
            }
        } finally {
            $lock->release( self::RECOVERY_LOCK_KEY, $token );
        }
    }

    /**
     * Reconcile bounded windows of active runs and durable workflow requests.
     */
    private function reconcile_active_runs(): void {
        $state = $this->read_option_direct( self::RECONCILE_OPTION );
        if ( is_array( $state ) && (int) ( $state['next_at'] ?? 0 ) > time() ) {
            return;
        }

        $lock = Background_Lock::instance();
		$token = $lock->acquire( self::RECONCILE_LOCK_KEY, Workflow_Operational_Limits::reconciliation_transport_envelope_seconds() );
        if ( false === $token ) {
            return;
        }

        try {
            $state = $this->read_option_direct( self::RECONCILE_OPTION );
            $state = is_array( $state ) ? $state : [];
            if ( (int) ( $state['next_at'] ?? 0 ) > time() ) {
                return;
            }

            $run_cursor = max( 0, (int) ( $state['run_cursor'] ?? 0 ) );
            $request_cursor = max( 0, (int) ( $state['request_cursor'] ?? 0 ) );
			$rows = $this->find_recoverable_runs_after( $run_cursor );
			if ( empty( $rows ) && $run_cursor > 0 ) {
				$run_cursor = 0;
				$rows = $this->find_recoverable_runs_after( 0 );
            }

            $last_id = $run_cursor;
            foreach ( $rows as $row ) {
                $last_id = max( $last_id, (int) ( $row['id'] ?? 0 ) );
                $run_id = trim( (string) ( $row['run_id'] ?? '' ) );
                $user_id = (int) ( $row['user_id'] ?? 0 );
                if ( '' === $run_id || $user_id <= 0 ) {
                    continue;
                }
				if ( $this->run_requires_finalizer_recovery( $row ) ) {
                    $this->schedule_finalize( $run_id, $user_id, 5 );
                } else {
                    Workflow_Start_Coordinator::terminalize_stale_run( $run_id );
                }
            }

			if ( ! $lock->renew( self::RECONCILE_LOCK_KEY, $token, Workflow_Operational_Limits::reconciliation_transport_envelope_seconds() ) ) {
				return;
			}
			$request_cursor = Workflow_Start_Coordinator::recover_stale_request_records(
				$request_cursor,
				self::RECONCILE_REQUEST_BATCH_SIZE
			);
			if ( ! $lock->renew( self::RECONCILE_LOCK_KEY, $token, Workflow_Operational_Limits::RECONCILIATION_LOCK_SAFETY_SECONDS ) ) {
                return;
            }
            if ( ! $this->persist_option_direct(
                self::RECONCILE_OPTION,
                [
                    'run_cursor'     => count( $rows ) < self::RECONCILE_BATCH_SIZE ? 0 : $last_id,
                    'request_cursor' => $request_cursor,
                    'next_at'        => time() + self::RECONCILE_INTERVAL,
                ]
            ) ) {
                Logger::instance()->error( 'finalize_schedule_reconcile_state_persist_failed' );
            }
        } finally {
            $lock->release( self::RECONCILE_LOCK_KEY, $token );
        }
    }

	private function find_recoverable_runs_after( int $cursor ): array {
        global $wpdb;

        $table = $wpdb->prefix . 'fotogenic_ai_runs';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned indexed table; bounded housekeeping recovers accepted jobs and stale no-job starts.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
				"SELECT id, run_id, user_id, job_ids, final_status, credit_settlement_state
				FROM %i
				WHERE id > %d
					AND user_id > 0
					AND (
						final_status IS NULL
						OR final_status IN ('started','starting','processing')
						OR credit_settlement_state LIKE %s
						OR credit_settlement_state LIKE %s
					)
				ORDER BY id ASC
				LIMIT %d",
                $table,
                $cursor,
				'%"status":"pending"%',
				'%"status":"settling"%',
                self::RECONCILE_BATCH_SIZE
            ),
            ARRAY_A
        );
        return is_array( $rows ) ? $rows : [];
    }

    private function run_has_job_ids( $job_ids ): bool {
        if ( is_string( $job_ids ) ) {
            $decoded = json_decode( $job_ids, true );
            $job_ids = is_array( $decoded ) ? $decoded : [ $job_ids ];
        }
        if ( ! is_array( $job_ids ) ) {
            return false;
        }
        foreach ( $job_ids as $job_id ) {
            if ( '' !== trim( (string) $job_id ) ) {
                return true;
            }
        }
        return false;
    }

    private function read_option_direct( string $option_key ) {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Recovery state must bypass stale request-local and persistent option caches.
        $serialized = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
                $option_key
            )
        );
        return null === $serialized ? null : maybe_unserialize( $serialized );
    }

    private function persist_option_direct( string $option_key, array $value ): bool {
        global $wpdb;

        if ( empty( $value ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Token-owned recovery lock serializes deletion.
            $deleted = $wpdb->query(
                $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $option_key )
            );
            $this->sync_option_cache( $option_key );
            return false !== $deleted;
        }

        $autoload = function_exists( 'wp_autoload_values_to_autoload' ) ? 'off' : 'no';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Token-owned recovery lock serializes this durable state write.
        $written = $wpdb->query(
            $wpdb->prepare(
                "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s) ON DUPLICATE KEY UPDATE option_value = VALUES(option_value), autoload = VALUES(autoload)",
                $option_key,
                maybe_serialize( $value ),
                $autoload
            )
        );
        $this->sync_option_cache( $option_key );
        return false !== $written;
    }

    private function sync_option_cache( string $option_key ): void {
        wp_cache_delete( $option_key, 'options' );
        wp_cache_delete( 'notoptions', 'options' );
    }
}
