<?php
/**
 * Recording REST API controller.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Rest;

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Recording\RecordingSession;
use ScheduledChanges\Domain\Shared\NamedTimezone;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Provider\ProviderAuthorization;
use ScheduledChanges\Provider\ProviderAuthorizationService;
use ScheduledChanges\Recording\RecordingService;
use ScheduledChanges\Repository\RecordingSessionRepository;
use ScheduledChanges\Scheduler\SchedulerService;
use ScheduledChanges\Support\PublicErrorMessages;

/**
 * Exposes provider-neutral recording workspace lifecycle actions.
 */
final class RecordingsController {
	use RegistersVersionedRoutes;

	private ?RecordingService $recording_service;
	private RecordingSessionRepository $recordings;
	private ?SchedulerService $scheduler;
	private ProviderAuthorization $authorization;

	/**
	 * Creates the recording REST controller.
	 */
	public function __construct( ?RecordingService $recording_service, RecordingSessionRepository $recordings, ?SchedulerService $scheduler, ?ProviderAuthorization $authorization = null ) {
		$this->recording_service = $recording_service;
		$this->recordings        = $recordings;
		$this->scheduler         = $scheduler;
		$this->authorization     = $authorization ?? new ProviderAuthorizationService();
	}

	/**
	 * Registers recording collection and lifecycle routes.
	 */
	public function registerRoutes(): void {
		$this->registerVersionedRoute(
			'/recordings',
			array(
				array(
					'methods'             => \WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'startRecording' ),
					'permission_callback' => array( $this, 'canManage' ),
					'args'                => array(
						'target_reference' => array(
							'type'              => 'string',
							'required'          => true,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				'schema' => array( $this, 'schema' ),
			)
		);

		foreach (
			array(
				'capture'  => array( 'captureRecording', array(), 'captureSchema' ),
				'finalize' => array( 'finalizeRecording', $this->finalizeArgs(), 'finalizeSchema' ),
				'cancel'   => array( 'cancelRecording', array(), 'cancelSchema' ),
			) as $action => $definition
		) {
			$this->registerVersionedRoute(
				'/recordings/(?P<uuid>[0-9a-fA-F-]{36})/' . $action,
				array(
					array(
						'methods'             => \WP_REST_Server::CREATABLE,
						'callback'            => array( $this, $definition[0] ),
						'permission_callback' => array( $this, 'canManage' ),
						'args'                => array_merge( $this->uuidArgs(), $definition[1] ),
					),
					'schema' => array( $this, $definition[2] ),
				)
			);
		}
	}

	/**
	 * Starts a recording for a provider-resolved canonical target reference.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function startRecording( \WP_REST_Request $request ) {
		try {
			$service = $this->service();
			$context = PublicHooks::targetReferenceContext(
				null,
				(string) $request['target_reference'],
				array( 'surface' => 'rest' )
			);

			if ( null === $context || null === $context->targetId() ) {
				return $this->notFound();
			}

			$session = $service->start( $context->targetId(), get_current_user_id() );

			return new \WP_REST_Response( $this->sessionRow( $session ), 201 );
		} catch ( \InvalidArgumentException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_target_invalid', __( 'Recording target is invalid or unavailable.', 'jjs-post-scheduler' ), 400, $exception );
		} catch ( \RuntimeException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_start_failed', __( 'Recording could not be started.', 'jjs-post-scheduler' ), 409, $exception );
		}
	}

	/**
	 * Captures a redacted summary of the current workspace diff.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function captureRecording( \WP_REST_Request $request ) {
		try {
			$uuid = new Uuid( (string) $request['uuid'] );
			$this->visibleSession( $uuid );
			$diff     = $this->service()->capture( $uuid );
			$warnings = array_map(
				static fn ( array $warning ): array => array(
					'code'     => (string) ( $warning['code'] ?? '' ),
					'blocking' => (bool) ( $warning['blocking'] ?? false ),
					'message'  => (string) ( $warning['message'] ?? '' ),
				),
				array_values( array_filter( is_array( $diff['warnings'] ?? null ) ? $diff['warnings'] : array(), 'is_array' ) )
			);

			return rest_ensure_response(
				array(
					'uuid'          => $uuid->value(),
					'changeCount'   => count( is_array( $diff['changes'] ?? null ) ? $diff['changes'] : array() ),
					'selectedCount' => count( is_array( $diff['selected_change_ids'] ?? null ) ? $diff['selected_change_ids'] : array() ),
					'warnings'      => $warnings,
				)
			);
		} catch ( \RuntimeException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_capture_failed', __( 'Recording changes could not be captured.', 'jjs-post-scheduler' ), 409, $exception );
		}
	}

	/**
	 * Finalizes a recording and optionally schedules the resulting launch.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function finalizeRecording( \WP_REST_Request $request ) {
		try {
			$uuid = new Uuid( (string) $request['uuid'] );
			$this->visibleSession( $uuid );
			$date         = trim( (string) $request['go_live_date'] );
			$time         = trim( (string) $request['go_live_time'] );
			$timezone     = new NamedTimezone( $this->siteTimezoneName() );
			$scheduled_at = null;

			if ( '' !== $date || '' !== $time ) {
				if ( '' === $date || '' === $time || ! current_user_can( CapabilityManager::SCHEDULE_CHANGES ) ) {
					return $this->error( 'scheduled_changes_recording_schedule_invalid', __( 'Scheduling requires a date, time, and schedule permission.', 'jjs-post-scheduler' ), 400 );
				}

				if ( null === $this->scheduler ) {
					return $this->error( 'scheduled_changes_scheduler_unavailable', __( 'Scheduler service is unavailable.', 'jjs-post-scheduler' ), 503 );
				}

				$scheduled_at = $this->scheduledTimestamp( $date, $time, $timezone );
			}

			$result = $this->service()->finalize(
				$uuid,
				array(),
				'' === trim( (string) $request['name'] ) ? null : (string) $request['name'],
				$timezone,
				true !== $request['retain_workspace'],
				true === $request['acknowledge_unsupported']
			);
			$data   = array(
				'launchUuid'          => $result->launchUuid()->value(),
				'launchId'            => $result->launchId(),
				'versionId'           => $result->versionId(),
				'selectedChangeCount' => $result->selectedChangeCount(),
			);

			if ( $scheduled_at instanceof UtcTimestamp && null !== $this->scheduler ) {
				$data['actionId']       = $this->scheduler->schedule( $result->versionId(), $scheduled_at );
				$data['scheduledAtUtc'] = $scheduled_at->toStorage();
			}

			return rest_ensure_response( $data );
		} catch ( \InvalidArgumentException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_finalize_invalid', __( 'Recording could not be finalized.', 'jjs-post-scheduler' ), 400, $exception );
		} catch ( \RuntimeException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_finalize_failed', __( 'Recording could not be finalized.', 'jjs-post-scheduler' ), 409, $exception );
		}
	}

	/**
	 * Cancels a visible active recording.
	 *
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function cancelRecording( \WP_REST_Request $request ) {
		try {
			$uuid = new Uuid( (string) $request['uuid'] );
			$this->visibleSession( $uuid );
			$this->service()->abandon( $uuid, true );

			return rest_ensure_response(
				array(
					'uuid'   => $uuid->value(),
					'status' => 'abandoned',
				)
			);
		} catch ( \RuntimeException $exception ) {
			return $this->exceptionError( 'scheduled_changes_recording_cancel_failed', __( 'Recording could not be canceled.', 'jjs-post-scheduler' ), 409, $exception );
		}
	}

	/**
	 * Checks collection capability and item visibility without leaking UUIDs.
	 */
	public function canManage( ?\WP_REST_Request $request = null ): bool {
		if ( ! current_user_can( CapabilityManager::CREATE_SCHEDULES ) ) {
			return false;
		}

		$uuid = null === $request ? '' : (string) $request['uuid'];

		if ( '' === $uuid ) {
			return true;
		}

		try {
			$this->visibleSession( new Uuid( $uuid ) );
			return true;
		} catch ( \Throwable $throwable ) {
			return false;
		}
	}

	/**
	 * Returns the recording response schema.
	 *
	 * @return array<string,mixed>
	 */
	public function schema(): array {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'scheduled_changes_recording',
			'type'       => 'object',
			'properties' => array(
				'uuid'                => array( 'type' => 'string' ),
				'sourceId'            => array( 'type' => 'integer' ),
				'sourceProviderId'    => array( 'type' => 'string' ),
				'sourceType'          => array( 'type' => 'string' ),
				'sourceReference'     => array( 'type' => 'string' ),
				'workspaceId'         => array( 'type' => array( 'integer', 'null' ) ),
				'workspaceProviderId' => array( 'type' => 'string' ),
				'workspaceType'       => array( 'type' => 'string' ),
				'workspaceReference'  => array( 'type' => array( 'string', 'null' ) ),
				'status'              => array( 'type' => 'string' ),
				'cleanupStatus'       => array( 'type' => 'string' ),
				'expiresAt'           => array( 'type' => 'string' ),
			),
		);
	}

	/**
	 * Returns the redacted recording capture schema.
	 *
	 * @return array<string,mixed>
	 */
	public function captureSchema(): array {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'scheduled_changes_recording_capture',
			'type'       => 'object',
			'properties' => array(
				'uuid'          => array( 'type' => 'string' ),
				'changeCount'   => array( 'type' => 'integer' ),
				'selectedCount' => array( 'type' => 'integer' ),
				'warnings'      => array( 'type' => 'array' ),
			),
		);
	}

