<?php
/**
 * Record Changes application service.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Recording;

// phpcs:disable WordPress.DB -- Scheduler-owned custom tables require direct wpdb queries.
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals -- scheduled_changes_ is the stable plugin prefix.

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Recording\RecordingCleanupStatus;
use ScheduledChanges\Domain\Recording\RecordingSession;
use ScheduledChanges\Domain\Recording\RecordingSessionStatus;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Domain\Shared\NamedTimezone;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Lock\LockManager;
use ScheduledChanges\Provider\ProviderAuthorization;
use ScheduledChanges\Provider\ProviderAuthorizationService;
use ScheduledChanges\Repository\RecordingSessionRepository;
use ScheduledChanges\Support\ThrowableDiagnostic;

/**
 * Starts and abandons isolated provider-owned recording workspaces.
 */
final class RecordingService {
	private RecordingSessionRepository $sessions;
	private RecordingWorkspaceAdapter $workspace;
	private RecordingLaunchCompilerContract $compiler;
	private LockManager $locks;
	private RecordingDiagnosticBuilder $diagnostics;
	private ProviderAuthorization $authorization;
	private const DEFAULT_TTL_SECONDS = DAY_IN_SECONDS;

	public function __construct( RecordingSessionRepository $sessions, RecordingWorkspaceAdapter $workspace, RecordingLaunchCompilerContract $compiler, LockManager $locks, ?RecordingDiagnosticBuilder $diagnostics = null, ?ProviderAuthorization $authorization = null ) {
		$diagnostics       ??= new RecordingDiagnosticBuilder();
		$this->sessions      = $sessions;
		$this->workspace     = $workspace;
		$this->compiler      = $compiler;
		$this->locks         = $locks;
		$this->diagnostics   = $diagnostics;
		$this->authorization = $authorization ?? new ProviderAuthorizationService();
	}

	/**
	 * Starts a standard Record Changes session for a source object.
	 */
	public function start(
		int $source_id,
		?int $owner_id = null,
		int $ttl_seconds = self::DEFAULT_TTL_SECONDS
	): RecordingSession {
		if ( $ttl_seconds <= 0 ) {
			throw new \InvalidArgumentException( 'Recording session TTL must be positive.' );
		}

		$this->workspace->assertCanStart( $source_id );

		$uuid             = Uuid::generate();
		$owner_id         = $owner_id ?? get_current_user_id();
		$source_context   = $this->recordingContext( $uuid, $source_id, null, $owner_id, 'start' );
		$source_provider  = $source_context->sourceProviderId();
		$source_type      = $source_context->sourceType();
		$source_reference = $source_context->sourceReference();
		$source_target    = $this->sourceTarget( $uuid, $source_id, $source_provider, $source_type, $source_reference );

		if ( ! $this->authorization->canSchedule( $owner_id, $source_target ) ) {
			throw new \RuntimeException( 'The current actor is not authorized to record this provider-owned target.' );
		}

		$this->assertNoActiveRecording( $source_provider, $source_type, $source_reference );

		$lock_targets = $this->recordingLockTargets( $source_id, $source_provider, $source_type, $source_reference );
		$lock_owner   = $this->lockOwner( $uuid );
		$request      = new RecordingWorkspaceRequest( $uuid, $source_id, $owner_id );
		$workspace    = null;

		$this->locks->acquire( $lock_targets, $lock_owner, $ttl_seconds );

		try {
			$this->assertNoActiveRecording( $source_provider, $source_type, $source_reference );

			$baseline = $this->workspace->captureSourceBaseline( $source_id );

			$this->withRecordingContext(
				$uuid,
				$source_id,
				null,
				$owner_id,
				'clone',
				function () use ( $request, &$workspace ): void {
					$workspace = $this->workspace->create( $request );
				}
			);

			if ( ! $workspace instanceof RecordingWorkspaceResult ) {
				throw new \RuntimeException( 'Recording workspace was not created.' );
			}

			$now        = UtcTimestamp::now();
			$expires_at = new UtcTimestamp(
				$now->native()->modify( '+' . $ttl_seconds . ' seconds' )
			);
			$session    = new RecordingSession(
				null,
				$uuid,
				$source_id,
				$workspace->workspaceId(),
				$workspace->relationMap(),
				$workspace->providerMap(),
				$baseline,
				array(),
				array(),
				array(),
				$owner_id,
				RecordingSessionStatus::ACTIVE,
				$expires_at,
				$now,
				$now,
				RecordingCleanupStatus::PENDING,
				$source_provider,
				$source_type,
				$source_reference,
				$workspace->workspaceProviderId(),
				$workspace->workspaceType(),
				$workspace->workspaceReference()
			);

			$this->sessions->create( $session );

			$persisted = $this->sessions->findByUuid( $uuid );

			if ( null === $persisted ) {
				throw new \RuntimeException( 'Recording session was not persisted.' );
			}

			return $persisted;
		} catch ( \Throwable $throwable ) {
			$cleanup_failure = null;

			if ( $workspace instanceof RecordingWorkspaceResult ) {
				try {
					$this->workspace->cleanupCreated( $request, $workspace, 'start_failed' );
				} catch ( \Throwable $cleanup_throwable ) {
					$cleanup_failure = $cleanup_throwable;
				}
			}

			$this->locks->release( $lock_targets, $lock_owner );

			if ( $cleanup_failure instanceof \Throwable ) {
				throw new \RuntimeException(
					'Recording start failed and workspace cleanup also failed: ' . $cleanup_failure->getMessage(),
					0,
					$throwable
				);
			}

			throw $throwable;
		}
	}

