<?php
/**
 * Bounded operation execution engine.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Execution;

use ScheduledChanges\Domain\Launch\FailurePolicy;
use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Domain\Run\FailureClassification;
use ScheduledChanges\Domain\Run\OperationRunStatus;
use ScheduledChanges\Domain\Run\RunStatus;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Lock\LockManager;
use ScheduledChanges\Provider\ExecutionContext;
use ScheduledChanges\Repository\RunRepository;

/**
 * Applies dependency-ordered operations and records every verified result.
 */
final class ExecutionEngine {
	private RunRepository $runs;
	private OperationApplier $applier;
	private LockManager $locks;
	private const BATCH_SIZE = 50;
	private const LOCK_TTL   = 300;

	private ?ExecutionGuard $guard;

	public function __construct(
		RunRepository $runs,
		OperationApplier $applier,
		LockManager $locks,
		?ExecutionGuard $guard = null
	) {
		$this->runs    = $runs;
		$this->applier = $applier;
		$this->locks   = $locks;
		$this->guard   = $guard;
	}

	/**
	 * @throws ExecutionException When the immutable graph cannot be processed.
	 */
	public function execute( ExecutionRequest $request ): ExecutionOutcome {
		$run_id            = $request->runId();
		$version_id        = $request->versionId();
		$graph             = $request->graph();
		$run_uuid          = $request->runUuid();
		$actor_id          = $request->actorId();
		$attempt_number    = $request->attemptNumber();
		$occurrence_at     = $request->occurrenceAt();
		$failure_policy    = $request->failurePolicy();
		$locked_targets    = $request->lockedTargets();
		$lock_token        = $request->lockToken();
		$state             = new ExecutionState( $graph );
		$successful        = 0;
		$skipped           = 0;
		$failed            = 0;
		$failedTargets     = array();
		$retry             = null;
		$operationCodes    = array();
		$mutated           = false;
		$blockingConflict  = false;
		$skippedConflict   = false;
		$conflicts         = array();
		$warningOperations = array();
		$stopExecution     = false;

		if ( null !== $this->guard ) {
			$this->guard->begin( $run_id, $version_id, $graph, $state );
		}

		foreach ( $this->operationBatches( $graph->topologicalOrder() ) as $batch ) {
			$this->locks->renew( $locked_targets, $lock_token, self::LOCK_TTL );

			foreach ( $batch as $operation ) {
				$target = $graph->targetByUuid( $operation->targetUuid()->value() );

				if ( null === $target ) {
					throw new ExecutionException( 'Execution graph operation target is missing.' );
				}

				$started = UtcTimestamp::now();
				$startNs = hrtime( true );

				if (
					$this->runs->hasVerifiedSuccess(
						$version_id,
						$operation->uuid()->value(),
						$occurrence_at
					)
				) {
					$result = OperationExecutionResult::skipped(
						'already_verified',
						'Operation was already verified in a prior execution attempt.'
					);
				} elseif ( isset( $failedTargets[ $target->reference() ] ) ) {
					$result = OperationExecutionResult::skipped(
						'target_failed',
						'A prior operation failed for this target.'
					);
				} else {
					$result = null === $this->guard
						? null
						: $this->guard->beforeOperation( $operation, $target, $state );

					if ( null === $result ) {
						$result = $this->applyAsActor(
							$actor_id,
							$operation,
							$target,
							new ExecutionContext( $run_uuid, $operation->uuid(), false, $actor_id ),
							$state
						);
					}
				}

				$result = $this->enforceVerifiedSuccess( $result );

				$completed     = UtcTimestamp::now();
				$durationMs    = max( 0, (int) round( ( hrtime( true ) - $startNs ) / 1_000_000 ) );
				$resultPayload = $result->toArray();
				$conflict      = $state->conflict( $operation->uuid()->value() );
				if ( array() !== $conflict ) {
					$resultPayload['conflict'] = $conflict;
				}

				if ( $result->changed() ) {
					$mutated = true;
				}

				$conflictDetails = $this->classifyConflict( $result, $conflict );
				if ( array() !== $conflictDetails ) {
					$conflicts[ $operation->uuid()->value() ] = $conflictDetails['details'];
					$skippedConflict                          = $skippedConflict || $conflictDetails['skipped'];
					$blockingConflict                         = $blockingConflict || $conflictDetails['blocking'];
				}

				if ( true === ( $operation->redactionRules()['sensitive'] ?? false ) ) {
					$resultPayload['message'] = '[redacted]';
					$resultPayload['details'] = array( 'redacted' => true );
					unset( $resultPayload['conflict'] );
				}

				$this->runs->recordOperation(
					$run_id,
					$version_id,
					$operation,
					$result->status(),
					$attempt_number,
					$started,
					$completed,
					$resultPayload,
					$result->classification(),
					$result->retryable(),
					$result->verified(),
					$durationMs
				);
				$operationCodes[ $operation->uuid()->value() ] = $result->code();
				if ( $result->hasWarnings() ) {
					$warningOperations[] = $operation->uuid()->value();
				}

				if ( OperationRunStatus::SUCCESSFUL === $result->status() ) {
					++$successful;
					continue;
				}

				if ( OperationRunStatus::SKIPPED === $result->status() ) {
					++$skipped;
					continue;
				}

				++$failed;
				$failedTargets[ $target->reference() ] = true;

				if ( $result->retryable() && null !== $result->classification() ) {
					$retry         = $result->classification();
					$stopExecution = true;
					break;
				}

				if ( FailurePolicy::CONTINUE_TARGETS !== $failure_policy ) {
					$stopExecution = true;
					break;
				}
			}

			if ( $stopExecution ) {
				break;
			}
		}

		if ( null !== $this->guard ) {
			$this->guard->complete( $run_id, $version_id, $graph, $state );
		}
		$status                  = $this->status( $successful, $skipped, $failed, null !== $retry, $blockingConflict, $mutated );
		$lifecycleClassification = $blockingConflict ? 'stale_conflict' : null;
		$summary                 = array(
			'successful_operations' => $successful,
			'skipped_operations'    => $skipped,
			'failed_operations'     => $failed,
			'failed_targets'        => array_keys( $failedTargets ),
			'operation_codes'       => $operationCodes,
			'warning_operations'    => $warningOperations,
			'duplicate_replay'      => 0 === $successful && 0 === $failed && $skipped > 0,
			'semantic_flags'        => array(
				'mutated'                  => $mutated,
				'blocking_conflict'        => $blockingConflict,
				'skipped_conflict'         => $skippedConflict,
				'failures'                 => $failed > 0,
				'warnings'                 => array() !== $warningOperations,
				'operator_intervention'    => $blockingConflict && null === $retry,
				'retry_eligible'           => null !== $retry,
				'lifecycle_classification' => $lifecycleClassification,
			),
		);

		if ( array() !== $conflicts ) {
			$summary['conflicts'] = $conflicts;
		}

		if ( null !== $lifecycleClassification ) {
			$summary['lifecycle_classification'] = $lifecycleClassification;
		}

		return new ExecutionOutcome(
			$status,
			$successful,
			$skipped,
			$failed,
			$retry,
			$summary,
			$summary['semantic_flags']
		);
	}

