<?php
/**
 * Operation graph structural validation.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Validation;

use ScheduledChanges\Domain\Operation\Operation;
use ScheduledChanges\Domain\Operation\OperationGraph;
use ScheduledChanges\Domain\Operation\OperationType;

/**
 * Rejects graphs that cannot be replayed deterministically and safely.
 */
final class OperationGraphValidator {
	public function validate( OperationGraph $graph ): ValidationReport {
		$issues = array();

		$this->validateReferences( $graph, $issues );
		$this->validateIdempotency( $graph, $issues );
		$this->validateTopology( $graph, $issues );
		$this->validateFutureTargets( $graph, $issues );
		$this->validateWriters( $graph, $issues );
		$this->validateSnapshotOrdering( $graph, $issues );
		$this->validateCacheOrdering( $graph, $issues );

		return new ValidationReport( $issues );
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateReferences( OperationGraph $graph, array &$issues ): void {
		foreach ( $graph->operations() as $operation ) {
			$target = $graph->targetByUuid( $operation->targetUuid()->value() );

			if ( null === $target || $target->reference() !== $operation->targetReference() ) {
				$issues[] = $this->error(
					'graph.missing_target',
					'Operation target UUID and reference do not resolve to the same target.',
					$this->path( $operation )
				);
			}

			foreach ( $operation->dependencies() as $dependency ) {
				if ( null === $graph->operation( $dependency ) ) {
					$issues[] = $this->error(
						'graph.missing_dependency',
						'Operation dependency does not exist in the immutable graph.',
						$this->path( $operation ),
						array( 'dependency_uuid' => $dependency )
					);
				}
			}
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateIdempotency( OperationGraph $graph, array &$issues ): void {
		$seen = array();

		foreach ( $graph->operations() as $operation ) {
			$key = $operation->idempotencyKey();

			if ( isset( $seen[ $key ] ) ) {
				$issues[] = $this->error(
					'graph.duplicate_idempotency_key',
					'Operation idempotency keys must be unique within a plan.',
					$this->path( $operation ),
					array( 'first_operation_uuid' => $seen[ $key ] )
				);
				continue;
			}

			$seen[ $key ] = $operation->uuid()->value();
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateTopology( OperationGraph $graph, array &$issues ): void {
		try {
			$graph->topologicalOrder();
		} catch ( \DomainException $exception ) {
			$issues[] = $this->error(
				'graph.cycle_or_missing_dependency',
				$exception->getMessage(),
				'operations'
			);
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateFutureTargets( OperationGraph $graph, array &$issues ): void {
		foreach ( $graph->targets() as $target ) {
			if ( ! $target->future() ) {
				continue;
			}

			$creation_operation_uuid = $target->creationOperationUuid();
			$creator_uuid            = null === $creation_operation_uuid ? null : $creation_operation_uuid->value();
			$creator                 = null === $creator_uuid ? null : $graph->operation( $creator_uuid );

			if (
				null === $creator
				|| ! OperationType::createsFutureTarget( $creator->type() )
				|| $creator->targetUuid()->value() !== $target->uuid()->value()
			) {
				$issues[] = $this->error(
					'graph.invalid_future_target_creator',
					'Future targets must reference a matching creation operation.',
					'targets.' . $target->uuid()->value()
				);
				continue;
			}

			foreach ( $graph->operations() as $operation ) {
				if (
					$operation->targetUuid()->value() !== $target->uuid()->value()
					|| $operation->uuid()->value() === $creator_uuid
				) {
					continue;
				}

				if ( ! $graph->dependsOn( $operation, $creator_uuid ) ) {
					$issues[] = $this->error(
						'graph.future_target_dependency_missing',
						'Operations targeting a future object must depend on its creation operation.',
						$this->path( $operation ),
						array( 'creation_operation_uuid' => $creator_uuid )
					);
				}
			}
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateWriters( OperationGraph $graph, array &$issues ): void {
		$writers = array();

		foreach ( $graph->operations() as $operation ) {
			if ( ! OperationType::writesField( $operation->type() ) || null === $operation->fieldPath() ) {
				continue;
			}

			$key = $operation->targetUuid()->value() . '|' . $operation->fieldPath();

			if ( isset( $writers[ $key ] ) ) {
				$issues[] = $this->error(
					'graph.conflicting_field_writers',
					'One immutable plan cannot contain multiple writers for the same target field.',
					$this->path( $operation ),
					array( 'first_operation_uuid' => $writers[ $key ] )
				);
				continue;
			}

			$writers[ $key ] = $operation->uuid()->value();
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateSnapshotOrdering( OperationGraph $graph, array &$issues ): void {
		$snapshots = array();

		foreach ( $graph->operations() as $operation ) {
			if ( OperationType::CAPTURE_SNAPSHOT === $operation->type() ) {
				$snapshots[ $operation->targetUuid()->value() ][] = $operation->uuid()->value();
			}
		}

		foreach ( $graph->operations() as $operation ) {
			if ( ! OperationType::destructive( $operation->type() ) ) {
				continue;
			}

			$targetSnapshots = $snapshots[ $operation->targetUuid()->value() ] ?? array();
			$ordered         = false;

			foreach ( $targetSnapshots as $snapshotUuid ) {
				if ( $graph->dependsOn( $operation, $snapshotUuid ) ) {
					$ordered = true;
					break;
				}
			}

			if ( ! $ordered ) {
				$issues[] = $this->error(
					'graph.snapshot_must_precede_destructive_operation',
					'Destructive operations must depend on a target snapshot.',
					$this->path( $operation )
				);
			}
		}
	}

	/**
	 * @param ValidationIssue[] $issues Accumulated issues.
	 */
	private function validateCacheOrdering( OperationGraph $graph, array &$issues ): void {
		foreach ( $graph->operations() as $cacheOperation ) {
			if ( OperationType::CLEAR_CACHE !== $cacheOperation->type() ) {
				continue;
			}

			foreach ( $graph->operations() as $mutation ) {
				if (
					! OperationType::mutatesTarget( $mutation->type() )
					|| $mutation->targetUuid()->value() !== $cacheOperation->targetUuid()->value()
				) {
					continue;
				}

				if ( ! $graph->dependsOn( $cacheOperation, $mutation->uuid()->value() ) ) {
					$issues[] = $this->error(
						'graph.cache_clear_too_early',
						'Cache clearing must depend on every mutation for the same target.',
						$this->path( $cacheOperation ),
						array( 'missing_dependency_uuid' => $mutation->uuid()->value() )
					);
				}
			}
		}
	}

	private function path( Operation $operation ): string {
		return 'operations.' . $operation->uuid()->value();
	}

	/**
	 * @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
		);
	}
}
