<?php
/**
 * Settings form parser.
 *
 * Phase-8 slice 8c-2 pure-function helper that converts a
 * `$_POST`-shaped array (rooted at
 * {@see SettingsFormToken::FORM_FIELD_PREFIX}) into a
 * {@see PluginSettings} instance the
 * {@see \CommerceLinkBuilder\Application\Settings\UseCase\SaveSettingsUseCase}
 * can consume. The parser is structural only — it does NOT
 * apply the §6.3 "回転入力" (rotating-input) rule for empty
 * provider-secret fields; that is the use case's responsibility
 * downstream.
 *
 * Graceful-fallback strategy:
 *
 *   - Each VO's constructor enforces its own structural
 *     invariants (TTL ordering, closed-set policy values,
 *     non-empty defaultTemplate, ...). If construction throws
 *     {@see \InvalidArgumentException}, the parser silently
 *     falls back to the corresponding section of the supplied
 *     `$current` snapshot. This is deliberately permissive for
 *     the Phase-8 minimum so a partially-malformed POST never
 *     blocks the user; field-level validation messaging is
 *     deferred to slice 8e or Phase 11.
 *
 *   - For unchecked checkboxes the absent boolean key is read
 *     as `false` (standard WordPress checkbox semantics) — the
 *     VO defaults are NOT used as fallback for booleans because
 *     a deliberate uncheck should win over the prior value.
 *
 *   - Numeric strings (e.g. `'3600'`) are coerced via
 *     `filter_var(..., FILTER_VALIDATE_INT)`. On failure the
 *     parser falls back to `$current`'s integer value so an
 *     invalid numeric input doesn't crash the save.
 *
 *   - Sub-arrays that arrive non-array (e.g.
 *     `clb_settings[providers]` is a string) are treated as
 *     missing and the corresponding section of `$current`
 *     wins.
 *
 * Provider-section schema (per §13.4):
 *
 *   - `amazon`  — `enabled`, `access_key`, `secret_key`,
 *                 `associate_tag`.
 *   - `rakuten` — `enabled`, `application_id`, `affiliate_id`.
 *   - `yahoo`   — `enabled`, `app_id`.
 *
 * Per §6.3 / §6.4 secret values are passed verbatim from the
 * POST through to the {@see ProviderCredentials} constructor;
 * the rotation rule is applied downstream in the SaveSettings
 * use case so this parser never branches on whether a value is
 * empty or not.
 *
 * @package CommerceLinkBuilder\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin\Page;

use CommerceLinkBuilder\Application\Settings\CacheSettings;
use CommerceLinkBuilder\Application\Settings\LinkDefaults;
use CommerceLinkBuilder\Application\Settings\PluginSettings;
use CommerceLinkBuilder\Application\Settings\ProviderCredentials;
use CommerceLinkBuilder\Application\Settings\RenderingDefaults;
use CommerceLinkBuilder\Application\Settings\SyncSettings;
use Throwable;

/**
 * Pure-function parser that converts a `$_POST`-shaped array
 * into a {@see PluginSettings} instance, falling back to the
 * supplied `$current` snapshot on any structural failure.
 *
 * See the file-level docblock for the graceful-fallback
 * strategy, the checkbox-absence semantics, and the
 * provider-section schema this parser knows about.
 */
final class SettingsFormParser {

	/**
	 * Per-provider secret-field schema.
	 *
	 * Keyed by provider key; each entry lists the secret-field
	 * names the {@see ProviderCredentials} of that provider
	 * carries.
	 *
	 * @var array<string, list<string>>
	 */
	private const PROVIDER_SECRET_FIELDS = array(
		'amazon'         => array( 'credential_id', 'credential_secret', 'version', 'partner_tag', 'marketplace' ),
		'rakuten'        => array( 'application_id', 'affiliate_id' ),
		'yahoo_shopping' => array( 'app_id' ),
	);