	/**
	 * Creates bounded batches so the main lifecycle names its queue-renewal unit.
	 *
	 * @param Operation[] $operations Dependency-ordered operations.
	 * @return array<int,Operation[]>
	 */
	private function operationBatches( array $operations ): array {
		return array_chunk( $operations, self::BATCH_SIZE );
	}

	private function enforceVerifiedSuccess( OperationExecutionResult $result ): OperationExecutionResult {
		if ( OperationRunStatus::SUCCESSFUL !== $result->status() || $result->verified() ) {
			return $result;
		}

		return OperationExecutionResult::failure(
			'verification_required',
			'The operation handler reported success without verified readback.',
			FailureClassification::PERMANENT_VALIDATION,
			false,
			array( 'handler_result' => $result->toArray() )
		);
	}

	private function applyAsActor(
		int $actor_id,
		Operation $operation,
		TargetDefinition $target,
		ExecutionContext $context,
		ExecutionState $state
	): OperationExecutionResult {
		if ( ! function_exists( 'get_current_user_id' ) || ! function_exists( 'wp_set_current_user' ) ) {
			return $this->applier->apply( $operation, $target, $context, $state );
		}

		$previous_user_id = get_current_user_id();

		if ( $actor_id > 0 && $actor_id !== $previous_user_id ) {
			wp_set_current_user( $actor_id );
		}

		try {
			return $this->applier->apply( $operation, $target, $context, $state );
		} finally {
			if ( $actor_id > 0 && $actor_id !== $previous_user_id ) {
				wp_set_current_user( $previous_user_id );
			}
		}
	}

	private function status(
		int $successful,
		int $skipped,
		int $failed,
		bool $retry_pending,
		bool $blocking_conflict,
		bool $mutated
	): string {
		if ( $retry_pending ) {
			return RunStatus::FAILED;
		}

		if ( $blocking_conflict && ! $mutated && 0 === $successful ) {
			return RunStatus::STALE;
		}

		if ( $failed > 0 && ( $successful > 0 || $skipped > 0 ) ) {
			return RunStatus::PARTIALLY_FAILED;
		}

		if ( $failed > 0 ) {
			return RunStatus::FAILED;
		}

		return RunStatus::SUCCESSFUL;
	}

	/**
	 * @param array<string,mixed> $state_conflict Conflict details recorded in ExecutionState.
	 *
	 * @return array{details:array<string,mixed>,skipped:bool,blocking:bool}|array{}
	 */
	private function classifyConflict( OperationExecutionResult $result, array $state_conflict ): array {
		$details  = $result->details();
		$conflict = array() !== $state_conflict ? $state_conflict : $details;

		if ( true !== ( $conflict['conflict'] ?? false ) ) {
			return array();
		}

		$conflict['result_code'] = $result->code();
		$conflict['status']      = $result->status();

		return array(
			'details'  => $conflict,
			'skipped'  => OperationRunStatus::SKIPPED === $result->status(),
			'blocking' => OperationRunStatus::FAILED === $result->status(),
		);
	}
}
