<?php
/**
 * Provider-aware operation plan validation.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Validation;

use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\OperationGraph;
use ScheduledChanges\Domain\Operation\TargetDefinition;
use ScheduledChanges\Field\FieldCatalog;
use ScheduledChanges\Field\FieldDefinition;
use ScheduledChanges\Field\FieldRegistry;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Provider\ObjectContext;
use ScheduledChanges\Provider\ProviderRegistry;
use ScheduledChanges\Provider\ProviderRequirementSet;
use ScheduledChanges\Support\ThrowableDiagnostic;

/**
 * Performs structural, compatibility, permission, field, and infrastructure checks.
 */
final class PlanValidator {
	private ProviderRegistry $providers;
	private FieldRegistry $fields;
	private ValidationEnvironment $environment;
	private OperationGraphValidator $graphs;
	public function __construct( ProviderRegistry $providers, FieldRegistry $fields, ?ValidationEnvironment $environment = null, ?OperationGraphValidator $graphs = null ) {
		$environment     ??= new WordPressValidationEnvironment();
		$graphs          ??= new OperationGraphValidator();
		$this->providers   = $providers;
		$this->fields      = $fields;
		$this->environment = $environment;
		$this->graphs      = $graphs;
	}

	/**
	 * @param array<string,string> $requiredProviders Exact provider versions.
	 */
	public function validate(
		OperationGraph $graph,
		array $requiredProviders,
		int $actor_id,
		bool $includeInfrastructure = true,
		bool $includeBaselines = true
	): ValidationReport {
		PublicHooks::beforeValidateLaunch( $graph );
		$issues = $this->graphs->validate( $graph )->issues();

		$this->validateProviders( $requiredProviders, $issues );
		$catalogs = $this->validateTargets( $graph, $issues, $actor_id, $includeBaselines );

		foreach ( $graph->operations() as $operation ) {
			$target = $graph->targetByUuid( $operation->targetUuid()->value() );

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

			if ( ! isset( $requiredProviders[ $operation->providerId() ] ) ) {
				$issues[] = $this->error(
					'operation.provider_requirement_missing',
					'Operation provider is missing from immutable version requirements.',
					'operations.' . $operation->uuid()->value()
				);
			}

			$this->validateOperation(
				$operation,
				$target,
				$catalogs[ $target->uuid()->value() ] ?? null,
				$actor_id,
				$issues
			);
		}

		if ( $includeInfrastructure ) {
			if ( ! $this->environment->snapshotStorageAvailable() ) {
				$issues[] = $this->error(
					'infrastructure.snapshot_storage_unavailable',
					'Snapshot storage is unavailable.',
					'infrastructure.snapshots'
				);
			}

			if ( ! $this->environment->actionSchedulerHealthy() ) {
				$issues[] = $this->error(
					'infrastructure.action_scheduler_unhealthy',
					'Action Scheduler APIs are unavailable.',
					'infrastructure.action_scheduler'
				);
			}
		}

		return new ValidationReport( $issues );
	}

