<?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 \Affilun\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.
 *     `affilun_settings[providers]` is a string) are treated as
 *     missing and the corresponding section of `$current`
 *     wins.
 *
 * Provider-section schema (per §13.4):
 *
 *   - `amazon`  — `enabled`, `credential_id`,
 *                 `credential_secret`, `version`, `partner_tag`.
 *                 `marketplace` is not user-editable; affilun fixes
 *                 it to `www.amazon.co.jp` for the Japan-focused setup.
 *   - `rakuten` — `enabled`, `application_id`, `affiliate_id`.
 *   - `yahoo`   — `enabled`, `app_id`, `affiliate_sid`,
 *                 `affiliate_pid`.
 *
 * 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 Affilun\Presentation\Admin\Page
 */

declare(strict_types=1);

namespace Affilun\Presentation\Admin\Page;

use Affilun\Application\Settings\CacheSettings;
use Affilun\Application\Settings\CloudSettings;
use Affilun\Application\Settings\LinkDefaults;
use Affilun\Application\Settings\OpenAiSettings;
use Affilun\Application\Settings\PluginSettings;
use Affilun\Application\Settings\ProviderCredentials;
use Affilun\Application\Settings\RenderingDefaults;
use Affilun\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', 'button_color', 'button_text_color' ),
		'rakuten'        => array( 'application_id', 'access_key', 'affiliate_id', 'button_color', 'button_text_color' ),
		'yahoo_shopping' => array( 'app_id', 'affiliate_sid', 'affiliate_pid', 'button_color', 'button_text_color' ),
		'mercari'        => array( 'afid', 'button_color', 'button_text_color' ),
	);

	/**
	 * 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['affilun_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' );
		$openAi    = OpenAiSettings::defaults();
		$cloud     = $this->parseCloud( $this->subArray( $postData, 'cloud' ), $current->cloud() );

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

		try {
			return new PluginSettings( $providers, $rendering, $links, $sync, $cache, $debugMode, $openAi, $cloud );
		} 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() ),
				$this->readString( $section, 'default_design_preset', $current->defaultDesignPreset() ),
				$this->readString( $section, 'default_button_layout', $current->defaultButtonLayout() ),
				$this->readString( $section, 'default_button_shape', $current->defaultButtonShape() ),
				'brand',
				$this->readString( $section, 'default_color_scheme', $current->defaultColorScheme() )
			);
		} 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 `cloud` sub-array into affilun Cloud settings.
	 *
	 * @param array<string, mixed> $section Section sub-array.
	 * @param CloudSettings        $current Current snapshot.
	 */
	private function parseCloud( array $section, CloudSettings $current ): CloudSettings {
		try {
			return new CloudSettings(
				$this->readBool( $section, 'enabled' ),
				$this->readString( $section, 'endpoint', $current->endpoint() ),
				$this->readString( $section, 'site_id', $current->siteId() ),
				$this->readString( $section, 'api_key', '' ),
				$this->readString( $section, 'plan', $current->plan() ),
				$this->readString( $section, 'article_draft_tone', $current->articleDraftTone() ),
				$this->readString(
					$section,
					'article_draft_target_length',
					$current->articleDraftTargetLength()
				),
				$this->readString(
					$section,
					'article_draft_review_source_policy',
					$current->articleDraftReviewSourcePolicy()
				)
			);
		} 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 ) {
				$value = $this->readString( $entry, $secretKey, '' );
				if ( trim( $value ) === '' && array_key_exists( $providerKey, $current ) ) {
					$currentValue = (string) $current[ $providerKey ]->secret( $secretKey );
					if ( trim( $currentValue ) !== '' ) {
						$value = $currentValue;
					}
				}
				$secrets[ $secretKey ] = $value;
			}

			$currentButtonColor = '';
			if ( array_key_exists( $providerKey, $current ) ) {
				$currentButtonColor = (string) $current[ $providerKey ]->secret( 'button_color' );
			}
			$rawButtonColor = $currentButtonColor;
			if ( trim( $secrets['button_color'] ?? '' ) !== '' ) {
				$rawButtonColor = (string) $secrets['button_color'];
			}
			$secrets['button_color'] = ProviderCredentials::normalizeButtonColor( $providerKey, $rawButtonColor );

			$currentButtonTextColor = '';
			if ( array_key_exists( $providerKey, $current ) ) {
				$currentButtonTextColor = (string) $current[ $providerKey ]->secret( 'button_text_color' );
			}
			$rawButtonTextColor           = trim( $secrets['button_text_color'] ?? '' ) !== ''
				? (string) $secrets['button_text_color']
				: $currentButtonTextColor;
			$secrets['button_text_color'] = ProviderCredentials::normalizeButtonTextColor( $rawButtonTextColor );

			if ( $providerKey === 'amazon' ) {
				$currentVersion = array_key_exists( $providerKey, $current )
					? (string) $current[ $providerKey ]->secret( 'version' )
					: '';
				if ( trim( $secrets['version'] ?? '' ) === '' && trim( $currentVersion ) === '' ) {
					$secrets['version'] = ProviderCredentials::AMAZON_DEFAULT_VERSION;
				}
				$secrets['marketplace'] = ProviderCredentials::AMAZON_MARKETPLACE;
			}

			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_scalar( $value ) ) {
			return $fallback;
		}

		return self::sanitizeString( (string) $value );
	}

	/**
	 * 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;
	}

	/**
	 * Sanitize a scalar text value from settings POST data.
	 *
	 * @param string $value Raw value.
	 */
	private static function sanitizeString( string $value ): string {
		$value = function_exists( 'wp_unslash' ) ? (string) wp_unslash( $value ) : $value;
		if ( function_exists( 'sanitize_text_field' ) ) {
			return (string) sanitize_text_field( $value );
		}
		$value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value;
		return trim( (string) preg_replace( '/\s+/', ' ', $value ) );
	}
}
