<?php
/**
 * Canonical JSON helpers.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Support;

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

/**
 * Fails explicitly when structured persistence cannot be encoded or decoded.
 */
final class Json {
	/**
	 * Encodes a value for persistence.
	 *
	 * @param mixed $value Value to encode.
	 */
	public static function encode( $value ): string {
		$encoded = wp_json_encode(
			$value,
			JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION
		);

		if ( false === $encoded ) {
			throw new \JsonException( json_last_error_msg(), json_last_error() );
		}

		return $encoded;
	}

	/**
	 * Encodes a value after recursively sorting object-shaped arrays.
	 *
	 * @param mixed $value Value to encode.
	 */
	public static function canonicalEncode( $value ): string {
		self::sortRecursively( $value );

		return self::encode( $value );
	}

	/**
	 * Decodes an object-shaped JSON document.
	 *
	 * @param string|null $value JSON document.
	 * @return array<string, mixed>
	 */
	public static function decodeObject( ?string $value ): array {
		if ( null === $value || '' === $value ) {
			return array();
		}

		$decoded = json_decode( $value, true, 512, JSON_THROW_ON_ERROR );

		if ( ! is_array( $decoded ) ) {
			throw new \UnexpectedValueException( 'Expected a JSON object.' );
		}

		return $decoded;
	}

	/**
	 * Recursively sorts associative arrays in place.
	 *
	 * @param mixed $value Value to normalize.
	 */
	private static function sortRecursively( &$value ): void {
		if ( ! is_array( $value ) ) {
			return;
		}

		if ( ! \ScheduledChanges\Support\PhpCompat::isList( $value ) ) {
			ksort( $value );
		}

		foreach ( $value as &$item ) {
			self::sortRecursively( $item );
		}
		unset( $item );
	}
}
