<?php
/**
 * Immutable operation dependency graph.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Domain\Operation;

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

/**
 * Directed acyclic graph input with deterministic replay ordering.
 */
final class OperationGraph {
	/**
	 * @var TargetDefinition[]
	 */
	private array $targets;
	/**
	 * @var Operation[]
	 */
	private array $operations;
	/**
	 * @var array<string,TargetDefinition>
	 */
	private array $targetsByUuid = array();

	/**
	 * @var array<string,TargetDefinition>
	 */
	private array $targetsByReference = array();

	/**
	 * @var array<string,Operation>
	 */
	private array $operationsByUuid = array();

	/**
	 * @param TargetDefinition[] $targets Graph targets.
	 * @param Operation[]        $operations Graph operations.
	 */
	public function __construct(
		array $targets,
		array $operations
	) {
		$this->targets    = $targets;
		$this->operations = $operations;
		foreach ( $this->targets as $target ) {
			if ( ! $target instanceof TargetDefinition ) {
				throw new \InvalidArgumentException( 'Operation graphs accept only TargetDefinition targets.' );
			}

			$uuid      = $target->uuid()->value();
			$reference = $target->reference();

			if ( isset( $this->targetsByUuid[ $uuid ] ) || isset( $this->targetsByReference[ $reference ] ) ) {
				throw new \InvalidArgumentException( 'Operation graph targets must have unique UUIDs and references.' );
			}

			$this->targetsByUuid[ $uuid ]           = $target;
			$this->targetsByReference[ $reference ] = $target;
		}

		foreach ( $this->operations as $operation ) {
			if ( ! $operation instanceof Operation ) {
				throw new \InvalidArgumentException( 'Operation graphs accept only Operation objects.' );
			}

			$uuid = $operation->uuid()->value();

			if ( isset( $this->operationsByUuid[ $uuid ] ) ) {
				throw new \InvalidArgumentException( 'Operation UUIDs must be unique within a graph.' );
			}

			$this->operationsByUuid[ $uuid ] = $operation;
		}
	}

	/**
	 * @return TargetDefinition[]
	 */
	public function targets(): array {
		return $this->targets;
	}

	/**
	 * @return Operation[]
	 */
	public function operations(): array {
		return $this->operations;
	}

	public function targetByUuid( string $uuid ): ?TargetDefinition {
		return $this->targetsByUuid[ $uuid ] ?? null;
	}

	public function targetByReference( string $reference ): ?TargetDefinition {
		return $this->targetsByReference[ $reference ] ?? null;
	}

	public function operation( string $uuid ): ?Operation {
		return $this->operationsByUuid[ $uuid ] ?? null;
	}

	/**
	 * Returns dependency-safe operation order with UUID tie breaking.
	 *
	 * @return Operation[]
	 */
	public function topologicalOrder(): array {
		$inDegree = array_fill_keys( array_keys( $this->operationsByUuid ), 0 );
		$children = array_fill_keys( array_keys( $this->operationsByUuid ), array() );

		foreach ( $this->operations as $operation ) {
			$uuid = $operation->uuid()->value();

			foreach ( $operation->dependencies() as $dependency ) {
				if ( ! isset( $this->operationsByUuid[ $dependency ] ) ) {
					throw new \DomainException( 'Operation dependency is missing: ' . $dependency );
				}

				++$inDegree[ $uuid ];
				$children[ $dependency ][] = $uuid;
			}
		}

		$ready = array_keys(
			array_filter(
				$inDegree,
				static fn ( int $degree ): bool => 0 === $degree
			)
		);
		sort( $ready );
		$ordered = array();

		while ( array() !== $ready ) {
			$uuid      = array_shift( $ready );
			$ordered[] = $this->operationsByUuid[ $uuid ];

			sort( $children[ $uuid ] );
			foreach ( $children[ $uuid ] as $child ) {
				--$inDegree[ $child ];

				if ( 0 === $inDegree[ $child ] ) {
					$ready[] = $child;
					sort( $ready );
				}
			}
		}

		if ( count( $ordered ) !== count( $this->operations ) ) {
			throw new \DomainException( 'Operation graph contains a dependency cycle.' );
		}

		return $ordered;
	}

	public function dependsOn( Operation $operation, string $ancestorUuid ): bool {
		$pending = $operation->dependencies();
		$visited = array();

		while ( array() !== $pending ) {
			$dependency = array_pop( $pending );

			if ( $dependency === $ancestorUuid ) {
				return true;
			}

			if ( isset( $visited[ $dependency ] ) ) {
				continue;
			}

			$visited[ $dependency ] = true;
			$parent                 = $this->operation( $dependency );

			if ( null !== $parent ) {
				array_push( $pending, ...$parent->dependencies() );
			}
		}

		return false;
	}

	/**
	 * @return array<string,mixed>
	 */
	public function toArray( int $schema_version = 3 ): array {
		unset( $schema_version );

		$targets = $this->targets;
		usort(
			$targets,
			static fn ( TargetDefinition $left, TargetDefinition $right ): int => strcmp(
				$left->uuid()->value(),
				$right->uuid()->value()
			)
		);
		$operations = $this->operations;
		usort(
			$operations,
			static fn ( Operation $left, Operation $right ): int => strcmp(
				$left->uuid()->value(),
				$right->uuid()->value()
			)
		);

		return array(
			'targets'    => array_map(
				static fn ( TargetDefinition $target ): array => $target->toArray(),
				$targets
			),
			'operations' => array_map(
				static fn ( Operation $operation ): array => $operation->toArray(),
				$operations
			),
		);
	}

	/**
	 * @param array<string,mixed> $data Serialized graph.
	 */
	public static function fromArray( array $data ): self {
		$targets = array();
		foreach ( is_array( $data['targets'] ?? null ) ? $data['targets'] : array() as $target ) {
			if ( is_array( $target ) ) {
				$targets[] = TargetDefinition::fromArray( $target );
			}
		}

		$operations = array();
		foreach ( is_array( $data['operations'] ?? null ) ? $data['operations'] : array() as $operation ) {
			if ( is_array( $operation ) ) {
				$operations[] = Operation::fromArray( $operation );
			}
		}

		return new self( $targets, $operations );
	}
}
