<?php
/**
 * Conflict-aware compensating rollback service.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Rollback;

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Audit\AuditEvent;
use ScheduledChanges\Domain\Audit\AuditSeverity;
use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Domain\Operation\OperationType;
use ScheduledChanges\Domain\Run\FailureClassification;
use ScheduledChanges\Domain\Run\OperationRunStatus;
use ScheduledChanges\Domain\Run\Run;
use ScheduledChanges\Domain\Run\RunStatus;
use ScheduledChanges\Domain\Run\TriggerSource;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Domain\Snapshot\SnapshotKind;
use ScheduledChanges\Domain\Snapshot\SnapshotRecord;
use ScheduledChanges\Execution\ExecutionState;
use ScheduledChanges\Snapshot\SnapshotCoordinator;
use ScheduledChanges\Support\ThrowableDiagnostic;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Field\FieldDefinition;
use ScheduledChanges\Field\FieldRegistry;
use ScheduledChanges\Lock\LockManager;
use ScheduledChanges\Provider\MergeCapableFieldProvider;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Provider\ProviderAuthorization;
use ScheduledChanges\Provider\ProviderAuthorizationService;
use ScheduledChanges\Provider\ProviderRegistry;
use ScheduledChanges\Provider\ProviderSnapshot;
use ScheduledChanges\Provider\RollbackContext;
use ScheduledChanges\Repository\AuditRepository;
use ScheduledChanges\Repository\LaunchVersionRepository;
use ScheduledChanges\Repository\RunRepository;
use ScheduledChanges\Repository\SnapshotRepository;
use ScheduledChanges\Scheduler\EnvironmentFingerprint;
use ScheduledChanges\Validation\ValidationReport;

/**
 * Restores provider-owned state without silently overwriting newer edits.
 */
final class RollbackService implements LaunchRollbackService {
	private LaunchVersionRepository $versions;
	private RunRepository $runs;
	private SnapshotRepository $snapshots;
	private ProviderRegistry $providers;
	private FieldRegistry $fields;
	private LockManager $locks;
	private AuditRepository $audit;
	private SnapshotCoordinator $snapshotCoordinator;
	private EnvironmentFingerprint $environment;
	private ProviderAuthorization $authorization;
	/**
	 * @var CreatedTargetRollbackHandler[]
	 */
	private array $createdTargetHandlers;
	private const LOCK_TTL = 300;

	/**
	 * @param CreatedTargetRollbackHandler[] $createdTargetHandlers Provider-owned created-target rollback handlers.
	 */
	public function __construct( LaunchVersionRepository $versions, RunRepository $runs, SnapshotRepository $snapshots, ProviderRegistry $providers, FieldRegistry $fields, LockManager $locks, AuditRepository $audit, SnapshotCoordinator $snapshotCoordinator, array $createdTargetHandlers = array(), ?EnvironmentFingerprint $environment = null, ?ProviderAuthorization $authorization = null ) {
		$environment               ??= new EnvironmentFingerprint();
		$this->versions              = $versions;
		$this->runs                  = $runs;
		$this->snapshots             = $snapshots;
		$this->providers             = $providers;
		$this->fields                = $fields;
		$this->locks                 = $locks;
		$this->audit                 = $audit;
		$this->snapshotCoordinator   = $snapshotCoordinator;
		$this->createdTargetHandlers = $createdTargetHandlers;
		$this->environment           = $environment;
		$this->authorization         = $authorization ?? new ProviderAuthorizationService();
	}

