<?php
/**
 * OpenAI settings value object.
 *
 * Carries the 0.4.0 product-query extraction settings used by the
 * multi-provider search-link feature. The API key is persisted in the same
 * settings blob as provider credentials and is treated as secret material by
 * admin/REST surfaces.
 *
 * @package Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\Application\Settings;

use InvalidArgumentException;

/**
 * Immutable OpenAI configuration snapshot.
 */
final class OpenAiSettings {

	/**
	 * 0.4.0 default model requested by the product brief.
	 */
	public const DEFAULT_MODEL = 'gpt-5.4-mini';

	/**
	 * Secret API key. Empty string means not configured.
	 */
	private string $apiKey;

	/**
	 * Model name used for extraction/test calls.
	 */
	private string $model;

	/**
	 * Whether OpenAI-backed extraction is enabled.
	 */
	private bool $enabled;

	/**
	 * @param string $apiKey  API key; empty string allowed for unconfigured installs.
	 * @param string $model   OpenAI model name; empty value falls back to DEFAULT_MODEL.
	 * @param bool   $enabled Whether OpenAI-backed extraction is enabled.
	 */
	public function __construct( string $apiKey, string $model, bool $enabled = true ) {
		$this->apiKey  = trim( $apiKey );
		$this->model   = self::normalizeModel( $model );
		$this->enabled = $enabled;
	}

	/**
	 * Baseline settings for fresh installs.
	 */
	public static function defaults(): self {
		return new self( '', self::DEFAULT_MODEL, false );
	}

	/**
	 * API key, returned verbatim for provider/API boundaries only.
	 */
	public function apiKey(): string {
		return $this->apiKey;
	}

	/**
	 * Configured model name.
	 */
	public function model(): string {
		return $this->model;
	}

	/**
	 * Whether OpenAI-backed extraction is enabled.
	 */
	public function enabled(): bool {
		return $this->enabled;
	}

	/**
	 * Whether the key is non-empty and OpenAI-backed extraction is enabled.
	 */
	public function isConfigured(): bool {
		return $this->enabled && $this->apiKey !== '';
	}

	/**
	 * Masked API key for UI display.
	 */
	public function maskedApiKey(): string {
		return SettingsCredentialMask::mask( $this->apiKey );
	}

	/**
	 * Copy with a new API key.
	 *
	 * @param string $apiKey New API key.
	 */
	public function withApiKey( string $apiKey ): self {
		return new self( $apiKey, $this->model, $this->enabled );
	}

	/**
	 * Copy with a new model.
	 *
	 * @param string $model New model name.
	 *
	 * @throws InvalidArgumentException When `$model` is too long.
	 */
	public function withModel( string $model ): self {
		return new self( $this->apiKey, $model, $this->enabled );
	}

	/**
	 * Copy with a new enabled flag.
	 *
	 * @param bool $enabled Whether OpenAI-backed extraction is enabled.
	 */
	public function withEnabled( bool $enabled ): self {
		return new self( $this->apiKey, $this->model, $enabled );
	}

	/**
	 * Structural equality.
	 *
	 * @param self $other Other settings snapshot.
	 */
	public function equals( self $other ): bool {
		return $this->apiKey === $other->apiKey
			&& $this->model === $other->model
			&& $this->enabled === $other->enabled;
	}

	/**
	 * Serialize for persistence.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'api_key' => $this->apiKey,
			'model'   => $this->model,
			'enabled' => $this->enabled,
		);
	}

	/**
	 * Construct from persisted payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 *
	 * @throws InvalidArgumentException When the payload is malformed.
	 */
	public static function fromArray( array $data ): self {
		if ( ! array_key_exists( 'api_key', $data ) || ! is_string( $data['api_key'] ) ) {
			$message = 'OpenAiSettings payload must carry a string `api_key`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$enabled = true;
		if ( array_key_exists( 'enabled', $data ) ) {
			$enabled = (bool) $data['enabled'];
		}

		return new self( $data['api_key'], $data['model'], $enabled );
	}

	/**
	 * Normalize the model name.
	 *
	 * @param string $model Raw model name.
	 *
	 * @throws InvalidArgumentException When `$model` is too long.
	 */
	private static function normalizeModel( string $model ): string {
		$trimmed = trim( $model );
		if ( $trimmed === '' ) {
			return self::DEFAULT_MODEL;
		}

		if ( strlen( $trimmed ) > 128 ) {
			$message = 'OpenAiSettings model must be 128 characters or fewer.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}
}
