<?php
/**
 * Plugin settings aggregate value object.
 *
 * Immutable Phase-8 top-level value object that aggregates
 * every §13 Settings tab into a single bundle. The fields
 * mirror the §13 tab structure of
 * `specifications/admin-ui-spec.md`:
 *
 *   - `providerCredentials` is the §13.4 Providers tab map,
 *     keyed by `providerKey` so callers can resolve a single
 *     provider in O(1) without scanning a list.
 *
 *   - `rendering` is the §13.5 Rendering tab bundle.
 *   - `links` is the §13.6 Links tab bundle.
 *   - `sync` is the §13.7 Sync tab bundle.
 *   - `cache` is the §13.8 Cache tab bundle.
 *   - `debugMode` is the §13.9 Advanced "Debug Mode" toggle.
 *     Other §13.9 axes (log level, feature flags, retention)
 *     are deferred to follow-up slices.
 *
 * Per `specifications/security-and-compliance.md` §6.3 / §6.4
 * the {@see self::toArray()} serialization carries the raw
 * provider-credential values verbatim because that is what
 * the OptionStore must persist; UI / REST surfaces MUST use
 * {@see self::toMaskedArray()} instead, which substitutes the
 * §6.3-masked form for every credential value.
 *
 * Instances are immutable; per-field withers return fresh
 * instances. Persistence shape is canonicalized by
 * {@see self::toArray()} / {@see self::fromArray()}.
 *
 * @package CommerceLinkBuilder\Application\Settings
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Settings;

use InvalidArgumentException;

/**
 * Immutable §13 top-level plugin settings aggregate.
 *
 * See the file-level docblock for the tab-by-tab mapping and
 * the §6.3 / §6.4 rule that gates {@see self::toMaskedArray()}.
 */
final class PluginSettings {

	/**
	 * Provider credentials map, keyed by provider key.
	 *
	 * @var array<string, ProviderCredentials>
	 */
	private array $providerCredentials;

	/**
	 * §13.5 Rendering tab settings.
	 */
	private RenderingDefaults $rendering;

	/**
	 * §13.6 Links tab settings.
	 */
	private LinkDefaults $links;

	/**
	 * §13.7 Sync tab settings.
	 */
	private SyncSettings $sync;

	/**
	 * §13.8 Cache tab settings.
	 */
	private CacheSettings $cache;

	/**
	 * §13.9 Advanced "Debug Mode" toggle.
	 */
	private bool $debugMode;

	/**
	 * Construct a plugin-settings snapshot with explicit values
	 * for every field.
	 *
	 * @param array<string, ProviderCredentials> $providerCredentials Provider credentials map.
	 * @param RenderingDefaults                  $rendering           §13.5 Rendering tab settings.
	 * @param LinkDefaults                       $links               §13.6 Links tab settings.
	 * @param SyncSettings                       $sync                §13.7 Sync tab settings.
	 * @param CacheSettings                      $cache               §13.8 Cache tab settings.
	 * @param bool                               $debugMode           §13.9 Debug Mode toggle.
	 *
	 * @throws InvalidArgumentException When the
	 *                                  `$providerCredentials`
	 *                                  map carries a non-string
	 *                                  key, a non-{@see ProviderCredentials}
	 *                                  value, or a key that
	 *                                  disagrees with its
	 *                                  entry's
	 *                                  {@see ProviderCredentials::providerKey()}.
	 */
	public function __construct(
		array $providerCredentials,
		RenderingDefaults $rendering,
		LinkDefaults $links,
		SyncSettings $sync,
		CacheSettings $cache,
		bool $debugMode
	) {
		$this->providerCredentials = self::normalizeProviderCredentials( $providerCredentials );
		$this->rendering           = $rendering;
		$this->links               = $links;
		$this->sync                = $sync;
		$this->cache               = $cache;
		$this->debugMode           = $debugMode;
	}

	/**
	 * Construct a plugin-settings snapshot with the canonical
	 * §13 baseline values: empty provider-credentials map and
	 * defaults for every sub-component.
	 */
	public static function defaults(): self {
		return new self(
			array(),
			RenderingDefaults::defaults(),
			LinkDefaults::defaults(),
			SyncSettings::defaults(),
			CacheSettings::defaults(),
			false
		);
	}

	/**
	 * Provider credentials map, keyed by provider key.
	 *
	 * @return array<string, ProviderCredentials>
	 */
	public function providerCredentials(): array {
		return $this->providerCredentials;
	}