	public function rollback(
		int $source_run_id,
		string $conflict_policy,
		string $created_target_policy,
		int $actor_id
	): RollbackOutcome {
		if ( $actor_id <= 0 || ! user_can( $actor_id, CapabilityManager::ROLLBACK_SCHEDULES ) ) {
			throw new RollbackException( 'The rollback actor is unavailable or no longer has rollback permission.' );
		}

		$sourceRun = $this->runs->findById( $source_run_id );

		if ( null === $sourceRun || null === $sourceRun->id() ) {
			throw new \InvalidArgumentException( 'Rollback source run was not found.' );
		}

		$version = $this->versions->findById( $sourceRun->launchVersionId() );
		if ( null === $version || null === $version->id() ) {
			throw new RollbackException( 'Rollback source launch version was not found.' );
		}

		$this->assertAuthorizedForRollback( $actor_id, $version );

		$before  = $this->indexSnapshots(
			$this->snapshots->findForRun( $source_run_id, SnapshotKind::PRE_EXECUTION )
		);
		$applied = $this->indexSnapshots(
			$this->snapshots->findForRun( $source_run_id, SnapshotKind::POST_EXECUTION )
		);

		$has_created_target = array_filter(
			$version->plan()->graph()->operations(),
			static fn ( Operation $operation ): bool => OperationType::createsFutureTarget( $operation->type() )
		);

		if ( array() === $before && array() === $applied && array() === $has_created_target ) {
			throw new RollbackException( 'Rollback source snapshots are unavailable.' );
		}

		$graph         = $version->plan()->graph();
		$sourceResults = $this->runs->operationResults( $source_run_id );
		$state         = new ExecutionState( $graph );
		$this->registerCreatedTargets( $graph->operations(), $sourceResults, $state );
		$targets          = array_map(
			static fn ( $target ): string => $target->lockReference(),
			$graph->targets()
		);
		$targets          = array_values( array_unique( $targets ) );
		$lockToken        = Uuid::generate()->value();
		$rollbackRun      = Run::pending(
			$sourceRun->launchId(),
			$sourceRun->launchVersionId(),
			null,
			TriggerSource::MANUAL_ROLLBACK,
			1,
			UtcTimestamp::now(),
			$lockToken,
			$this->environment->generate( $this->providers )
		);
		$rollbackRunId    = $this->runs->create( $rollbackRun );
		$validation       = ValidationReport::valid();
		$successful       = 0;
		$skipped          = 0;
		$failed           = 0;
		$aborted          = false;
		$locked           = false;
		$operationResults = array();

		$this->runs->linkRollback( $source_run_id, $rollbackRunId );

		try {
			if ( ! $this->runs->markValidating( $rollbackRunId, UtcTimestamp::now() ) ) {
				throw new RollbackException( 'Rollback run could not enter validating state.' );
			}

			$this->locks->acquire( $targets, $lockToken, self::LOCK_TTL );
			$locked = true;
			if ( ! $this->runs->markRunning( $rollbackRunId, $validation ) ) {
				throw new RollbackException( 'Rollback run could not enter running state.' );
			}
			$this->snapshotCoordinator->capture(
				$rollbackRunId,
				$version->id(),
				$graph,
				$state,
				SnapshotKind::PRE_EXECUTION
			);

			foreach ( array_reverse( $graph->topologicalOrder() ) as $operation ) {
				$started = UtcTimestamp::now();
				$startNs = hrtime( true );
				$target  = $graph->targetByUuid( $operation->targetUuid()->value() );
				$result  = $this->rollbackOperation(
					$operation,
					$target,
					$state,
					$before,
					$applied,
					$sourceResults,
					$conflict_policy,
					$created_target_policy,
					$rollbackRun->uuid(),
					$actor_id
				);

				$completed  = UtcTimestamp::now();
				$durationMs = max( 0, (int) round( ( hrtime( true ) - $startNs ) / 1_000_000 ) );
				$status     = \ScheduledChanges\Support\EnumValue::from( \ScheduledChanges\Domain\Run\OperationRunStatus::class, (string) $result['status'] );
				$this->runs->recordOperation(
					$rollbackRunId,
					$version->id(),
					$operation,
					$status,
					1,
					$started,
					$completed,
					$result,
					OperationRunStatus::FAILED === $status
						? FailureClassification::PERMANENT_VALIDATION
						: null,
					false,
					(bool) ( $result['verified'] ?? false ),
					$durationMs
				);
				$operationResults[ $operation->uuid()->value() ] = $result;

				if ( OperationRunStatus::SUCCESSFUL === $status ) {
					++$successful;
				} elseif ( OperationRunStatus::SKIPPED === $status ) {
					++$skipped;
				} else {
					++$failed;

					if ( RollbackConflictPolicy::ABORT === $conflict_policy ) {
						$aborted = true;
						break;
					}
				}
			}

			$this->snapshotCoordinator->capture(
				$rollbackRunId,
				$version->id(),
				$graph,
				$state,
				SnapshotKind::POST_EXECUTION
			);
			$this->locks->release( $targets, $lockToken );
			$locked = false;

			$status  = $this->status( $successful, $skipped, $failed );
			$summary = array(
				'source_run_id'         => $source_run_id,
				'conflict_policy'       => $conflict_policy,
				'created_target_policy' => $created_target_policy,
				'successful_operations' => $successful,
				'skipped_operations'    => $skipped,
				'failed_operations'     => $failed,
				'aborted'               => $aborted,
				'operations'            => $operationResults,
			);
			if ( ! $this->runs->finalize(
				$rollbackRunId,
				$status,
				$validation,
				$summary,
				0,
				$failed,
				UtcTimestamp::now()
			) ) {
				throw new RollbackException( 'Rollback run could not enter its terminal state.' );
			}
			$this->audit->record(
				new AuditEvent(
					Uuid::generate(),
					$sourceRun->launchId(),
					$rollbackRunId,
					$actor_id,
					'launch.rolled_back',
					AuditSeverity::INFO,
					$summary,
					UtcTimestamp::now()
				)
			);
			PublicHooks::afterRollbackLaunch( $summary );

			return new RollbackOutcome( $rollbackRunId, $status, $summary );
		} catch ( \Throwable $throwable ) {
			$diagnostic = ThrowableDiagnostic::from( $throwable );
			$this->runs->finalize(
				$rollbackRunId,
				RunStatus::FAILED,
				$validation,
				array(
					'source_run_id' => $source_run_id,
					'message'       => $diagnostic['message'],
				),
				0,
				1,
				UtcTimestamp::now()
			);
			$this->audit->record(
				new AuditEvent(
					Uuid::generate(),
					$sourceRun->launchId(),
					$rollbackRunId,
					$actor_id,
					'launch.rollback_failed',
					AuditSeverity::ERROR,
					array(
						'source_run_id' => $source_run_id,
						'message'       => $diagnostic['message'],
					),
					UtcTimestamp::now()
				)
			);

			throw $throwable;
		} finally {
			if ( $locked ) {
				$this->locks->release( $targets, $lockToken );
			}
		}
	}