	/**
	 * Convert the supplied POST-shaped array into a
	 * {@see PluginSettings} instance.
	 *
	 * `$postData` MUST already be rooted at the
	 * {@see SettingsFormToken::FORM_FIELD_PREFIX} key (i.e.
	 * `$_POST['clb_settings'] ?? array()`); the parser does not
	 * unwrap the prefix itself.
	 *
	 * @param array<string, mixed> $postData Form payload rooted at the form-field prefix.
	 * @param PluginSettings       $current  Currently persisted snapshot used as fallback.
	 */
	public function parse( array $postData, PluginSettings $current ): PluginSettings {
		$rendering = $this->parseRendering( $this->subArray( $postData, 'rendering' ), $current->rendering() );
		$links     = $this->parseLinks( $this->subArray( $postData, 'links' ), $current->links() );
		$sync      = $this->parseSync( $this->subArray( $postData, 'sync' ), $current->sync() );
		$cache     = $this->parseCache( $this->subArray( $postData, 'cache' ), $current->cache() );

		$debugMode = $this->readBool( $postData, 'debug_mode' );

		$providers = $this->parseProviders(
			$this->subArray( $postData, 'providers' ),
			$current->providerCredentials()
		);

		try {
			return new PluginSettings( $providers, $rendering, $links, $sync, $cache, $debugMode );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $current;
		}
	}