	/**
	 * Returns an authorized active session for one source, when present.
	 */
	public function activeForSource( int $source_id, int $actor_id ): ?RecordingSession {
		$this->workspace->assertCanStart( $source_id );

		$uuid             = Uuid::generate();
		$source_context   = $this->recordingContext( $uuid, $source_id, null, $actor_id, 'lookup' );
		$source_type      = $source_context->sourceType();
		$source_provider  = $source_context->sourceProviderId();
		$source_reference = $source_context->sourceReference();
		$source_target    = $this->sourceTarget( $uuid, $source_id, $source_provider, $source_type, $source_reference );

		if ( ! $this->authorization->canSchedule( $actor_id, $source_target ) ) {
			throw new \RuntimeException( 'The current actor is not authorized to record this provider-owned target.' );
		}

		return $this->sessions->findActiveBySourceIdentity( $source_provider, $source_type, $source_reference );
	}

	/**
	 * Builds the canonical source target used for provider authorization.
	 */
	private function sourceTarget( Uuid $uuid, int $source_id, string $provider_id, string $source_type, string $source_reference ): TargetDefinition {
		return new TargetDefinition(
			$uuid,
			$source_reference,
			$source_id,
			$source_type,
			$source_type,
			null,
			$provider_id,
			null
		);
	}

	private function assertNoActiveRecording( string $provider_id, string $source_type, string $source_reference ): void {
		$existing = $this->sessions->findActiveBySourceIdentity( $provider_id, $source_type, $source_reference );

		if ( null !== $existing ) {
			throw new \RuntimeException( 'An active recording session already exists for this source.' );
		}
	}

	/**
	 * Captures the current workspace state and persists a structured diff.
	 *
	 * @return array<string,mixed>
	 */
	public function capture( Uuid $uuid ): array {
		$session = $this->activeSession( $uuid );

		return $this->withSessionContext(
			$session,
			'capture',
			function () use ( $session, $uuid ): array {
				$current        = $this->workspace->captureWorkspace( $session );
				$source_current = $this->workspace->captureSource( $session );
				$diff           = $this->workspace->buildDiff( $session, $current );

				if ( $this->diagnostics->sourceMutationDetected( $session->baselineSnapshot(), $source_current ) ) {
					$diff['warnings'][] = array(
						'code'     => 'source_mutation_detected',
						'blocking' => true,
						'message'  => 'The source changed while recording was active. Finalization is blocked to prevent overwriting an unexpected source edit.',
					);
				}

				$diff['diagnostics'] = $this->diagnostics->build(
					$session,
					$current,
					$source_current,
					$diff
				);

				$this->sessions->updateCapture( $uuid, $current, $diff );

				return $diff;
			}
		);
	}