	/**
	 * Look up a single provider's credentials by key.
	 *
	 * Returns null when the key is absent so callers can
	 * distinguish "not configured" from "configured with empty
	 * secrets".
	 *
	 * @param string $key Provider key.
	 */
	public function provider( string $key ): ?ProviderCredentials {
		if ( ! array_key_exists( $key, $this->providerCredentials ) ) {
			return null;
		}

		return $this->providerCredentials[ $key ];
	}

	/**
	 * §13.5 Rendering tab settings.
	 */
	public function rendering(): RenderingDefaults {
		return $this->rendering;
	}

	/**
	 * §13.6 Links tab settings.
	 */
	public function links(): LinkDefaults {
		return $this->links;
	}

	/**
	 * §13.7 Sync tab settings.
	 */
	public function sync(): SyncSettings {
		return $this->sync;
	}

	/**
	 * §13.8 Cache tab settings.
	 */
	public function cache(): CacheSettings {
		return $this->cache;
	}

	/**
	 * §13.9 Debug Mode toggle.
	 */
	public function debugMode(): bool {
		return $this->debugMode;
	}

	/**
	 * Produce a copy with the supplied provider's credentials
	 * inserted or replaced.
	 *
	 * The map is keyed by
	 * {@see ProviderCredentials::providerKey()}, so calling
	 * this with an entry whose key already exists overwrites
	 * the previous value.
	 *
	 * @param ProviderCredentials $credentials Credentials to insert.
	 */
	public function withProviderCredentials( ProviderCredentials $credentials ): self {
		$clone = clone $this;
		$clone->providerCredentials[ $credentials->providerKey() ] = $credentials;
		return $clone;
	}

	/**
	 * Produce a copy with the supplied provider removed from
	 * the credentials map.
	 *
	 * Removing an absent key is a no-op (the resulting copy
	 * equals the receiver under {@see self::equals()}).
	 *
	 * @param string $key Provider key to remove.
	 */
	public function withoutProvider( string $key ): self {
		$clone = clone $this;
		unset( $clone->providerCredentials[ $key ] );
		return $clone;
	}

	/**
	 * Produce a copy with the §13.5 Rendering tab settings
	 * replaced.
	 *
	 * @param RenderingDefaults $rendering New rendering settings.
	 */
	public function withRendering( RenderingDefaults $rendering ): self {
		$clone            = clone $this;
		$clone->rendering = $rendering;
		return $clone;
	}

	/**
	 * Produce a copy with the §13.6 Links tab settings
	 * replaced.
	 *
	 * @param LinkDefaults $links New link settings.
	 */
	public function withLinks( LinkDefaults $links ): self {
		$clone        = clone $this;
		$clone->links = $links;
		return $clone;
	}

	/**
	 * Produce a copy with the §13.7 Sync tab settings replaced.
	 *
	 * @param SyncSettings $sync New sync settings.
	 */
	public function withSync( SyncSettings $sync ): self {
		$clone       = clone $this;
		$clone->sync = $sync;
		return $clone;
	}

	/**
	 * Produce a copy with the §13.8 Cache tab settings
	 * replaced.
	 *
	 * @param CacheSettings $cache New cache settings.
	 */
	public function withCache( CacheSettings $cache ): self {
		$clone        = clone $this;
		$clone->cache = $cache;
		return $clone;
	}

	/**
	 * Produce a copy with the §13.9 Debug Mode toggle replaced.
	 *
	 * @param bool $debugMode New toggle.
	 */
	public function withDebugMode( bool $debugMode ): self {
		$clone            = clone $this;
		$clone->debugMode = $debugMode;
		return $clone;
	}

