<?php
/**
 * Immutable launch operation plan.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Domain\Launch;

use ScheduledChanges\Domain\Operation\OperationGraph;
use ScheduledChanges\Support\Json;
use ScheduledChanges\Validation\ValidationReport;

/**
 * Canonical checksummed document persisted for every launch version.
 */
final class OperationPlan {
	private int $schemaVersion;
	private OperationGraph $graph;
	private ValidationReport $validation;
	/**
	 * @var array<string, mixed>
	 */
	private array $compatibility;
	/**
	 * @var array<string, string>
	 */
	private array $requiredProviders;
	private string $humanSummary;
	/**
	 * @var array<string, string>
	 */
	private array $fingerprints;
	/**
	 * @var array<string, string>
	 */
	private array $fieldFingerprints;
	private string $checksum;
	public const SCHEMA_VERSION             = 3;
	private const SUPPORTED_SCHEMA_VERSIONS = array( 1, 2, 3 );

	/**
	 * @param array<string,mixed>  $compatibility Compatibility requirements.
	 * @param array<string,string> $requiredProviders Exact provider versions.
	 * @param array<string,string> $fingerprints Target baseline fingerprints.
	 * @param array<string,string> $fieldFingerprints Field planning fingerprints.
	 */
	private function __construct(
		int $schemaVersion,
		OperationGraph $graph,
		ValidationReport $validation,
		array $compatibility,
		array $requiredProviders,
		string $humanSummary,
		array $fingerprints,
		array $fieldFingerprints,
		string $checksum
	) {
		$this->schemaVersion     = $schemaVersion;
		$this->graph             = $graph;
		$this->validation        = $validation;
		$this->compatibility     = $compatibility;
		$this->requiredProviders = $requiredProviders;
		$this->humanSummary      = $humanSummary;
		$this->fingerprints      = $fingerprints;
		$this->fieldFingerprints = $fieldFingerprints;
		$this->checksum          = $checksum;
		if ( ! in_array( $this->schemaVersion, self::SUPPORTED_SCHEMA_VERSIONS, true ) ) {
			throw new \UnexpectedValueException( 'Unsupported operation plan schema version.' );
		}

		if ( '' === trim( $this->humanSummary ) ) {
			throw new \InvalidArgumentException( 'Immutable plans require a human-readable summary.' );
		}

		if ( 1 !== preg_match( '/^[0-9a-f]{64}$/', $this->checksum ) ) {
			throw new \InvalidArgumentException( 'Plan checksums must be SHA-256 hashes.' );
		}

		foreach ( $this->requiredProviders as $providerId => $version ) {
			if (
				1 !== preg_match( '/^[a-z][a-z0-9-]*$/', (string) $providerId )
				|| '' === trim( (string) $version )
			) {
				throw new \InvalidArgumentException( 'Plan provider requirements must contain provider slugs and versions.' );
			}
		}

		foreach ( $this->fingerprints as $reference => $fingerprint ) {
			if ( '' === trim( (string) $reference ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', (string) $fingerprint ) ) {
				throw new \InvalidArgumentException( 'Plan target fingerprints must be keyed SHA-256 hashes.' );
			}
		}

		foreach ( $this->fieldFingerprints as $reference => $fingerprint ) {
			if ( '' === trim( (string) $reference ) || 1 !== preg_match( '/^[0-9a-f]{64}$/', (string) $fingerprint ) ) {
				throw new \InvalidArgumentException( 'Plan field fingerprints must be keyed SHA-256 hashes.' );
			}
		}
	}

	/**
	 * @param array<string,mixed>  $compatibility Compatibility requirements.
	 * @param array<string,string> $requiredProviders Exact provider versions.
	 * @param array<string,string> $fingerprints Target baseline fingerprints.
	 */
	public static function create(
		OperationGraph $graph,
		ValidationReport $validation,
		array $compatibility,
		array $requiredProviders,
		string $humanSummary,
		array $fingerprints,
		int $schemaVersion = self::SCHEMA_VERSION
	): self {
		$fieldFingerprints = self::fieldFingerprintsFor( $graph );
		$payload           = self::payload(
			$schemaVersion,
			$graph,
			$validation,
			$compatibility,
			$requiredProviders,
			$humanSummary,
			$fingerprints,
			$fieldFingerprints
		);
		$checksum          = hash( 'sha256', Json::canonicalEncode( $payload ) );

		return new self(
			$schemaVersion,
			$graph,
			$validation,
			$compatibility,
			$requiredProviders,
			$humanSummary,
			$fingerprints,
			$fieldFingerprints,
			$checksum
		);
	}

	public function schemaVersion(): int {
		return $this->schemaVersion;
	}

	public function graph(): OperationGraph {
		return $this->graph;
	}

	public function validation(): ValidationReport {
		return $this->validation;
	}

	/**
	 * @return array<string,mixed>
	 */
	public function compatibility(): array {
		return $this->compatibility;
	}

	/**
	 * @return array<string,string>
	 */
	public function requiredProviders(): array {
		return $this->requiredProviders;
	}

	public function humanSummary(): string {
		return $this->humanSummary;
	}

	/**
	 * @return array<string,string>
	 */
	public function fingerprints(): array {
		return $this->fingerprints;
	}

	/**
	 * @return array<string,string>
	 */
	public function fieldFingerprints(): array {
		return $this->fieldFingerprints;
	}

	public function checksum(): string {
		return $this->checksum;
	}

	public function json(): string {
		return Json::canonicalEncode( $this->toArray() );
	}

	/**
	 * @return array<string,mixed>
	 */
	public function toArray(): array {
		return array_merge(
			self::payload(
				$this->schemaVersion,
				$this->graph,
				$this->validation,
				$this->compatibility,
				$this->requiredProviders,
				$this->humanSummary,
				$this->fingerprints,
				$this->fieldFingerprints
			),
			array( 'checksum' => $this->checksum )
		);
	}

	/**
	 * @param array<string,mixed> $data Serialized immutable plan.
	 */
	public static function fromArray( array $data ): self {
		$graph             = OperationGraph::fromArray(
			is_array( $data['graph'] ?? null ) ? $data['graph'] : array()
		);
		$validation        = ValidationReport::fromArray(
			is_array( $data['validation_summary'] ?? null ) ? $data['validation_summary'] : array()
		);
		$compatibility     = is_array( $data['compatibility'] ?? null ) ? $data['compatibility'] : array();
		$requiredProviders = self::stringMap( $data['required_providers'] ?? array() );
		$fingerprints      = self::stringMap( $data['fingerprints'] ?? array() );
		$fieldFingerprints = self::stringMap( $data['field_fingerprints'] ?? array() );
		$schemaVersion     = (int) ( $data['schema_version'] ?? 0 );
		$humanSummary      = (string) ( $data['human_summary'] ?? '' );
		$expectedChecksum  = (string) ( $data['checksum'] ?? '' );
		$plan              = self::create(
			$graph,
			$validation,
			$compatibility,
			$requiredProviders,
			$humanSummary,
			$fingerprints,
			$schemaVersion
		);

		if ( $schemaVersion >= 2 && $fieldFingerprints !== $plan->fieldFingerprints() ) {
			throw new \UnexpectedValueException( 'Immutable plan field fingerprints do not match its graph.' );
		}

		if ( ! hash_equals( $expectedChecksum, $plan->checksum() ) ) {
			throw new \UnexpectedValueException( 'Immutable plan checksum verification failed.' );
		}

		return $plan;
	}

	public static function fromJson( string $json ): self {
		return self::fromArray( Json::decodeObject( $json ) );
	}

	/**
	 * @param array<string,mixed>  $compatibility Compatibility requirements.
	 * @param array<string,string> $requiredProviders Exact provider versions.
	 * @param array<string,string> $fingerprints Target baseline fingerprints.
	 * @param array<string,string> $fieldFingerprints Field planning fingerprints.
	 * @return array<string,mixed>
	 */
	private static function payload(
		int $schemaVersion,
		OperationGraph $graph,
		ValidationReport $validation,
		array $compatibility,
		array $requiredProviders,
		string $humanSummary,
		array $fingerprints,
		array $fieldFingerprints
	): array {
		$payload = array(
			'schema_version'     => $schemaVersion,
			'graph'              => $graph->toArray( $schemaVersion ),
			'validation_summary' => $validation->toArray(),
			'compatibility'      => $compatibility,
			'required_providers' => $requiredProviders,
			'human_summary'      => $humanSummary,
			'fingerprints'       => $fingerprints,
		);

		if ( $schemaVersion >= 2 ) {
			$payload['field_fingerprints'] = $fieldFingerprints;
		}

		return $payload;
	}

	/**
	 * @return array<string,string>
	 */
	private static function fieldFingerprintsFor( OperationGraph $graph ): array {
		$fingerprints = array();

		foreach ( $graph->operations() as $operation ) {
			if ( null === $operation->fieldPath() ) {
				continue;
			}

			$key                  = $operation->targetReference() . '|' . $operation->fieldPath();
			$fingerprints[ $key ] = hash( 'sha256', Json::canonicalEncode( $operation->before() ) );
		}

		ksort( $fingerprints );

		return $fingerprints;
	}

	/**
	 * @return array<string,string>
	 * @param mixed $values
	 */
	private static function stringMap( $values ): array {
		if ( ! is_array( $values ) ) {
			return array();
		}

		$result = array();
		foreach ( $values as $key => $value ) {
			$result[ (string) $key ] = (string) $value;
		}

		return $result;
	}
}