	/**
	 * Finalizes selected changes into a draft launch version.
	 *
	 * @param string[] $selected_change_ids Selected change IDs. Empty means all.
	 */
	public function finalize(
		Uuid $uuid,
		array $selected_change_ids = array(),
		?string $launch_name = null,
		?NamedTimezone $timezone = null,
		bool $delete_workspace = true,
		bool $acknowledge_unsupported_metadata = false
	): RecordingFinalizationResult {
		if ( ! current_user_can( CapabilityManager::CREATE_SCHEDULES ) ) {
			throw new \RuntimeException( 'Current user cannot create schedules.' );
		}

		$session = $this->activeSession( $uuid );

		return $this->withSessionContext(
			$session,
			'finalize',
			function () use ( $session, $uuid, $selected_change_ids, $launch_name, $timezone, $delete_workspace, $acknowledge_unsupported_metadata ): RecordingFinalizationResult {
				try {
					$diff = $this->capture( $uuid );
				} catch ( \Throwable $throwable ) {
					$this->markFinalizationFailed( $session, $throwable, 'capture' );

					throw $throwable;
				}

				$this->assertNoBlockingWarnings( $diff );
				$ids = array() === $selected_change_ids
					? array_map( 'strval', $diff['selected_change_ids'] ?? array() )
					: $selected_change_ids;

				$this->assertUnsupportedMetadataAcknowledged( $diff, $ids, $acknowledge_unsupported_metadata );

				$selected_changes = array(
					'selected_change_ids'               => array_values( $ids ),
					'selected_at_utc'                   => gmdate( 'c' ),
					'unsupported_metadata_acknowledged' => $acknowledge_unsupported_metadata,
				);
				$this->sessions->updateSelectedChanges( $uuid, $selected_changes );

				$launch_timezone = $timezone ?? new NamedTimezone( $this->siteTimezoneName() );

				try {
					$result = $this->compiler->compile(
						$session,
						$diff,
						$ids,
						$launch_name ?? $this->workspace->defaultLaunchName( $session ),
						'Draft launch generated from a JJ\'s Post Scheduler recording session.',
						$launch_timezone,
						get_current_user_id()
					);
				} catch ( \Throwable $throwable ) {
					$this->markFinalizationFailed( $session, $throwable, 'compile' );

					throw $throwable;
				}

				$cleanup = RecordingCleanupStatus::RETAINED;

				if ( $delete_workspace && null !== $session->workspaceId() ) {
					try {
						$this->workspace->cleanup( $session, 'finalized' );
					} catch ( \Throwable $throwable ) {
						$this->markFinalizationFailed( $session, $throwable, 'cleanup' );

						throw $throwable;
					}
					$cleanup = RecordingCleanupStatus::CLEANED;
				}

				$this->persistCleanupDiagnostic( $session, $diff, $selected_changes, $cleanup, 'finalized' );
				$this->sessions->updateStatus( $uuid, RecordingSessionStatus::FINALIZED, $cleanup );
				$this->locks->release(
					$this->sessionLockTargets( $session ),
					$this->lockOwner( $uuid )
				);

				return $result;
			}
		);
	}

	/**
	 * Abandons a recording session and optionally cleans the workspace.
	 */
	public function abandon( Uuid $uuid, bool $delete_workspace = true ): void {
		$session = $this->abandonableSession( $uuid );

		$this->withSessionContext(
			$session,
			'cleanup',
			function () use ( $session, $uuid, $delete_workspace ): void {
				$cleanup = RecordingCleanupStatus::RETAINED;

				if ( $delete_workspace && null !== $session->workspaceId() ) {
					$this->workspace->cleanup( $session, 'abandoned' );
					$cleanup = RecordingCleanupStatus::CLEANED;
				}

				$this->persistCleanupDiagnostic(
					$session,
					$session->diff(),
					$session->selectedChanges(),
					$cleanup,
					'abandoned'
				);
				$this->sessions->updateStatus(
					$uuid,
					RecordingSessionStatus::ABANDONED,
					$cleanup
				);
				$this->locks->release(
					$this->sessionLockTargets( $session ),
					$this->lockOwner( $uuid )
				);
			}
		);
	}

	/**
	 * Cleans expired active recording sessions.
	 *
	 * @param int $limit Maximum sessions to clean.
	 * @return array<string,mixed>
	 */
	public function cleanupStale( int $limit = 25 ): array {
		$cleaned = 0;
		$failed  = array();

		foreach ( $this->sessions->stale( $limit ) as $session ) {
			$uuid = $session->uuid();

			try {
				$this->withSessionContext(
					$session,
					'cleanup',
					function () use ( $session, $uuid ): void {
						if ( null !== $session->workspaceId() ) {
							$this->workspace->cleanup( $session, 'expired' );
						}

						$this->persistCleanupDiagnostic(
							$session,
							$session->diff(),
							$session->selectedChanges(),
							RecordingCleanupStatus::CLEANED,
							'expired'
						);
						$this->sessions->updateStatus(
							$uuid,
							RecordingSessionStatus::EXPIRED,
							RecordingCleanupStatus::CLEANED
						);
						$this->locks->release(
							$this->sessionLockTargets( $session ),
							$this->lockOwner( $uuid )
						);
					}
				);
				++$cleaned;
			} catch ( \Throwable $throwable ) {
				$failed[] = array(
					'uuid'    => $uuid->value(),
					'message' => $throwable->getMessage(),
				);
			}
		}

		return array(
			'cleaned' => $cleaned,
			'failed'  => $failed,
		);
	}