	/**
	 * Enforces provider-owned target authorization before rollback state is written.
	 */
	private function assertAuthorizedForRollback( int $actor_id, \ScheduledChanges\Domain\Launch\LaunchVersion $version ): void {
		$targets = $version->plan()->graph()->targets();

		if ( array() === $targets ) {
			throw new RollbackException( 'The launch version has no provider-owned targets to authorize for rollback.' );
		}

		foreach ( $targets as $target ) {
			if ( ! $this->authorization->canRollback( $actor_id, $target ) ) {
				throw new RollbackException( 'The rollback actor is not authorized for this provider-owned target.' );
			}
		}
	}

	/**
	 * @param array<string,SnapshotRecord>      $before Before snapshot index.
	 * @param array<string,SnapshotRecord>      $applied Applied snapshot index.
	 * @param array<string,array<string,mixed>> $sourceResults Source operation results.
	 * @return array<string,mixed>
	 */
	private function rollbackOperation(
		Operation $operation,
		?TargetDefinition $targetDefinition,
		ExecutionState $state,
		array $before,
		array $applied,
		array $sourceResults,
		string $conflictPolicy,
		string $createdTargetPolicy,
		Uuid $runUuid,
		int $actor_id
	): array {
		if ( OperationType::createsFutureTarget( $operation->type() ) ) {
			$handler = $this->createdTargetHandler( $operation );

			if ( null === $handler ) {
				return $this->failed( 'created_target_rollback_handler_missing', 'No provider handler can roll back this created target.' );
			}

			return $handler->rollbackCreatedTarget(
				$operation,
				$sourceResults,
				$createdTargetPolicy
			);
		}

		if ( null === $operation->fieldPath() || null === $operation->rollbackHandler() ) {
			return $this->skipped( 'irreversible_or_non_field', 'The operation has no automatic field rollback.' );
		}

		$target   = $state->resolve( $operation->targetReference() );
		$provider = $this->providers->get( $operation->providerId() );
		$key      = $this->snapshotKey( $operation->targetReference(), $operation->providerId() );

		if ( null === $target || null === $provider || null === $targetDefinition || ! isset( $before[ $key ], $applied[ $key ] ) ) {
			return $this->failed( 'rollback_context_unavailable', 'Rollback target, provider, or snapshots are unavailable.' );
		}

		$context = new ObjectContext(
			$targetDefinition->targetType(),
			$target,
			$targetDefinition->subtype()
		);
		$catalog = $this->fields->discover( $context );
		$field   = $catalog->fields()->get( (string) $operation->fieldPath() );

		if ( null === $field ) {
			return $this->failed( 'rollback_field_unavailable', 'Rollback field is no longer discoverable.' );
		}

		$currentSnapshot = $provider->snapshot( $context );
		$fieldPath       = (string) $operation->fieldPath();

		if (
			! array_key_exists( $fieldPath, $before[ $key ]->values() )
			|| ! array_key_exists( $fieldPath, $applied[ $key ]->values() )
		) {
			return $this->failed(
				'rollback_snapshot_field_unavailable',
				'Rollback values are unavailable or were omitted as sensitive.'
			);
		}

		$beforeValue  = $before[ $key ]->value( $fieldPath );
		$appliedValue = $applied[ $key ]->value( $fieldPath );
		$currentValue = $provider->normalize( $field, $currentSnapshot->value( $fieldPath ) );
		$conflict     = $currentValue !== $appliedValue;
		$restoreValue = $beforeValue;

		if ( $conflict ) {
			if ( RollbackConflictPolicy::PRESERVE === $conflictPolicy ) {
				return $this->skipped( 'rollback_conflict_preserved', 'A newer current value was preserved.' );
			}

			if ( RollbackConflictPolicy::ABORT === $conflictPolicy ) {
				return $this->failed( 'rollback_conflict_aborted', 'Rollback stopped because the current value changed.' );
			}

			if ( RollbackConflictPolicy::MERGE === $conflictPolicy ) {
				if ( ! $provider instanceof MergeCapableFieldProvider ) {
					return $this->failed( 'rollback_merge_not_supported', 'The provider does not support rollback merging.' );
				}

				$restoreValue = $provider->mergeForRollback(
					$context,
					$field,
					$beforeValue,
					$appliedValue,
					$currentValue
				);
			}
		}

		$result = $provider->rollback(
			$context,
			new ProviderSnapshot(
				$provider->id(),
				$provider->version(),
				array( $fieldPath => $restoreValue )
			),
			new RollbackContext( $runUuid, RollbackConflictPolicy::FORCE === $conflictPolicy, $actor_id )
		);

		if ( ! $result->successful() ) {
			return $this->failed( $result->code(), $result->message(), $result->details() );
		}

		$current = $provider->normalize( $field, $provider->read( $context, $field ) );
		if ( $current !== $restoreValue ) {
			return $this->failed( 'rollback_verification_failed', 'Rollback readback did not match the selected value.' );
		}

		return $this->successful(
			$result->message(),
			array(
				'conflict' => $conflict,
				'policy'   => $conflictPolicy,
			)
		);
	}