	/**
	 * Build a {@see RenderingDefaults} from a section sub-array,
	 * falling back to `$current` on construction failure.
	 *
	 * @param array<string, mixed> $section Section sub-array.
	 * @param RenderingDefaults    $current Current snapshot.
	 */
	private function parseRendering( array $section, RenderingDefaults $current ): RenderingDefaults {
		try {
			return new RenderingDefaults(
				$this->readString( $section, 'default_template', $current->defaultTemplate() ),
				$this->readBool( $section, 'theme_override_enabled' ),
				$this->readBool( $section, 'show_provider_badge_by_default' ),
				$this->readBool( $section, 'show_disclaimer_by_default' ),
				$this->readBool( $section, 'lazy_load_images' ),
				$this->readString( $section, 'placeholder_image_policy', $current->placeholderImagePolicy() )
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $current;
		}
	}

	/**
	 * Build a {@see LinkDefaults} from a section sub-array,
	 * falling back to `$current` on construction failure.
	 *
	 * @param array<string, mixed> $section Section sub-array.
	 * @param LinkDefaults         $current Current snapshot.
	 */
	private function parseLinks( array $section, LinkDefaults $current ): LinkDefaults {
		try {
			return new LinkDefaults(
				$this->readBool( $section, 'open_in_new_tab_default' ),
				$this->readBool( $section, 'add_nofollow' ),
				$this->readBool( $section, 'add_sponsored' ),
				$this->readBool( $section, 'add_noopener' ),
				$this->readBool( $section, 'add_noreferrer' ),
				$this->readBool( $section, 'tracking_enabled_default' )
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $current;
		}
	}

	/**
	 * Build a {@see SyncSettings} from a section sub-array,
	 * falling back to `$current` on construction failure.
	 *
	 * @param array<string, mixed> $section Section sub-array.
	 * @param SyncSettings         $current Current snapshot.
	 */
	private function parseSync( array $section, SyncSettings $current ): SyncSettings {
		try {
			return new SyncSettings(
				$this->readBool( $section, 'enabled' ),
				$this->readBool( $section, 'scheduled_sync_enabled' ),
				$this->readInt( $section, 'fresh_ttl_seconds', $current->freshTtlSeconds() ),
				$this->readInt( $section, 'stale_ttl_seconds', $current->staleTtlSeconds() ),
				$this->readInt( $section, 'expired_ttl_seconds', $current->expiredTtlSeconds() ),
				$this->readInt( $section, 'max_concurrent_jobs', $current->maxConcurrentJobs() ),
				$this->readInt( $section, 'batch_size', $current->batchSize() ),
				$this->readString( $section, 'retry_policy_preset', $current->retryPolicyPreset() )
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $current;
		}
	}

	/**
	 * Build a {@see CacheSettings} from a section sub-array,
	 * falling back to `$current` on construction failure.
	 *
	 * @param array<string, mixed> $section Section sub-array.
	 * @param CacheSettings        $current Current snapshot.
	 */
	private function parseCache( array $section, CacheSettings $current ): CacheSettings {
		try {
			return new CacheSettings(
				$this->readBool( $section, 'search_cache_enabled' ),
				$this->readInt( $section, 'search_cache_ttl_seconds', $current->searchCacheTtlSeconds() ),
				$this->readInt( $section, 'lookup_cache_ttl_seconds', $current->lookupCacheTtlSeconds() ),
				$this->readBool( $section, 'render_cache_enabled' ),
				$this->readInt( $section, 'render_cache_ttl_seconds', $current->renderCacheTtlSeconds() ),
				$this->readInt(
					$section,
					'provider_health_cache_ttl_seconds',
					$current->providerHealthCacheTtlSeconds()
				)
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $current;
		}
	}

	/**
	 * Parse the `providers` sub-array into the
	 * {@see PluginSettings::providerCredentials()} map.
	 *
	 * Per §6.3 / §6.4 the secret values are passed verbatim;
	 * the rotation rule is applied downstream in the
	 * SaveSettings use case.
	 *
	 * Falls back to `$current` for any provider whose section is
	 * malformed or whose {@see ProviderCredentials} construction
	 * throws.
	 *
	 * @param array<string, mixed>               $section Section sub-array.
	 * @param array<string, ProviderCredentials> $current Current credentials map.
	 *
	 * @return array<string, ProviderCredentials>
	 */
	private function parseProviders( array $section, array $current ): array {
		$result = $current;
		foreach ( self::PROVIDER_SECRET_FIELDS as $providerKey => $secretKeys ) {
			$entry = $this->subArray( $section, $providerKey );

			$enabled = $this->readBool( $entry, 'enabled' );

			$secrets = array();
			foreach ( $secretKeys as $secretKey ) {
				$secrets[ $secretKey ] = $this->readString( $entry, $secretKey, '' );
			}

			try {
				$result[ $providerKey ] = new ProviderCredentials( $providerKey, $enabled, $secrets );
			} catch ( Throwable $unused ) {
				unset( $unused );
				if ( array_key_exists( $providerKey, $current ) ) {
					$result[ $providerKey ] = $current[ $providerKey ];
				}
			}
		}

		return $result;
	}

	/**
	 * Return the array stored under `$key` in `$source`, or an
	 * empty array when the key is absent or non-array.
	 *
	 * @param array<string, mixed> $source Outer array.
	 * @param string               $key    Sub-array key.
	 *
	 * @return array<string, mixed>
	 */
	private function subArray( array $source, string $key ): array {
		if ( ! array_key_exists( $key, $source ) || ! is_array( $source[ $key ] ) ) {
			return array();
		}

		/** @var array<string, mixed> $result */
		$result = $source[ $key ];
		return $result;
	}

	/**
	 * Read a checkbox-style boolean from `$source`.
	 *
	 * Per WordPress checkbox semantics: presence under `$key`
	 * (regardless of value) is treated as `true`; absence is
	 * `false`. Explicitly `''` / `'0'` / `false` values are
	 * treated as `false` so a renderer-supplied hidden default
	 * input can still force the off-state when needed.
	 *
	 * @param array<string, mixed> $source Source array.
	 * @param string               $key    Field name.
	 */
	private function readBool( array $source, string $key ): bool {
		if ( ! array_key_exists( $key, $source ) ) {
			return false;
		}

		$value = $source[ $key ];
		if ( $value === '' || $value === '0' || $value === false || $value === 0 || $value === null ) {
			return false;
		}

		return true;
	}

	/**
	 * Read a string field from `$source`, falling back to
	 * `$fallback` when missing or non-scalar.
	 *
	 * @param array<string, mixed> $source   Source array.
	 * @param string               $key      Field name.
	 * @param string               $fallback Fallback value.
	 */
	private function readString( array $source, string $key, string $fallback ): string {
		if ( ! array_key_exists( $key, $source ) ) {
			return $fallback;
		}

		$value = $source[ $key ];
		if ( is_string( $value ) ) {
			return $value;
		}

		if ( is_scalar( $value ) ) {
			return (string) $value;
		}

		return $fallback;
	}

	/**
	 * Read a numeric field from `$source` and coerce to int via
	 * `FILTER_VALIDATE_INT`, falling back to `$fallback` on
	 * parse failure.
	 *
	 * @param array<string, mixed> $source   Source array.
	 * @param string               $key      Field name.
	 * @param int                  $fallback Fallback value.
	 */
	private function readInt( array $source, string $key, int $fallback ): int {
		if ( ! array_key_exists( $key, $source ) ) {
			return $fallback;
		}

		$value = $source[ $key ];
		if ( is_int( $value ) ) {
			return $value;
		}

		if ( is_string( $value ) || is_float( $value ) ) {
			$coerced = filter_var( (string) $value, FILTER_VALIDATE_INT );
			if ( $coerced !== false ) {
				return $coerced;
			}
		}

		return $fallback;
	}
}