	private function activeSession( Uuid $uuid, bool $require_active = true ): RecordingSession {
		$session = $this->sessions->findByUuid( $uuid );

		if ( null === $session ) {
			throw new \RuntimeException( 'Recording session was not found.' );
		}

		if ( $require_active && RecordingSessionStatus::ACTIVE !== $session->status() ) {
			throw new \RuntimeException( 'Recording session is not active.' );
		}

		$this->workspace->assertCanUse( $session );

		return $session;
	}

	private function abandonableSession( Uuid $uuid ): RecordingSession {
		$session = $this->sessions->findByUuid( $uuid );

		if ( null === $session ) {
			throw new \RuntimeException( 'Recording session was not found.' );
		}

		if ( ! in_array( $session->status(), array( RecordingSessionStatus::ACTIVE, RecordingSessionStatus::FINALIZATION_FAILED ), true ) ) {
			throw new \RuntimeException( 'Recording session is not active or recoverable.' );
		}

		$this->workspace->assertCanUse( $session );

		return $session;
	}

	private function lockOwner( Uuid $uuid ): string {
		return 'recording:' . $uuid->value();
	}

	/**
	 * @return string[]
	 */
	private function recordingLockTargets( int $source_id, string $provider_id, string $source_type, string $source_reference ): array {
		return array_values(
			array_unique(
				array(
					$this->sourceIdentityLockTarget( $provider_id, $source_type, $source_reference ),
					$this->workspace->lockTarget( $source_id ),
				)
			)
		);
	}

	/**
	 * @return string[]
	 */
	private function sessionLockTargets( RecordingSession $session ): array {
		return $this->recordingLockTargets(
			$session->sourceId(),
			$session->sourceProviderId(),
			$session->sourceType(),
			$session->sourceReference()
		);
	}

	private function sourceIdentityLockTarget( string $provider_id, string $source_type, string $source_reference ): string {
		return 'recording-source:' . hash( 'sha256', $provider_id . "\0" . $source_type . "\0" . $source_reference );
	}

	private function siteTimezoneName(): string {
		$timezone = wp_timezone_string();

		return NamedTimezone::isValid( $timezone ) ? $timezone : 'UTC';
	}

	private function markFinalizationFailed( RecordingSession $session, \Throwable $throwable, string $stage ): void {
		$failure          = array(
			'stage'         => $stage,
			'message'       => $this->boundedFailureMessage( $throwable ),
			'failed_at_utc' => gmdate( 'c' ),
		);
		$selected_changes = $session->selectedChanges();
		$diagnostic       = is_array( $selected_changes['diagnostics'] ?? null )
			? $selected_changes['diagnostics']
			: $this->diagnostics->build(
				$session,
				array() === $session->currentSnapshot() ? $session->baselineSnapshot() : $session->currentSnapshot(),
				$session->baselineSnapshot(),
				$session->diff(),
				RecordingCleanupStatus::RETAINED
			);

		$diagnostic['cleanup_result']   = RecordingCleanupStatus::RETAINED;
		$diagnostic['terminal_status']  = RecordingSessionStatus::FINALIZATION_FAILED;
		$diagnostic['completed_at_utc'] = gmdate( 'c' );
		$diagnostic['failure']          = $failure;

		$selected_changes['diagnostics']          = $diagnostic;
		$selected_changes['finalization_failure'] = $failure;
		$selected_changes['selected_change_ids']  = is_array( $selected_changes['selected_change_ids'] ?? null )
			? $selected_changes['selected_change_ids']
			: array();

		$this->sessions->updateSelectedChanges( $session->uuid(), $selected_changes );
		$this->sessions->updateStatus(
			$session->uuid(),
			RecordingSessionStatus::FINALIZATION_FAILED,
			RecordingCleanupStatus::RETAINED
		);
		$this->locks->release(
			$this->sessionLockTargets( $session ),
			$this->lockOwner( $session->uuid() )
		);
	}

	private function boundedFailureMessage( \Throwable $throwable ): string {
		$diagnostic = ThrowableDiagnostic::from( $throwable );
		$message    = sanitize_text_field( $diagnostic['message'] );

		if ( '' === $message ) {
			$message = 'Recording finalization failed.';
		}

		if ( strlen( $message ) > 240 ) {
			$message = substr( $message, 0, 237 ) . '...';
		}

		return $message;
	}