	/**
	 * Report whether this snapshot equals the supplied one.
	 *
	 * Equality is structural: every sub-component must compare
	 * equal under its own `equals()` and the provider
	 * credentials map must agree on keys + per-entry equality.
	 *
	 * @param self $other Other snapshot to compare against.
	 */
	public function equals( self $other ): bool {
		if ( $this->debugMode !== $other->debugMode ) {
			return false;
		}

		if ( ! $this->rendering->equals( $other->rendering ) ) {
			return false;
		}

		if ( ! $this->links->equals( $other->links ) ) {
			return false;
		}

		if ( ! $this->sync->equals( $other->sync ) ) {
			return false;
		}

		if ( ! $this->cache->equals( $other->cache ) ) {
			return false;
		}

		if ( count( $this->providerCredentials ) !== count( $other->providerCredentials ) ) {
			return false;
		}

		foreach ( $this->providerCredentials as $key => $credentials ) {
			if ( ! array_key_exists( $key, $other->providerCredentials ) ) {
				return false;
			}

			if ( ! $credentials->equals( $other->providerCredentials[ $key ] ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Serialize this snapshot for OptionStore persistence.
	 *
	 * The provider-credentials sub-array carries the raw secret
	 * values; UI / REST surfaces MUST use
	 * {@see self::toMaskedArray()} instead.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		$providers = array();
		foreach ( $this->providerCredentials as $key => $credentials ) {
			$providers[ $key ] = $credentials->toArray();
		}

		return array(
			'provider_credentials' => $providers,
			'rendering'            => $this->rendering->toArray(),
			'links'                => $this->links->toArray(),
			'sync'                 => $this->sync->toArray(),
			'cache'                => $this->cache->toArray(),
			'debug_mode'           => $this->debugMode,
		);
	}

	/**
	 * Serialize this snapshot for UI / REST surfaces.
	 *
	 * Identical to {@see self::toArray()} except that every
	 * provider-credentials entry's `secrets` map is replaced by
	 * its §6.3-masked form. Non-credential fields are emitted
	 * verbatim because they do not carry secret material.
	 *
	 * @return array<string, mixed>
	 */
	public function toMaskedArray(): array {
		$providers = array();
		foreach ( $this->providerCredentials as $key => $credentials ) {
			$providers[ $key ] = array(
				'provider_key' => $credentials->providerKey(),
				'enabled'      => $credentials->enabled(),
				'secrets'      => $credentials->masked(),
			);
		}

		return array(
			'provider_credentials' => $providers,
			'rendering'            => $this->rendering->toArray(),
			'links'                => $this->links->toArray(),
			'sync'                 => $this->sync->toArray(),
			'cache'                => $this->cache->toArray(),
			'debug_mode'           => $this->debugMode,
		);
	}

	/**
	 * Construct a snapshot from a {@see self::toArray()} payload.
	 *
	 * Performs full structural validation: missing keys, wrong
	 * types, and the same per-field rules each sub-component's
	 * `fromArray()` enforces all surface as
	 * {@see InvalidArgumentException}.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 *
	 * @throws InvalidArgumentException When `$data` is malformed.
	 */
	public static function fromArray( array $data ): self {
		if ( ! array_key_exists( 'provider_credentials', $data ) || ! is_array( $data['provider_credentials'] ) ) {
			$message = 'PluginSettings payload must carry an array `provider_credentials`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'rendering', $data ) || ! is_array( $data['rendering'] ) ) {
			$message = 'PluginSettings payload must carry an array `rendering`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'links', $data ) || ! is_array( $data['links'] ) ) {
			$message = 'PluginSettings payload must carry an array `links`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'sync', $data ) || ! is_array( $data['sync'] ) ) {
			$message = 'PluginSettings payload must carry an array `sync`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'cache', $data ) || ! is_array( $data['cache'] ) ) {
			$message = 'PluginSettings payload must carry an array `cache`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'debug_mode', $data ) || ! is_bool( $data['debug_mode'] ) ) {
			$message = 'PluginSettings payload must carry a bool `debug_mode`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$providers = array();
		foreach ( $data['provider_credentials'] as $key => $entry ) {
			if ( ! is_string( $key ) ) {
				$message = 'PluginSettings provider_credentials keys must be strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! is_array( $entry ) ) {
				$message = sprintf(
					'PluginSettings provider_credentials entry `%s` must be an array.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$providers[ $key ] = ProviderCredentials::fromArray( $entry );
		}

		return new self(
			$providers,
			RenderingDefaults::fromArray( $data['rendering'] ),
			LinkDefaults::fromArray( $data['links'] ),
			SyncSettings::fromArray( $data['sync'] ),
			CacheSettings::fromArray( $data['cache'] ),
			$data['debug_mode']
		);
	}

	/**
	 * Validate and normalize the `providerCredentials` map at
	 * construction.
	 *
	 * Keys must be strings that match the
	 * {@see ProviderCredentials::providerKey()} of their entry;
	 * entries must be {@see ProviderCredentials} instances.
	 *
	 * @param array<int|string, mixed> $providerCredentials Raw map.
	 *
	 * @return array<string, ProviderCredentials>
	 *
	 * @throws InvalidArgumentException When any per-entry rule
	 *                                  is violated.
	 */
	private static function normalizeProviderCredentials( array $providerCredentials ): array {
		$result = array();
		foreach ( $providerCredentials as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = 'PluginSettings providerCredentials keys must be strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! $value instanceof ProviderCredentials ) {
				$message = sprintf(
					'PluginSettings providerCredentials entry `%s` must be a ProviderCredentials instance.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $key !== $value->providerKey() ) {
				$message = sprintf(
					'PluginSettings providerCredentials key `%s` must match entry providerKey `%s`.',
					$key,
					$value->providerKey()
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$result[ $key ] = $value;
		}

		return $result;
	}
}
