<?php
/**
 * Option-store backed settings repository.
 *
 * Production {@see SettingsRepository} implementation that
 * persists the §13 Settings aggregate as a single serialized
 * blob under the `affilun_settings` option. The blob shape is
 * canonicalized by {@see PluginSettings::toArray()} /
 * {@see PluginSettings::fromArray()} so a future change to the
 * settings schema is a one-method change on
 * {@see PluginSettings} rather than a cross-cutting refactor
 * of every persistence call site.
 *
 * Recovery behavior:
 *
 *   - When the option is unset, {@see self::load()} returns
 *     {@see PluginSettings::defaults()}.
 *
 *   - When the option carries a value that is not an array, or
 *     when {@see PluginSettings::fromArray()} rejects the
 *     payload as malformed, {@see self::load()} ALSO returns
 *     {@see PluginSettings::defaults()}. This is the Phase-8
 *     minimum: silent recovery so the admin page remains
 *     functional even if the option was edited out-of-band or
 *     written by an older plugin version with an incompatible
 *     schema. Observability of the recovery path (a log line,
 *     an admin notice) is a Phase-11 concern; this slice
 *     deliberately keeps {@see self::load()} side-effect-free
 *     so it can be exercised in pure-unit tests without a
 *     logger fixture.
 *
 * @package Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\Application\Settings;

use Affilun\Infrastructure\Persistence\OptionStore;
use InvalidArgumentException;

/**
 * Persists {@see PluginSettings} as a single option-store blob.
 *
 * See the file-level docblock for the recovery rule that
 * gates {@see self::load()} when the stored payload is
 * malformed.
 */
final class OptionStoreSettingsRepository implements SettingsRepository {

	/**
	 * Canonical option key under which the §13 settings blob
	 * is persisted.
	 *
	 * Named here as the single source of truth so a future
	 * rename is a one-file change.
	 */
	public const OPTION_KEY = 'affilun_settings';

	/**
	 * Canonical non-autoload option key for secret-bearing settings material.
	 */
	public const SECRETS_OPTION_KEY = 'affilun_settings_secrets';

	/**
	 * Underlying key/value store used to read and write the
	 * option.
	 */
	private OptionStore $optionStore;

	/**
	 * Construct the repository.
	 *
	 * @param OptionStore $optionStore Backing store for the option.
	 */
	public function __construct( OptionStore $optionStore ) {
		$this->optionStore = $optionStore;
	}

	/**
	 * Load the current settings snapshot.
	 *
	 * Returns {@see PluginSettings::defaults()} when the option
	 * is unset, when the stored value is not an array, or when
	 * {@see PluginSettings::fromArray()} rejects it as
	 * malformed. See the file-level docblock for the rationale
	 * for treating malformed payloads as a silent recovery
	 * branch in Phase 8.
	 */
	public function load(): PluginSettings {
		$stored = $this->optionStore->get( self::OPTION_KEY, null );

		if ( ! is_array( $stored ) ) {
			return PluginSettings::defaults();
		}

		$secrets = SettingsSecretBox::decryptArray(
			$this->optionStore->get( self::SECRETS_OPTION_KEY, array() )
		);
		if ( is_array( $secrets ) ) {
			$stored = self::mergeSecrets( $stored, $secrets );
		}

		try {
			return PluginSettings::fromArray( $stored );
		} catch ( InvalidArgumentException $unused ) {
			unset( $unused );
			return PluginSettings::defaults();
		}
	}

	/**
	 * Persist the supplied settings snapshot.
	 *
	 * The blob is serialized via {@see PluginSettings::toArray()}
	 * and written via {@see OptionStore::update()}. Errors
	 * from the underlying store propagate.
	 *
	 * @param PluginSettings $settings Snapshot to persist.
	 */
	public function save( PluginSettings $settings ): void {
		$data    = $settings->toArray();
		$secrets = self::extractSecrets( $data );
		$this->optionStore->update( self::OPTION_KEY, $data );
		$this->optionStore->updateNonAutoload(
			self::SECRETS_OPTION_KEY,
			SettingsSecretBox::encryptArray( $secrets )
		);
	}

	/**
	 * Delete the stored settings blob.
	 *
	 * After a successful call, the next {@see self::load()}
	 * returns {@see PluginSettings::defaults()}.
	 */
	public function reset(): void {
		$this->optionStore->delete( self::OPTION_KEY );
		$this->optionStore->delete( self::SECRETS_OPTION_KEY );
	}

	/**
	 * Extract secret-bearing leaves from the persisted settings payload, replacing
	 * them in-place with empty placeholders in the non-secret blob.
	 *
	 * @param array<string,mixed> $data Settings payload, mutated in-place.
	 *
	 * @return array<string,mixed> Secret payload for non-autoload storage.
	 */
	private static function extractSecrets( array &$data ): array {
		$secrets = array(
			'provider_credentials' => array(),
			'openai'               => array(),
			'cloud'                => array(),
		);

		if ( isset( $data['provider_credentials'] ) && is_array( $data['provider_credentials'] ) ) {
			foreach ( $data['provider_credentials'] as $providerKey => &$entry ) {
				if ( is_array( $entry ) && isset( $entry['secrets'] ) && is_array( $entry['secrets'] ) ) {
					$secrets['provider_credentials'][ $providerKey ] = $entry['secrets'];
					$entry['secrets']                                = array();
				}
			}
			unset( $entry );
		}

		if ( isset( $data['openai'] ) && is_array( $data['openai'] ) && isset( $data['openai']['api_key'] ) ) {
			$secrets['openai']['api_key'] = $data['openai']['api_key'];
			$data['openai']['api_key']    = '';
		}

		if ( isset( $data['cloud'] ) && is_array( $data['cloud'] ) && isset( $data['cloud']['api_key'] ) ) {
			$secrets['cloud']['api_key'] = $data['cloud']['api_key'];
			$data['cloud']['api_key']    = '';
		}

		return $secrets;
	}

	/**
	 * Merge non-autoload secret payload back into a settings payload.
	 *
	 * @param array<string,mixed> $data    Non-secret settings payload.
	 * @param array<string,mixed> $secrets Secret payload.
	 *
	 * @return array<string,mixed>
	 */
	private static function mergeSecrets( array $data, array $secrets ): array {
		$providerSecrets = $secrets['provider_credentials'] ?? array();
		if ( is_array( $providerSecrets ) && isset( $data['provider_credentials'] ) && is_array( $data['provider_credentials'] ) ) {
			foreach ( $providerSecrets as $providerKey => $secretMap ) {
				if ( isset( $data['provider_credentials'][ $providerKey ] ) && is_array( $data['provider_credentials'][ $providerKey ] ) && is_array( $secretMap ) ) {
					$data['provider_credentials'][ $providerKey ]['secrets'] = $secretMap;
				}
			}
		}

		if ( isset( $data['openai'] ) && is_array( $data['openai'] ) && isset( $secrets['openai'] ) && is_array( $secrets['openai'] ) && array_key_exists( 'api_key', $secrets['openai'] ) ) {
			$data['openai']['api_key'] = $secrets['openai']['api_key'];
		}

		if ( isset( $data['cloud'] ) && is_array( $data['cloud'] ) && isset( $secrets['cloud'] ) && is_array( $secrets['cloud'] ) && array_key_exists( 'api_key', $secrets['cloud'] ) ) {
			$data['cloud']['api_key'] = $secrets['cloud']['api_key'];
		}

		return $data;
	}
}
