<?php
/**
 * Secret storage utilities for Mail Doctor.
 *
 * @package MailDoctor
 */

namespace MailDoctor;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Secrets manager provides encrypted storage for credentials and tokens.
 */
class Secrets {

	/**
	 * Option name prefix for secret payloads.
	 */
	const OPTION_PREFIX = 'mail_doctor_secret_';

	/**
	 * Cipher for encrypting payloads.
	 */
	const CIPHER = 'aes-256-gcm';

	/**
	 * Encode and persist a secret value.
	 *
	 * @param string               $value Raw secret value.
	 * @param array<string, mixed> $meta  Optional metadata.
	 * @return string Reference token for retrieval.
	 */
	public static function store( string $value, array $meta = array() ): string {
		$reference = self::generate_reference();
		$payload   = self::encrypt( $value );

		if ( empty( $payload ) ) {
			$payload = array(
				'plain' => base64_encode( $value ),
			);
		}

		$payload['created_at'] = gmdate( 'Y-m-d H:i:s' );
		$payload['meta']       = $meta;

		add_option(
			self::option_key( $reference ),
			$payload,
			'',
			false
		);

		return $reference;
	}

	/**
	 * Retrieve a secret value if available.
	 *
	 * @param string $reference Secret reference token.
	 * @return string|null Decrypted secret or null when unavailable.
	 */
	public static function retrieve( string $reference ): ?string {
		$key     = self::option_key( $reference );
		$payload = get_option( $key, null );

		if ( ! is_array( $payload ) || empty( $payload ) ) {
			return null;
		}

		if ( isset( $payload['ciphertext'] ) && isset( $payload['iv'] ) && isset( $payload['tag'] ) ) {
			return self::decrypt( $payload );
		}

		if ( isset( $payload['plain'] ) ) {
			$decoded = base64_decode( (string) $payload['plain'], true );
			return false === $decoded ? null : $decoded;
		}

		return null;
	}

	/**
	 * Replace the value for an existing secret.
	 *
	 * @param string               $reference Secret reference token.
	 * @param string               $value     New secret value.
	 * @param array<string, mixed> $meta      Optional metadata.
	 * @return string Reference token (existing or new when rotated).
	 */
	public static function rotate( string $reference, string $value, array $meta = array() ): string {
		$key = self::option_key( $reference );

		if ( ! get_option( $key ) ) {
			return self::store( $value, $meta );
		}

		$payload = self::encrypt( $value );

		if ( empty( $payload ) ) {
			$payload = array(
				'plain' => base64_encode( $value ),
			);
		}

		$payload['updated_at'] = gmdate( 'Y-m-d H:i:s' );
		$payload['meta']       = $meta;

		update_option(
			$key,
			$payload,
			false
		);

		return $reference;
	}

	/**
	 * Delete a persisted secret.
	 *
	 * @param string $reference Stored secret reference token.
	 * @return void
	 */
	public static function forget( string $reference ): void {
		delete_option( self::option_key( $reference ) );
	}

	/**
	 * Fetch metadata associated with a secret.
	 *
	 * @param string $reference Secret reference token.
	 * @return array<string, mixed>
	 */
	public static function metadata( string $reference ): array {
		$key     = self::option_key( $reference );
		$payload = get_option( $key, null );

		if ( ! is_array( $payload ) ) {
			return array();
		}

		return isset( $payload['meta'] ) && is_array( $payload['meta'] ) ? $payload['meta'] : array();
	}

	/**
	 * Encrypt a value using site salts when OpenSSL is available.
	 *
	 * @param string $value Plain text value.
	 * @return array<string, mixed>
	 */
	protected static function encrypt( string $value ): array {
		if ( ! self::encryption_supported() ) {
			return array();
		}

		$iv_length = openssl_cipher_iv_length( self::CIPHER );

		if ( ! $iv_length ) {
			return array();
		}

		$key_material = self::key_material();

		if ( empty( $key_material ) ) {
			return array();
		}

		try {
			$iv = random_bytes( $iv_length );
		} catch ( \Exception $exception ) {
			return array();
		}

		$ciphertext = openssl_encrypt(
			$value,
			self::CIPHER,
			$key_material,
			OPENSSL_RAW_DATA,
			$iv,
			$tag
		);

		if ( false === $ciphertext || empty( $tag ) ) {
			return array();
		}

		return array(
			'cipher'     => self::CIPHER,
			'iv'         => base64_encode( $iv ),
			'ciphertext' => base64_encode( $ciphertext ),
			'tag'        => base64_encode( $tag ),
		);
	}

