<?php
/**
 * Recording launch compiler.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChangesWooCommerce\CoreCompat\Recording;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.

use ScheduledChanges\Application\RecordingOperationGraphCompiler;
use ScheduledChanges\Application\RecordingPlanCompilationRequest;
use ScheduledChanges\Application\RecordingPlanCompilationResult;
use ScheduledChanges\Application\RecordingPlanCompilationService;
use ScheduledChanges\Application\RecordingPlanDraft;
use ScheduledChanges\Domain\Launch\ConflictPolicy;
use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\OperationFactory;
use ScheduledChanges\Domain\Operation\OperationGraphBuilder;
use ScheduledChanges\Domain\Operation\OperationType;
use ScheduledChanges\Domain\Operation\Reversibility;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Domain\Recording\RecordingSession;
use ScheduledChanges\Domain\Shared\NamedTimezone;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Field\FieldRegistry;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Provider\ProviderRegistry;
use ScheduledChanges\Recording\RecordingFinalizationResult;
use ScheduledChanges\Recording\RecordingLaunchCompilerContract;
use ScheduledChanges\Repository\LaunchRepository;
use ScheduledChanges\Repository\LaunchVersionRepository;
use ScheduledChanges\Validation\ValidationEnvironment;
use ScheduledChanges\Validation\WordPressValidationEnvironment;
use ScheduledChangesWooCommerce\CoreCompat\WooCommerce\Variations\VariationStructureModule;

/**
 * Converts selected WooCommerce recording changes through the application boundary.
 */
final class RecordingLaunchCompiler implements RecordingLaunchCompilerContract, RecordingOperationGraphCompiler {
	private FieldRegistry $fields;
	private ValidationEnvironment $environment;
	private RecordingPlanCompilationService $compilation;

	/** Creates a compiler from the active provider, field, and persistence services. */
	public function __construct(
		ProviderRegistry $providers,
		FieldRegistry $fields,
		LaunchRepository $launches,
		LaunchVersionRepository $versions,
		?ValidationEnvironment $environment = null
	) {
		$this->environment = $environment ?? new WordPressValidationEnvironment();
		$this->fields      = $fields;
		$this->compilation = new RecordingPlanCompilationService( $providers, $fields, $launches, $versions, $this->environment );
	}

	/**
	 * Finalizes selected changes into a draft launch and immutable version.
	 *
	 * @param RecordingSession    $session Recording session.
	 * @param array<string,mixed> $diff Structured diff.
	 * @param string[]            $selected_change_ids Selected change IDs.
	 */
	public function compile(
		RecordingSession $session,
		array $diff,
		array $selected_change_ids,
		string $launch_name,
		string $launch_description,
		NamedTimezone $timezone,
		int $actor_id
	): RecordingFinalizationResult {
		$changes = $this->selectedChanges( $diff, $selected_change_ids );

		if ( array() === $changes ) {
			throw new \RuntimeException( 'At least one recorded change must be selected.' );
		}

		$request = new RecordingPlanCompilationRequest(
			$session,
			$changes,
			$launch_name,
			$launch_description,
			$timezone,
			$actor_id
		);
		$result  = $this->compilation->compile( $request, $this );

		return $this->finalizationResult( $result );
	}

