<?php
/**
 * WPDB immutable launch version repository.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Repository;

// 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.

use ScheduledChanges\Database\TableNames;
use ScheduledChanges\Database\SchedulerTransaction;
use ScheduledChanges\Domain\Launch\LaunchVersion;
use ScheduledChanges\Domain\Launch\LaunchVersionRole;
use ScheduledChanges\Domain\Launch\LaunchVersionStatus;
use ScheduledChanges\Domain\Launch\OperationPlan;
use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Domain\Run\TriggerSource;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Snapshot\SnapshotKind;
use ScheduledChanges\Domain\Snapshot\SnapshotRecord;
use ScheduledChanges\Provider\ProviderSnapshot;
use ScheduledChanges\Support\EnumValue;
use ScheduledChanges\Support\Json;

/**
 * Inserts complete versions transactionally and never updates them.
 */
final class WpdbLaunchVersionRepository implements LaunchVersionRepository {
	/**
	 * WordPress database connection.
	 *
	 * @var \wpdb
	 */
	private \wpdb $wpdb;

	/**
	 * Scheduler table names.
	 *
	 * @var array<string,string>
	 */
	private array $tables;
	/**
	 * Scheduler-owned transaction helper.
	 *
	 * @var SchedulerTransaction
	 */
	private SchedulerTransaction $transactions;

	/**
	 * Creates the repository.
	 *
	 * @param \wpdb|null                $database WordPress database connection.
	 * @param TableNames|null           $tables Table name resolver.
	 * @param SchedulerTransaction|null $transactions Scheduler-owned transaction helper.
	 */
	public function __construct( ?\wpdb $database = null, ?TableNames $tables = null, ?SchedulerTransaction $transactions = null ) {
		$database = \ScheduledChanges\Support\WpdbConnection::resolve( $database );

		$table_names        = $tables ?? new TableNames( $database );
		$this->wpdb         = $database;
		$this->tables       = $table_names->all();
		$this->transactions = $transactions ?? new SchedulerTransaction( $database, $table_names );
	}

	/**
	 * {@inheritDoc}
	 */
	public function create( LaunchVersion $version ): int {
		if ( null !== $version->id() ) {
			throw new \InvalidArgumentException( 'Cannot create an already-persisted launch version.' );
		}

		return $this->transactions->run(
			array( 'launch_versions', 'launch_targets', 'operations', 'snapshots' ),
			function () use ( $version ): int {
				$plan          = $version->plan();
				$scheduled_at  = $version->scheduledAt();
				$superseded_at = $version->supersededAt();
				$inserted      = $this->wpdb->insert(
					$this->tables['launch_versions'],
					array(
						'launch_id'               => $version->launchId(),
						'version_number'          => $version->versionNumber(),
						'status'                  => $version->status(),
						'action_scheduler_id'     => $version->actionSchedulerId(),
						'scheduled_at_utc'        => null === $scheduled_at ? null : $scheduled_at->toStorage(),
						'occurrence_at_utc'       => null,
						'attempt_number'          => 1,
						'trigger_source'          => TriggerSource::SCHEDULED,
						'claimed_at_utc'          => null,
						'updated_at_utc'          => $version->createdAt()->toStorage(),
						'superseded_at_utc'       => null === $superseded_at ? null : $superseded_at->toStorage(),
						'version_role'            => $version->role(),
						'linked_version_id'       => $version->linkedVersionId(),
						'plan_json'               => $plan->json(),
						'schema_version'          => $plan->schemaVersion(),
						'checksum'                => $plan->checksum(),
						'validation_summary_json' => Json::encode( $plan->validation()->toArray() ),
						'compatibility_json'      => Json::encode( $plan->compatibility() ),
						'required_providers_json' => Json::encode( $plan->requiredProviders() ),
						'creator_id'              => $version->creatorId(),
						'created_at_utc'          => $version->createdAt()->toStorage(),
						'human_summary'           => $plan->humanSummary(),
						'fingerprints_json'       => Json::encode( $plan->fingerprints() ),
					),
					array( '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%s' )
				);

				if ( false === $inserted ) {
					throw new RepositoryException( 'Unable to create launch version: ' . $this->wpdb->last_error );
				}

				$version_id = (int) $this->wpdb->insert_id;

				foreach ( $plan->graph()->targets() as $target ) {
					$this->insertTarget( $version_id, $target );
				}

				foreach ( $plan->graph()->topologicalOrder() as $order => $operation ) {
					$this->insertOperation( $version_id, $operation, $order );
				}

				$this->insertPlanningSnapshots( $version_id, $plan );

				return $version_id;
			}
		);
	}