	/**
	 * Returns the recording finalization schema.
	 *
	 * @return array<string,mixed>
	 */
	public function finalizeSchema(): array {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'scheduled_changes_recording_finalize',
			'type'       => 'object',
			'properties' => array(
				'launchUuid'          => array( 'type' => 'string' ),
				'launchId'            => array( 'type' => 'integer' ),
				'versionId'           => array( 'type' => 'integer' ),
				'selectedChangeCount' => array( 'type' => 'integer' ),
				'actionId'            => array( 'type' => 'integer' ),
				'scheduledAtUtc'      => array( 'type' => 'string' ),
			),
		);
	}

	/**
	 * Returns the recording cancellation schema.
	 *
	 * @return array<string,mixed>
	 */
	public function cancelSchema(): array {
		return array(
			'$schema'    => 'http://json-schema.org/draft-04/schema#',
			'title'      => 'scheduled_changes_recording_cancel',
			'type'       => 'object',
			'properties' => array(
				'uuid'   => array( 'type' => 'string' ),
				'status' => array( 'type' => 'string' ),
			),
		);
	}

	/**
	 * Returns the active recording service.
	 */
	private function service(): RecordingService {
		if ( null === $this->recording_service ) {
			throw new \RuntimeException( 'Recording service is unavailable.' );
		}

		return $this->recording_service;
	}

	/**
	 * Returns a recording session visible to the current actor.
	 */
	private function visibleSession( Uuid $uuid ): RecordingSession {
		$session = $this->recordings->findByUuid( $uuid );

		if ( null === $session || ! $this->authorization->canInspect( get_current_user_id(), $this->sessionTarget( $session ) ) ) {
			throw new \RuntimeException( 'Recording was not found or is not available to the current actor.' );
		}

		return $session;
	}

	/**
	 * Builds the canonical provider-owned source target for one session.
	 */
	private function sessionTarget( RecordingSession $session ): TargetDefinition {
		return new TargetDefinition(
			$session->uuid(),
			$session->sourceReference(),
			$session->sourceId(),
			$session->sourceType(),
			$session->sourceType(),
			null,
			$session->sourceProviderId(),
			null
		);
	}

	/**
	 * @return array<string,mixed>
	 */
	private function sessionRow( RecordingSession $session ): array {
		return array(
			'uuid'                => $session->uuid()->value(),
			'sourceId'            => $session->sourceId(),
			'sourceProviderId'    => $session->sourceProviderId(),
			'sourceType'          => $session->sourceType(),
			'sourceReference'     => $session->sourceReference(),
			'workspaceId'         => $session->workspaceId(),
			'workspaceProviderId' => $session->workspaceProviderId(),
			'workspaceType'       => $session->workspaceType(),
			'workspaceReference'  => $session->workspaceReference(),
			'status'              => $session->status(),
			'cleanupStatus'       => $session->cleanupStatus(),
			'expiresAt'           => $session->expiresAt()->toStorage(),
		);
	}

	/**
	 * @return array<string,array<string,mixed>>
	 */
	private function uuidArgs(): array {
		return array(
			'uuid' => array(
				'type'              => 'string',
				'required'          => true,
				'validate_callback' => static fn ( $value ): bool => is_string( $value ) && 1 === preg_match( '/^[0-9a-fA-F-]{36}$/', $value ),
			),
		);
	}

	/**
	 * @return array<string,array<string,mixed>>
	 */
	private function finalizeArgs(): array {
		return array(
			'name'                    => array(
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			),
			'go_live_date'            => array(
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			),
			'go_live_time'            => array(
				'type'              => 'string',
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
			),
			'retain_workspace'        => array(
				'type'              => 'boolean',
				'default'           => false,
				'sanitize_callback' => 'rest_sanitize_boolean',
			),
			'acknowledge_unsupported' => array(
				'type'              => 'boolean',
				'default'           => false,
				'sanitize_callback' => 'rest_sanitize_boolean',
			),
		);
	}

	/**
	 * Converts a local recording schedule time to UTC.
	 */
	private function scheduledTimestamp( string $date, string $time, NamedTimezone $timezone ): UtcTimestamp {
		$local  = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . $time, new \DateTimeZone( $timezone->value() ) );
		$errors = \DateTimeImmutable::getLastErrors();

		if ( false === $local || ( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) ) {
			throw new \InvalidArgumentException( 'Choose a valid go-live date and time.' );
		}

		$scheduled_at = new UtcTimestamp( $local->setTimezone( new \DateTimeZone( 'UTC' ) ) );

		if ( $scheduled_at->native() <= UtcTimestamp::now()->native() ) {
			throw new \InvalidArgumentException( 'Choose a go-live time in the future.' );
		}

		return $scheduled_at;
	}

	/**
	 * Returns the site's timezone with a UTC fallback.
	 */
	private function siteTimezoneName(): string {
		$timezone = wp_timezone_string();

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

	/**
	 * Returns a target-not-found response.
	 */
	private function notFound(): \WP_Error {
		return $this->error( 'scheduled_changes_recording_target_not_found', __( 'Recording target was not found.', 'jjs-post-scheduler' ), 404 );
	}

	/**
	 * Returns a REST error with a stable HTTP status.
	 */
	private function error( string $code, string $message, int $status ): \WP_Error {
		return new \WP_Error( $code, $message, array( 'status' => $status ) );
	}

	/**
	 * Returns a REST error without exposing raw exception details.
	 */
	private function exceptionError( string $code, string $message, int $status, \Throwable $exception ): \WP_Error {
		return $this->error(
			$code,
			PublicErrorMessages::message( $code, $message, $exception, array( 'surface' => 'rest.recordings' ) ),
			$status
		);
	}
}