	/**
	 * Persists the final cleanup result with the session's diagnostic record.
	 *
	 * @param array<string,mixed> $diff Recording diff.
	 * @param array<string,mixed> $selected_changes Selected change document.
	 */
	private function persistCleanupDiagnostic(
		RecordingSession $session,
		array $diff,
		array $selected_changes,
		string $cleanup,
		string $terminal_status
	): void {
		$diagnostic = is_array( $diff['diagnostics'] ?? null )
			? $diff['diagnostics']
			: $this->diagnostics->build(
				$session,
				array() === $session->currentSnapshot() ? $session->baselineSnapshot() : $session->currentSnapshot(),
				$session->baselineSnapshot(),
				$diff,
				$cleanup
			);

		$diagnostic['cleanup_result']    = $cleanup;
		$diagnostic['terminal_status']   = $terminal_status;
		$diagnostic['completed_at_utc']  = gmdate( 'c' );
		$selected_changes['diagnostics'] = $diagnostic;

		$this->sessions->updateSelectedChanges( $session->uuid(), $selected_changes );
	}

	/**
	 * Prevents finalization when recording detected a change it cannot represent safely.
	 *
	 * @param array<string,mixed> $diff Recording diff.
	 */
	private function assertNoBlockingWarnings( array $diff ): void {
		$messages = array();

		foreach ( is_array( $diff['warnings'] ?? null ) ? $diff['warnings'] : array() as $warning ) {
			if ( ! is_array( $warning ) || true !== ( $warning['blocking'] ?? false ) ) {
				continue;
			}

			$message = trim( (string) ( $warning['message'] ?? '' ) );
			$code    = sanitize_key( (string) ( $warning['code'] ?? '' ) );

			if ( '' !== $message ) {
				$messages[] = '' === $code ? $message : '[' . $code . '] ' . $message;
			}
		}

		if ( array() === $messages ) {
			return;
		}

		throw new \RuntimeException(
			'Recording cannot be finalized: ' . implode( ' ', array_values( array_unique( $messages ) ) )
		);
	}

	/**
	 * Prevents finalization when unsupported metadata would be silently excluded.
	 *
	 * @param array<string,mixed> $diff Recording diff.
	 * @param string[]            $selected_change_ids Selected supported change IDs.
	 */
	private function assertUnsupportedMetadataAcknowledged(
		array $diff,
		array $selected_change_ids,
		bool $acknowledged
	): void {
		if ( $acknowledged ) {
			return;
		}

		$warnings = array_values(
			array_filter(
				is_array( $diff['warnings'] ?? null ) ? $diff['warnings'] : array(),
				static fn ( $warning ): bool => is_array( $warning )
					&& 'unsupported_metadata_changed' === (string) ( $warning['code'] ?? '' )
			)
		);

		if ( array() === $warnings ) {
			return;
		}

		throw new RecordingFinalizationRequiresAcknowledgement( $warnings, $selected_change_ids );
	}

	/**
	 * Runs an operation inside public recording context hooks.
	 *
	 * @template T
	 * @param RecordingSession $session Recording session.
	 * @param string           $phase Recording phase.
	 * @param callable():T     $callback Operation callback.
	 * @return T
	 */
	private function withSessionContext( RecordingSession $session, string $phase, callable $callback ) {
		return $this->withRecordingContext(
			$session->uuid(),
			$session->sourceId(),
			$session->workspaceId(),
			$session->ownerId(),
			$phase,
			$callback
		);
	}

	/**
	 * Runs an operation inside public recording context hooks.
	 *
	 * @template T
	 * @param Uuid         $uuid Recording UUID.
	 * @param int          $source_id Source object ID.
	 * @param int|null     $workspace_id Workspace object ID.
	 * @param int          $owner_id Owner user ID.
	 * @param string       $phase Recording phase.
	 * @param callable():T $callback Operation callback.
	 * @return T
	 */
	private function withRecordingContext(
		Uuid $uuid,
		int $source_id,
		?int $workspace_id,
		int $owner_id,
		string $phase,
		callable $callback
	) {
		$context = $this->recordingContext( $uuid, $source_id, $workspace_id, $owner_id, $phase )->toArray();

		do_action( 'scheduled_changes_recording_context_started', $context );

		try {
			return $callback();
		} finally {
			do_action( 'scheduled_changes_recording_context_ended', $context );
		}
	}

	private function recordingContext(
		Uuid $uuid,
		int $source_id,
		?int $workspace_id,
		int $owner_id,
		string $phase
	): RecordingWorkspaceContext {
		$context = RecordingWorkspaceContext::fromArray(
			$this->workspace->context( $uuid, $source_id, $workspace_id, $owner_id, $phase )
		);
		$context->assertMatches( $uuid, $source_id, $workspace_id, $owner_id, $phase );

		return $context;
	}
}