	/**
	 * {@inheritDoc}
	 */
	public function findById( int $id ): ?LaunchVersion {
		$sql = $this->wpdb->prepare(
			"SELECT * FROM {$this->tables['launch_versions']} WHERE id = %d LIMIT 1",
			$id
		);

		return $this->hydrate( $this->wpdb->get_row( $sql, ARRAY_A ) );
	}

	/**
	 * {@inheritDoc}
	 */
	public function findLatestForLaunch( int $launch_id ): ?LaunchVersion {
		$sql = $this->wpdb->prepare(
			"SELECT * FROM {$this->tables['launch_versions']}
				WHERE launch_id = %d
				ORDER BY version_number DESC
				LIMIT 1",
			$launch_id
		);

		return $this->hydrate( $this->wpdb->get_row( $sql, ARRAY_A ) );
	}

	/**
	 * {@inheritDoc}
	 */
	public function findLinkedEndVersion( int $start_version_id ): ?LaunchVersion {
		$sql = $this->wpdb->prepare(
			"SELECT * FROM {$this->tables['launch_versions']}
				WHERE version_role = %s
					AND linked_version_id = %d
				ORDER BY version_number DESC
				LIMIT 1",
			LaunchVersionRole::END,
			$start_version_id
		);

		return $this->hydrate( $this->wpdb->get_row( $sql, ARRAY_A ) );
	}