	/**
	 * @param array<string,string> $requiredProviders Exact provider versions.
	 * @param ValidationIssue[]    $issues Accumulated issues.
	 */
	private function validateProviders( array $requiredProviders, array &$issues ): void {
		$report = ( new ProviderRequirementSet( $requiredProviders ) )->validate( $this->providers );

		foreach ( $report->missing() as $providerId ) {
			$issues[] = $this->error(
				'provider.missing',
				'Required provider is not active: ' . $providerId,
				'required_providers.' . $providerId
			);
		}

		foreach ( $report->changed() as $providerId => $versions ) {
			$issues[] = $this->error(
				'provider.version_changed',
				'Required provider version changed after the plan was created.',
				'required_providers.' . $providerId,
				$versions
			);
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 * @return array<string,FieldCatalog>
	 */
	private function validateTargets(
		OperationGraph $graph,
		array &$issues,
		int $actor_id,
		bool $includeBaselines
	): array {
		$catalogs = array();

		foreach ( $graph->targets() as $target ) {
			$path = 'targets.' . $target->uuid()->value();

			if ( ! $this->environment->targetExists( $target ) ) {
				$issues[] = $this->error( 'target.not_found', 'Target does not exist.', $path );
				continue;
			}

			if ( $target->future() ) {
				continue;
			}

			if ( ! $this->environment->actorCan( $actor_id, 'edit_post', $target ) ) {
				$issues[] = $this->error(
					'target.permission_denied',
					'The actor cannot edit this target.',
					$path
				);
			}

			$actualType = $this->environment->actualSubtype( $target );
			if ( '' !== $target->subtype() && $actualType !== $target->subtype() ) {
				$issues[] = $this->error(
					'target.subtype_changed',
					'Target subtype no longer matches the immutable plan.',
					$path,
					array(
						'planned' => $target->subtype(),
						'actual'  => $actualType,
					)
				);
			}

			if ( $includeBaselines ) {
				$currentFingerprint = $this->environment->targetFingerprint( $target );
				if (
					null !== $target->baselineFingerprint()
					&& null !== $currentFingerprint
					&& ! hash_equals( $target->baselineFingerprint(), $currentFingerprint )
				) {
					$issues[] = $this->error(
						'target.baseline_changed',
						'Target baseline fingerprint changed after plan creation.',
						$path
					);
				}
			}

			try {
				$catalogs[ $target->uuid()->value() ] = $this->fields->discover(
					new ObjectContext(
						$target->targetType(),
						$target->targetId(),
						$actualType
					)
				);
			} catch ( \Throwable $throwable ) {
				$issues[] = $this->error(
					'target.field_discovery_failed',
					'Field discovery failed for this target.',
					$path,
					array( 'exception' => ThrowableDiagnostic::from( $throwable ) )
				);
			}
		}

		return $catalogs;
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateOperation(
		Operation $operation,
		TargetDefinition $target,
		?FieldCatalog $catalog,
		int $actor_id,
		array &$issues
	): void {
		$path     = 'operations.' . $operation->uuid()->value();
		$provider = $this->providers->get( $operation->providerId() );

		if ( null === $provider ) {
			$issues[] = $this->error(
				'operation.provider_missing',
				'Operation provider is not active.',
				$path
			);

			return;
		}

		$context = null;
		if ( ! $target->future() ) {
			$context = new ObjectContext(
				$target->targetType(),
				$target->targetId(),
				$this->environment->actualSubtype( $target )
			);

			if ( ! $provider->supports( $context ) ) {
				$issues[] = $this->error(
					'operation.provider_no_longer_supports_target',
					'Operation provider no longer supports this target.',
					$path
				);
			}
		}

		if ( $target->future() ) {
			$capability = (string) ( $operation->validationRequirements()['capability'] ?? 'edit_post' );

			if ( ! $this->environment->actorCan( $actor_id, $capability, $target ) ) {
				$issues[] = $this->error(
					'operation.permission_denied',
					'The actor cannot perform this future-target operation.',
					$path
				);
			}

			$issues[] = new ValidationIssue(
				'operation.future_target_runtime_validation_deferred',
				'Provider value validation will run after the future target is created.',
				ValidationLevel::WARNING,
				$path
			);

			return;
		}

		if ( null === $operation->fieldPath() ) {
			$capability = (string) ( $operation->validationRequirements()['capability'] ?? '' );

			if ( '' === $capability ) {
				$capability = 'edit_post';
			}

			if ( ! $this->environment->actorCan( $actor_id, $capability, $target ) ) {
				$issues[] = $this->error(
					'operation.permission_denied',
					'The actor cannot perform this provider operation.',
					$path,
					array( 'capability' => $capability )
				);
			}

			$external = $this->environment->externalHealth( $operation );
			array_push( $issues, ...$external->issues() );

			return;
		}

		if ( null === $catalog ) {
			return;
		}

		$field = $catalog->fields()->get( $operation->fieldPath() );

		if ( null === $field ) {
			$issues[] = $this->error(
				'operation.field_missing',
				'Canonical field is no longer discoverable for the target.',
				$path
			);

			return;
		}

		if ( ! $field->writable() || ! $field->schedulable() ) {
			$issues[] = $this->error(
				'operation.field_not_schedulable',
				'Canonical field is not currently writable and schedulable.',
				$path
			);
		}

		if ( $field->dataType() !== $operation->valueType() ) {
			$issues[] = $this->error(
				'operation.field_type_changed',
				'Canonical field type changed after plan creation.',
				$path,
				array(
					'planned' => $operation->valueType(),
					'actual'  => $field->dataType(),
				)
			);
		}

		try {
			$owner = $catalog->writableOwner( $field->id() );

			if ( $owner->id() !== $operation->providerId() ) {
				$issues[] = $this->error(
					'operation.provider_ownership_changed',
					'Canonical field write ownership changed after plan creation.',
					$path,
					array(
						'planned' => $operation->providerId(),
						'actual'  => $owner->id(),
					)
				);
			}
		} catch ( \Throwable $throwable ) {
			$issues[] = $this->error(
				'operation.provider_ownership_unresolved',
				'Provider ownership could not be resolved.',
				$path,
				array( 'exception' => ThrowableDiagnostic::from( $throwable ) )
			);
		}

		if ( ! $this->environment->actorCan( $actor_id, $field->capability(), $target ) ) {
			$issues[] = $this->error(
				'operation.permission_denied',
				'The actor lacks the field capability.',
				$path,
				array( 'capability' => $field->capability() )
			);
		}

		if ( $this->environment instanceof FieldPermissionValidationEnvironment ) {
			$permission = $this->environment->fieldPermissionFailure( $actor_id, $target, $field, $operation->after() );

			if ( null !== $permission ) {
				$issues[] = $this->error(
					'operation.permission_denied',
					$permission['message'],
					$path,
					array( 'provider_code' => $permission['code'] )
				);
			}
		}

		if ( null === $context ) {
			return;
		}

		$validation = $provider->validate( $context, $field, $operation->after() );

		foreach ( $validation->errors() as $error ) {
			$issues[] = $this->error(
				'operation.provider_validation_failed',
				$error['message'],
				$path,
				array( 'provider_code' => $error['code'] )
			);
		}

		foreach ( $validation->warnings() as $warning ) {
			$issues[] = new ValidationIssue(
				'operation.provider_validation_warning',
				$warning['message'],
				ValidationLevel::WARNING,
				$path,
				array( 'provider_code' => $warning['code'] )
			);
		}

		$this->validateReferencedObjects( $operation, $target, $field, $issues );
		$external = $this->environment->externalHealth( $operation );
		array_push( $issues, ...$external->issues() );
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateReferencedObjects(
		Operation $operation,
		TargetDefinition $target,
		FieldDefinition $field,
		array &$issues
	): void {
		$path = 'operations.' . $operation->uuid()->value();

		if ( strpos( $field->id(), '.media.' ) !== false ) {
			foreach ( $this->positiveIds( $operation->after() ) as $attachmentId ) {
				if ( ! $this->environment->attachmentExists( $attachmentId ) ) {
					$issues[] = $this->error(
						'operation.attachment_missing',
						'Referenced media attachment does not exist.',
						$path,
						array( 'attachment_id' => $attachmentId )
					);
				}
			}
		}

		$taxonomy = (string) ( $field->validationRules()['taxonomy'] ?? '' );
		if ( '' !== $taxonomy ) {
			foreach ( $this->positiveIds( $operation->after() ) as $termId ) {
				if ( ! $this->environment->termExists( $termId, $taxonomy ) ) {
					$issues[] = $this->error(
						'operation.term_missing',
						'Referenced taxonomy term does not exist.',
						$path,
						array(
							'term_id'  => $termId,
							'taxonomy' => $taxonomy,
						)
					);
				}
			}
		}
	}

	/**
	 * @return int[]
	 * @param mixed $value
	 */
	private function positiveIds( $value ): array {
		$values = is_array( $value ) ? $value : array( $value );
		$ids    = array();

		foreach ( $values as $candidate ) {
			if ( is_scalar( $candidate ) && (int) $candidate > 0 ) {
				$ids[] = (int) $candidate;
			}
		}

		return array_values( array_unique( $ids ) );
	}

	/**
	 * @param array<string,mixed> $context Issue context.
	 */
	private function error(
		string $code,
		string $message,
		string $path,
		array $context = array()
	): ValidationIssue {
		return new ValidationIssue(
			$code,
			$message,
			ValidationLevel::BLOCKING_ERROR,
			$path,
			$context
		);
	}
}