	/**
	 * Compiles normalized WooCommerce selected intent into an operation graph draft.
	 */
	public function compileGraph( RecordingPlanCompilationRequest $request ): RecordingPlanDraft {
		$session       = $request->session();
		$changes       = $request->selectedChanges();
		$targets       = array();
		$target_by_ref = array();
		$operations    = array();
		$factory       = new OperationFactory();
		$builder       = new OperationGraphBuilder();

		foreach ( $changes as $change ) {
			$recording_operation = (string) ( $change['recording_operation'] ?? '' );

			if ( 'create_variation' === $recording_operation ) {
				$this->addCreateVariationOperation( $session, $change, $targets, $target_by_ref, $operations, $builder );
				continue;
			}

			$source_reference = (string) $change['source_reference'];

			if ( ! isset( $target_by_ref[ $source_reference ] ) ) {
				$this->addExistingTargetForChange( $change, $targets, $target_by_ref, $builder );
			}

			$target = $target_by_ref[ $source_reference ];

			if ( 'trash_variation' === $recording_operation ) {
				$operation    = $this->trashVariationOperation( $target, $change );
				$operations[] = $operation;
				$builder->addOperation( $operation );
				continue;
			}

			if ( 'replace_taxonomy_terms_with_pending_terms' === $recording_operation ) {
				$this->addPendingTaxonomyOperations( $target, $change, $operations, $builder );
				continue;
			}

			$context = new ObjectContext(
				$target->targetType(),
				$target->targetId(),
				$target->subtype()
			);
			$field   = $this->fields->discover( $context )->fields()->get( (string) $change['field_id'] );

			if ( null === $field ) {
				throw new \RuntimeException( 'Recorded field is no longer discoverable: ' . (string) $change['field_id'] );
			}

			$operation    = $factory->fieldChange(
				$target,
				$field,
				$change['before'] ?? null,
				$change['after'] ?? null
			);
			$operations[] = $operation;
			$builder->addOperation( $operation );
		}

		$graph        = $builder->build();
		$fingerprints = array();
		foreach ( $targets as $target ) {
			if ( null !== $target->baselineFingerprint() ) {
				$fingerprints[ $target->reference() ] = $target->baselineFingerprint();
			}
		}

		return new RecordingPlanDraft(
			$graph,
			$request->selectedChangeCount(),
			array(
				'wordpress'   => get_bloginfo( 'version' ),
				'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : '',
				'php'         => PHP_VERSION,
			),
			$fingerprints
		);
	}

	/**
	 * Maps application compilation output to the existing recording result.
	 */
	private function finalizationResult( RecordingPlanCompilationResult $result ): RecordingFinalizationResult {
		return new RecordingFinalizationResult(
			$result->launchUuid(),
			$result->launchId(),
			$result->versionId(),
			$result->selectedChangeCount()
		);
	}

	/**
	 * @param array<string,mixed>            $change Structured change.
	 * @param TargetDefinition[]             $targets Launch targets.
	 * @param array<string,TargetDefinition> $target_by_ref Targets by reference.
	 */
	private function addExistingTargetForChange(
		array $change,
		array &$targets,
		array &$target_by_ref,
		OperationGraphBuilder $builder
	): void {
		$target_by_ref[ (string) $change['source_reference'] ] = $this->targetForChange( $change );
		$target    = $target_by_ref[ (string) $change['source_reference'] ];
		$targets[] = $target;
		$builder->addTarget( $target );
		$builder->addOperation( $this->snapshotOperation( $target ) );
	}

	/**
	 * @param array<string,mixed>            $change Structured change.
	 * @param TargetDefinition[]             $targets Launch targets.
	 * @param array<string,TargetDefinition> $target_by_ref Targets by reference.
	 * @param Operation[]                    $operations Launch operations.
	 */
	private function addCreateVariationOperation(
		RecordingSession $session,
		array $change,
		array &$targets,
		array &$target_by_ref,
		array &$operations,
		OperationGraphBuilder $builder
	): void {
		$parent_reference = 'product:' . $session->sourceProductId();

		if ( ! isset( $target_by_ref[ $parent_reference ] ) ) {
			$parent_target                      = $this->targetForProduct(
				$session->sourceProductId(),
				$parent_reference,
				'woocommerce-core'
			);
			$target_by_ref[ $parent_reference ] = $parent_target;
			$targets[]                          = $parent_target;
			$builder->addTarget( $parent_target );
		}

		$creation_uuid = Uuid::generate();
		$future_target = new TargetDefinition(
			Uuid::generate(),
			(string) $change['source_reference'],
			null,
			'variation',
			'variation',
			$parent_reference,
			'woocommerce-core',
			null,
			0,
			$creation_uuid
		);
		$after         = is_array( $change['after'] ?? null ) ? $change['after'] : array();
		$attributes    = is_array( $after['attributes'] ?? null ) ? $after['attributes'] : array();
		$values        = is_array( $after['values'] ?? null ) ? $after['values'] : array();
		$operation     = Operation::create(
			OperationType::CREATE_VARIATION,
			$future_target,
			null,
			'woocommerce-core',
			null,
			$after,
			'object',
			array(),
			array(
				'capability' => 'edit_products',
				'modules'    => ( new VariationStructureModule() )->requiredModules( $attributes, $values ),
			),
			array(),
			Reversibility::CONDITIONALLY_REVERSIBLE,
			ConflictPolicy::FAIL,
			array(),
			'woocommerce.create_variation',
			'woocommerce.trash_variation',
			'Create recorded variation.',
			$creation_uuid
		);

		$target_by_ref[ $future_target->reference() ] = $future_target;
		$targets[]                                    = $future_target;
		$operations[]                                 = $operation;
		$builder->addTarget( $future_target );
		$builder->addOperation( $operation );
	}