	/**
	 * {@inheritDoc}
	 */
	public function markQueuedCallback( int $id, int $action_scheduler_id, UtcTimestamp $fire_at, UtcTimestamp $occurrence_at, int $attempt_number, string $trigger_source ): void {
		if ( $attempt_number <= 0 ) {
			throw new \InvalidArgumentException( 'Queued launch callbacks require a positive attempt number.' );
		}

		$trigger_source = EnumValue::from( TriggerSource::class, $trigger_source );
		$updated        = $this->wpdb->update(
			$this->tables['launch_versions'],
			array(
				'status'              => LaunchVersionStatus::SCHEDULED,
				'action_scheduler_id' => $action_scheduler_id,
				'scheduled_at_utc'    => $fire_at->toStorage(),
				'occurrence_at_utc'   => $occurrence_at->toStorage(),
				'attempt_number'      => $attempt_number,
				'trigger_source'      => $trigger_source,
				'claimed_at_utc'      => null,
				'updated_at_utc'      => UtcTimestamp::now()->toStorage(),
				'superseded_at_utc'   => null,
			),
			array( 'id' => $id ),
			array( '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to mark launch version queued: ' . $this->wpdb->last_error );
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public function replaceQueuedCallback( int $id, int $expected_action_scheduler_id, UtcTimestamp $expected_occurrence_at, int $expected_attempt_number, string $expected_trigger_source, int $action_scheduler_id, UtcTimestamp $fire_at, UtcTimestamp $occurrence_at, int $attempt_number, string $trigger_source ): bool {
		if ( $expected_attempt_number <= 0 || $attempt_number <= 0 ) {
			throw new \InvalidArgumentException( 'Queued launch callbacks require a positive attempt number.' );
		}

		$expected_trigger_source = EnumValue::from( TriggerSource::class, $expected_trigger_source );
		$trigger_source          = EnumValue::from( TriggerSource::class, $trigger_source );
		$updated                 = $this->wpdb->update(
			$this->tables['launch_versions'],
			array(
				'status'              => LaunchVersionStatus::SCHEDULED,
				'action_scheduler_id' => $action_scheduler_id,
				'scheduled_at_utc'    => $fire_at->toStorage(),
				'occurrence_at_utc'   => $occurrence_at->toStorage(),
				'attempt_number'      => $attempt_number,
				'trigger_source'      => $trigger_source,
				'claimed_at_utc'      => null,
				'updated_at_utc'      => UtcTimestamp::now()->toStorage(),
				'superseded_at_utc'   => null,
			),
			array(
				'id'                  => $id,
				'status'              => LaunchVersionStatus::RUNNING,
				'action_scheduler_id' => $expected_action_scheduler_id,
				'occurrence_at_utc'   => $expected_occurrence_at->toStorage(),
				'attempt_number'      => $expected_attempt_number,
				'trigger_source'      => $expected_trigger_source,
			),
			array( '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s' ),
			array( '%d', '%s', '%d', '%s', '%d', '%s' )
		);

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to replace launch version queue pointer: ' . $this->wpdb->last_error );
		}

		return 1 === (int) $updated;
	}

	/**
	 * {@inheritDoc}
	 */
	public function queuedCallbacks(): array {
		$sql  = $this->wpdb->prepare(
			"SELECT id, status, action_scheduler_id, occurrence_at_utc, attempt_number, trigger_source
				FROM {$this->tables['launch_versions']}
				WHERE action_scheduler_id IS NOT NULL
					AND status IN (%s, %s)",
			LaunchVersionStatus::SCHEDULED,
			LaunchVersionStatus::RUNNING
		);
		$rows = $this->wpdb->get_results( $sql, ARRAY_A );

		if ( ! is_array( $rows ) ) {
			return array();
		}

		return array_map(
			static function ( array $row ): array {
				return array(
					'id'                  => (int) $row['id'],
					'status'              => (string) $row['status'],
					'action_scheduler_id' => (int) $row['action_scheduler_id'],
					'occurrence_at_utc'   => (string) $row['occurrence_at_utc'],
					'attempt_number'      => (int) $row['attempt_number'],
					'trigger_source'      => (string) $row['trigger_source'],
				);
			},
			$rows
		);
	}

	/**
	 * {@inheritDoc}
	 */
	public function claimQueuedCallback( int $id, int $action_scheduler_id, UtcTimestamp $occurrence_at, int $attempt_number, string $trigger_source ): bool {
		if ( $attempt_number <= 0 ) {
			throw new \InvalidArgumentException( 'Queued launch callbacks require a positive attempt number.' );
		}

		$trigger_source = EnumValue::from( TriggerSource::class, $trigger_source );
		$claimed_at     = UtcTimestamp::now();
		$updated        = $this->wpdb->update(
			$this->tables['launch_versions'],
			array(
				'status'         => LaunchVersionStatus::RUNNING,
				'claimed_at_utc' => $claimed_at->toStorage(),
				'updated_at_utc' => $claimed_at->toStorage(),
			),
			array(
				'id'                  => $id,
				'status'              => LaunchVersionStatus::SCHEDULED,
				'action_scheduler_id' => $action_scheduler_id,
				'occurrence_at_utc'   => $occurrence_at->toStorage(),
				'attempt_number'      => $attempt_number,
				'trigger_source'      => $trigger_source,
			),
			array( '%s', '%s', '%s' ),
			array( '%d', '%s', '%d', '%s', '%d', '%s' )
		);

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to claim queued launch version: ' . $this->wpdb->last_error );
		}

		return 1 === (int) $updated;
	}

	/**
	 * {@inheritDoc}
	 */
	public function transitionStatus( int $id, array $expected_statuses, string $status ): bool {
		if ( array() === $expected_statuses ) {
			throw new \InvalidArgumentException( 'Launch version transitions require at least one expected prior status.' );
		}

		$status            = $this->validatedStatus( $status );
		$expected_statuses = array_map(
			fn ( string $expected_status ): string => $this->validatedStatus( $expected_status ),
			array_values( $expected_statuses )
		);
		$updated_at        = UtcTimestamp::now();
		$placeholders      = implode( ', ', array_fill( 0, count( $expected_statuses ), '%s' ) );
		$sql               = $this->wpdb->prepare(
			"UPDATE {$this->tables['launch_versions']}
				SET status = %s,
					updated_at_utc = %s
				WHERE id = %d
					AND status IN ({$placeholders})",
			array_merge(
				array(
					$status,
					$updated_at->toStorage(),
					$id,
				),
				$expected_statuses
			)
		);
		$updated           = $this->wpdb->query( $sql );

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to transition launch version: ' . $this->wpdb->last_error );
		}