	/**
	 * @param Operation[]                       $operations Source operations.
	 * @param array<string,array<string,mixed>> $sourceResults Source operation results.
	 */
	private function registerCreatedTargets(
		array $operations,
		array $sourceResults,
		ExecutionState $state
	): void {
		foreach ( $operations as $operation ) {
			$handler = $this->createdTargetHandler( $operation );

			if ( null === $handler ) {
				continue;
			}

			$handler->registerCreatedTarget( $operation, $sourceResults, $state );
		}
	}

	private function createdTargetHandler( Operation $operation ): ?CreatedTargetRollbackHandler {
		foreach ( PublicHooks::createdTargetRollbackHandlers( $this->createdTargetHandlers, $this->providers, $this->fields, $this->runs ) as $handler ) {
			if ( $handler->supportsCreatedTarget( $operation ) ) {
				return $handler;
			}
		}

		return null;
	}

	/**
	 * @param SnapshotRecord[] $snapshots Snapshots to index.
	 * @return array<string,SnapshotRecord>
	 */
	private function indexSnapshots( array $snapshots ): array {
		$indexed = array();

		foreach ( $snapshots as $snapshot ) {
			$indexed[ $this->snapshotKey( $snapshot->targetReference(), $snapshot->providerId() ) ] = $snapshot;
		}

		return $indexed;
	}

	private function snapshotKey( string $targetReference, string $providerId ): string {
		return $targetReference . '|' . $providerId;
	}

	/**
	 * @param array<string,mixed> $details Result details.
	 * @return array<string,mixed>
	 */
	private function successful( string $message, array $details = array() ): array {
		return array(
			'status'   => OperationRunStatus::SUCCESSFUL,
			'code'     => 'success',
			'message'  => $message,
			'verified' => true,
			'details'  => $details,
		);
	}

	/**
	 * @return array<string,mixed>
	 */
	private function skipped( string $code, string $message ): array {
		return array(
			'status'   => OperationRunStatus::SKIPPED,
			'code'     => $code,
			'message'  => $message,
			'verified' => true,
			'details'  => array(),
		);
	}

	/**
	 * @param array<string,mixed> $details Result details.
	 * @return array<string,mixed>
	 */
	private function failed( string $code, string $message, array $details = array() ): array {
		return array(
			'status'   => OperationRunStatus::FAILED,
			'code'     => $code,
			'message'  => $message,
			'verified' => false,
			'details'  => $details,
		);
	}

	private function status( int $successful, int $skipped, int $failed ): string {
		if ( $failed > 0 && ( $successful > 0 || $skipped > 0 ) ) {
			return RunStatus::PARTIALLY_FAILED;
		}

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

		return RunStatus::SUCCESSFUL;
	}
}