	/**
	 * @param array<string,mixed> $change Structured change.
	 */
	private function trashVariationOperation( TargetDefinition $target, array $change ): Operation {
		return Operation::create(
			OperationType::TRASH_VARIATION,
			$target,
			null,
			'woocommerce-core',
			$change['before'] ?? null,
			$change['after'] ?? array( 'mode' => 'trash' ),
			'object',
			array(),
			array( 'capability' => 'edit_products' ),
			array(),
			Reversibility::CONDITIONALLY_REVERSIBLE,
			ConflictPolicy::FAIL,
			array(),
			'woocommerce.trash_variation',
			null,
			'Trash recorded variation.'
		);
	}

	/**
	 * @param array<string,mixed> $change Structured change.
	 * @param Operation[]         $operations Launch operations.
	 */
	private function addPendingTaxonomyOperations(
		TargetDefinition $target,
		array $change,
		array &$operations,
		OperationGraphBuilder $builder
	): void {
		$after         = is_array( $change['after'] ?? null ) ? $change['after'] : array();
		$pending_terms = is_array( $after['pending_terms'] ?? null ) ? $after['pending_terms'] : array();
		$term_refs     = array();
		$dependencies  = array();
		$term_ops      = array();

		foreach ( $pending_terms as $token => $term ) {
			if ( ! is_array( $term ) ) {
				continue;
			}

			$term['token'] = (string) $token;
			$parent_token  = (string) ( $term['parent_token'] ?? '' );
			$term_deps     = isset( $term_ops[ $parent_token ] )
				? array( $term_ops[ $parent_token ]->uuid() )
				: array();
			$operation     = Operation::create(
				OperationType::CREATE_TAXONOMY_TERM,
				$target,
				null,
				'product-taxonomies',
				null,
				$term,
				'object',
				array(),
				array( 'capability' => 'edit_products' ),
				$term_deps,
				Reversibility::CONDITIONALLY_REVERSIBLE,
				ConflictPolicy::FAIL,
				array(),
					// phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Execution handler IDs are stable lowercase strings.
					'wordpress.create_taxonomy_term',
				null,
				'Create recorded taxonomy term.'
			);

			$term_refs[]        = 'term:' . (string) $token;
			$dependencies[]     = $operation->uuid();
			$term_ops[ $token ] = $operation;
			$operations[]       = $operation;
			$builder->addOperation( $operation );
		}

		$replace_after = array(
			'taxonomy'        => (string) ( $after['taxonomy'] ?? '' ),
			'term_ids'        => is_array( $after['term_ids'] ?? null ) ? $after['term_ids'] : array(),
			'term_references' => $term_refs,
		);
		$replace       = Operation::create(
			OperationType::PROVIDER_DEFINED,
			$target,
			null,
			'product-taxonomies',
			$change['before'] ?? array(),
			$replace_after,
			'object',
			array(),
			array( 'capability' => 'edit_products' ),
			$dependencies,
			Reversibility::REVERSIBLE,
			ConflictPolicy::FAIL,
			array(),
				// phpcs:ignore WordPress.WP.CapitalPDangit.MisspelledInText -- Execution handler IDs are stable lowercase strings.
				'wordpress.replace_taxonomy_terms',
			null,
			'Assign recorded taxonomy terms.'
		);

		$operations[] = $replace;
		$builder->addOperation( $replace );
	}