	/**
	 * Decrypt stored payload when possible.
	 *
	 * @param array<string, mixed> $payload Stored payload.
	 * @return string|null
	 */
	protected static function decrypt( array $payload ): ?string {
		if ( ! self::encryption_supported() ) {
			return null;
		}

		if ( empty( $payload['ciphertext'] ) || empty( $payload['iv'] ) || empty( $payload['tag'] ) ) {
			return null;
		}

		$ciphertext = base64_decode( (string) $payload['ciphertext'], true );
		$iv         = base64_decode( (string) $payload['iv'], true );
		$tag        = base64_decode( (string) $payload['tag'], true );

		if ( false === $ciphertext || false === $iv || false === $tag ) {
			return null;
		}

		$key_material = self::key_material();

		if ( empty( $key_material ) ) {
			return null;
		}

		$plain = openssl_decrypt(
			$ciphertext,
			self::CIPHER,
			$key_material,
			OPENSSL_RAW_DATA,
			$iv,
			$tag
		);

		return false === $plain ? null : $plain;
	}

	/**
	 * Generate a random reference string.
	 *
	 * @return string
	 */
	protected static function generate_reference(): string {
		try {
			$bytes = random_bytes( 16 );
		} catch ( \Exception $exception ) {
			$bytes = wp_generate_password( 16, false, false );
			return strtolower( $bytes );
		}

		return bin2hex( $bytes );
	}

	/**
	 * Produce the full option key for a secret.
	 *
	 * @param string $reference Token reference.
	 * @return string
	 */
	protected static function option_key( string $reference ): string {
		return self::OPTION_PREFIX . strtolower( preg_replace( '/[^a-f0-9]/', '', $reference ) );
	}

	/**
	 * Derive encryption key material from WordPress salts.
	 *
	 * @return string
	 */
	protected static function key_material(): string {
		$keys = array();

		if ( defined( 'AUTH_KEY' ) && AUTH_KEY ) {
			$keys[] = AUTH_KEY;
		}

		if ( defined( 'SECURE_AUTH_SALT' ) && SECURE_AUTH_SALT ) {
			$keys[] = SECURE_AUTH_SALT;
		}

		if ( empty( $keys ) ) {
			$keys[] = wp_salt( 'auth' );
			$keys[] = wp_salt( 'secure_auth' );
		}

		$combined = implode( '|', array_filter( $keys ) );

		if ( '' === $combined ) {
			return '';
		}

		return hash( 'sha256', $combined . '|mail_doctor_secret', true );
	}

	/**
	 * Determine if OpenSSL encryption is available.
	 *
	 * @return bool
	 */
	public static function encryption_supported(): bool {
		return function_exists( 'openssl_encrypt' ) && function_exists( 'openssl_decrypt' ) && function_exists( 'openssl_cipher_iv_length' );
	}

	/**
	 * Return all stored secret option keys.
	 *
	 * @return array<int, string>
	 */
	protected static function all_option_keys(): array {
		global $wpdb;

		$like = self::OPTION_PREFIX . '%';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Fetching secret option keys.
		$results = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
				$like
			)
		);

		return is_array( $results ) ? $results : array();
	}

	/**
	 * Rotate encryption on all stored secrets.
	 *
	 * @return array{updated:int, failures:int}
	 */
	public static function reencrypt_all(): array {
		$keys     = self::all_option_keys();
		$updated  = 0;
		$failures = 0;

		foreach ( $keys as $option_name ) {
			$payload = get_option( $option_name, null );

			if ( ! is_array( $payload ) ) {
				continue;
			}

			$meta  = $payload['meta'] ?? array();
			$value = self::decrypt( $payload );

			if ( null === $value && isset( $payload['plain'] ) ) {
				$decoded = base64_decode( (string) $payload['plain'], true );
				if ( false !== $decoded ) {
					$value = $decoded;
				}
			}

			if ( null === $value ) {
				++$failures;
				continue;
			}

			$replacement = self::encrypt( $value );

			if ( empty( $replacement ) ) {
				++$failures;
				continue;
			}

			$replacement['meta']       = $meta;
			$replacement['updated_at'] = gmdate( 'Y-m-d H:i:s' );

			if ( update_option( $option_name, $replacement, false ) ) {
				++$updated;
			} else {
				++$failures;
			}
		}

		return array(
			'updated'  => $updated,
			'failures' => $failures,
		);
	}
}