		return 1 === (int) $updated;
	}

	/**
	 * {@inheritDoc}
	 */
	public function markSuperseded( int $id, UtcTimestamp $superseded_at ): void {
		$status  = $this->validatedStatus( LaunchVersionStatus::SUPERSEDED );
		$updated = $this->wpdb->update(
			$this->tables['launch_versions'],
			array(
				'status'            => $status,
				'superseded_at_utc' => $superseded_at->toStorage(),
				'updated_at_utc'    => UtcTimestamp::now()->toStorage(),
			),
			array( 'id' => $id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to mark launch version superseded: ' . $this->wpdb->last_error );
		}
	}

	/**
	 * {@inheritDoc}
	 */
	public function updateStatus( int $id, string $status ): void {
		$status  = $this->validatedStatus( $status );
		$updated = $this->wpdb->update(
			$this->tables['launch_versions'],
			array(
				'status'         => $status,
				'updated_at_utc' => UtcTimestamp::now()->toStorage(),
			),
			array( 'id' => $id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			throw new RepositoryException( 'Unable to update launch version status: ' . $this->wpdb->last_error );
		}
	}

	/** Validates every launch-version status writer through one enum boundary. */
	private function validatedStatus( string $status ): string {
		return EnumValue::from( LaunchVersionStatus::class, $status );
	}

	/**
	 * {@inheritDoc}
	 */
	public function operationId( int $version_id, string $operation_uuid ): ?int {
		$sql = $this->wpdb->prepare(
			"SELECT id FROM {$this->tables['operations']}
				WHERE launch_version_id = %d
					AND operation_uuid = %s
				LIMIT 1",
			$version_id,
			$operation_uuid
		);
		$id  = $this->wpdb->get_var( $sql );

		return null === $id ? null : (int) $id;
	}

	/**
	 * {@inheritDoc}
	 */
	public function deleteById( int $id ): bool {
		return $this->transactions->run(
			array( 'launch_versions', 'launch_targets', 'operations', 'snapshots' ),
			function () use ( $id ): bool {
				$operations_deleted = $this->wpdb->delete(
					$this->tables['operations'],
					array( 'launch_version_id' => $id ),
					array( '%d' )
				);
				$targets_deleted    = $this->wpdb->delete(
					$this->tables['launch_targets'],
					array( 'launch_version_id' => $id ),
					array( '%d' )
				);
				$snapshots_deleted  = $this->wpdb->delete(
					$this->tables['snapshots'],
					array( 'launch_version_id' => $id ),
					array( '%d' )
				);

				if ( false === $operations_deleted || false === $targets_deleted || false === $snapshots_deleted ) {
					throw new RepositoryException( 'Unable to delete normalized launch graph rows: ' . $this->wpdb->last_error );
				}

				$deleted = $this->wpdb->delete( $this->tables['launch_versions'], array( 'id' => $id ), array( '%d' ) );

				if ( false === $deleted ) {
					throw new RepositoryException( 'Unable to delete launch version: ' . $this->wpdb->last_error );
				}

				return $deleted > 0;
			}
		);
	}

	/**
	 * Inserts a normalized target row.
	 *
	 * @param int              $version_id Version ID.
	 * @param TargetDefinition $target Target to insert.
	 */
	private function insertTarget( int $version_id, TargetDefinition $target ): void {
		$inserted = $this->wpdb->insert(
			$this->tables['launch_targets'],
			array(
				'launch_version_id'    => $version_id,
				'target_uuid'          => $target->uuid()->value(),
				'target_reference'     => $target->reference(),
				'target_id'            => $target->targetId(),
				'target_type'          => $target->targetType(),
				'subtype'              => $target->subtype(),
				'parent_reference'     => $target->parentReference(),
				'provider_id'          => $target->providerId(),
				'baseline_fingerprint' => $target->baselineFingerprint(),
				'execution_order'      => $target->executionOrder(),
			),
			array( '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%d' )
		);

		if ( false === $inserted ) {
			throw new RepositoryException( 'Unable to persist launch target: ' . $this->wpdb->last_error );
		}
	}

	/**
	 * Inserts a normalized operation row.
	 *
	 * @param int       $version_id Version ID.
	 * @param Operation $operation Operation to insert.
	 * @param int       $order Topological execution order.
	 */
	private function insertOperation( int $version_id, Operation $operation, int $order ): void {
		$inserted = $this->wpdb->insert(
			$this->tables['operations'],
			array(
				'launch_version_id' => $version_id,
				'operation_uuid'    => $operation->uuid()->value(),
				'target_uuid'       => $operation->targetUuid()->value(),
				'operation_type'    => $operation->type(),
				'field_path'        => $operation->fieldPath(),
				'provider_id'       => $operation->providerId(),
				'before_json'       => Json::encode( $operation->before() ),
				'after_json'        => Json::encode( $operation->after() ),
				'value_type'        => $operation->valueType(),
				'schema_json'       => Json::encode( $operation->schema() ),
				'validation_json'   => Json::encode( $operation->validationRequirements() ),
				'dependencies_json' => Json::encode( $operation->dependencies() ),
				'idempotency_key'   => $operation->idempotencyKey(),
				'reversibility'     => $operation->reversibility(),
				'conflict_strategy' => $operation->conflictStrategy(),
				'redaction_json'    => Json::encode( $operation->redactionRules() ),
				'summary'           => $operation->summary(),
				'execution_order'   => $order,
			),
			array(
				'%d',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%s',
				'%d',
			)
		);

		if ( false === $inserted ) {
			throw new RepositoryException( 'Unable to persist launch operation: ' . $this->wpdb->last_error );
		}
	}

	/**
	 * Persists run-independent planning snapshots for conflict checks.
	 */
	private function insertPlanningSnapshots( int $version_id, OperationPlan $plan ): void {
		$grouped = array();

		foreach ( $plan->graph()->operations() as $operation ) {
			$field_path = $operation->fieldPath();

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

			$key               = $operation->targetReference() . '|' . $operation->providerId();
			$grouped[ $key ] ??= array(
				'target'    => $operation->targetReference(),
				'provider'  => $operation->providerId(),
				'values'    => array(),
				'sensitive' => array(),
			);

			if ( (bool) ( $operation->redactionRules()['sensitive'] ?? false ) ) {
				$grouped[ $key ]['sensitive'][] = $field_path;
				continue;
			}

			$grouped[ $key ]['values'][ $field_path ] = $operation->before();
		}

		$repository = new WpdbSnapshotRepository( $this->wpdb );

		foreach ( $grouped as $snapshot ) {
			$provider_id = (string) $snapshot['provider'];
			$repository->create(
				SnapshotRecord::capture(
					null,
					$version_id,
					(string) $snapshot['target'],
					new ProviderSnapshot(
						$provider_id,
						(string) ( $plan->requiredProviders()[ $provider_id ] ?? 'unknown' ),
						$snapshot['values']
					),
					SnapshotKind::PLANNING,
					array(
						'omitted_sensitive_fields' => array_values( array_unique( $snapshot['sensitive'] ) ),
					)
				)
			);
		}
	}

	/**
	 * Rehydrates a checksummed immutable launch version.
	 *
	 * @param array<string,mixed>|null $row Database row.
	 */
	private function hydrate( ?array $row ): ?LaunchVersion {
		if ( null === $row ) {
			return null;
		}

		$plan = OperationPlan::fromJson( (string) $row['plan_json'] );

		if ( ! hash_equals( (string) $row['checksum'], $plan->checksum() ) ) {
			throw new \UnexpectedValueException( 'Stored launch version checksum does not match its plan.' );
		}

		return new LaunchVersion(
			(int) $row['id'],
			(int) $row['launch_id'],
			(int) $row['version_number'],
			$plan,
			(int) $row['creator_id'],
			UtcTimestamp::fromStorage( (string) $row['created_at_utc'] ),
			\ScheduledChanges\Support\EnumValue::from( \ScheduledChanges\Domain\Launch\LaunchVersionStatus::class, (string) ( $row['status'] ?? LaunchVersionStatus::DRAFT ) ),
			null === ( $row['action_scheduler_id'] ?? null ) ? null : (int) $row['action_scheduler_id'],
			$this->timestamp( $row['scheduled_at_utc'] ?? null ),
			$this->timestamp( $row['superseded_at_utc'] ?? null ),
			\ScheduledChanges\Support\EnumValue::from( \ScheduledChanges\Domain\Launch\LaunchVersionRole::class, (string) ( $row['version_role'] ?? LaunchVersionRole::START ) ),
			null === ( $row['linked_version_id'] ?? null ) ? null : (int) $row['linked_version_id']
		);
	}

	/**
	 * Converts a nullable database datetime.
	 *
	 * @param mixed $value
	 */
	private function timestamp( $value ): ?UtcTimestamp {
		return null === $value || '' === $value
			? null
			: UtcTimestamp::fromStorage( (string) $value );
	}
}