	/**
	 * @param array<string,mixed> $diff Structured diff.
	 * @param string[]            $selected_change_ids Selected change IDs.
	 * @return array<int,array<string,mixed>>
	 */
	private function selectedChanges( array $diff, array $selected_change_ids ): array {
		$selected = array_fill_keys( $selected_change_ids, true );
		$changes  = array();

		foreach ( is_array( $diff['changes'] ?? null ) ? $diff['changes'] : array() as $change ) {
			if ( ! is_array( $change ) ) {
				continue;
			}

			$change_id = (string) ( $change['change_id'] ?? '' );

			if ( isset( $selected[ $change_id ] ) ) {
				$changes[] = $change;
			}
		}

		return $changes;
	}

	/**
	 * @param array<string,mixed> $change Structured change.
	 */
	private function targetForChange( array $change ): TargetDefinition {
		$object_id   = (int) ( $change['object_id'] ?? 0 );
		$object_type = (string) ( $change['object_type'] ?? '' );
		$product     = wc_get_product( $object_id );

		if ( ! $product instanceof \WC_Product ) {
			throw new \RuntimeException( 'Recorded target no longer exists: ' . $object_id );
		}

		$without_fingerprint = new TargetDefinition(
			Uuid::generate(),
			(string) $change['source_reference'],
			$object_id,
			$object_type,
			$product->get_type(),
			$product instanceof \WC_Product_Variation ? 'product:' . $product->get_parent_id() : null,
			(string) $change['provider_id'],
			null
		);

		return new TargetDefinition(
			$without_fingerprint->uuid(),
			$without_fingerprint->reference(),
			$without_fingerprint->targetId(),
			$without_fingerprint->targetType(),
			$without_fingerprint->subtype(),
			$without_fingerprint->parentReference(),
			$without_fingerprint->providerId(),
			$this->environment->targetFingerprint( $without_fingerprint )
		);
	}

	/** Builds one immutable parent-product target. */
	private function targetForProduct( int $product_id, string $reference, string $provider_id ): TargetDefinition {
		$product = wc_get_product( $product_id );

		if ( ! $product instanceof \WC_Product || $product instanceof \WC_Product_Variation ) {
			throw new \RuntimeException( 'Recorded parent product no longer exists: ' . $product_id );
		}

		$without_fingerprint = new TargetDefinition(
			Uuid::generate(),
			$reference,
			$product_id,
			'product',
			$product->get_type(),
			null,
			$provider_id,
			null
		);

		return new TargetDefinition(
			$without_fingerprint->uuid(),
			$without_fingerprint->reference(),
			$without_fingerprint->targetId(),
			$without_fingerprint->targetType(),
			$without_fingerprint->subtype(),
			$without_fingerprint->parentReference(),
			$without_fingerprint->providerId(),
			$this->environment->targetFingerprint( $without_fingerprint )
		);
	}

	/** Builds the snapshot dependency for one target. */
	private function snapshotOperation( TargetDefinition $target ): Operation {
		return Operation::create(
			OperationType::CAPTURE_SNAPSHOT,
			$target,
			null,
			$target->providerId(),
			null,
			null,
			'object',
			array(),
			array( 'capability' => 'edit_products' ),
			array(),
			Reversibility::REVERSIBLE,
			ConflictPolicy::FAIL,
			array(),
			'system.capture_snapshot',
			null,
			'Capture a just-in-time snapshot for ' . $target->reference() . '.'
		);
	}
}
