<?php

declare(strict_types=1);
/**
 * Loads and stores the SecurWP master key.
 */

namespace SecurWP\Includes;

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

class MasterKey {

	private const KEY_BYTES     = 32;
	private const KEY_FILE_NAME = 'securwp-key.php';
	private const DATA_DIR_NAME = 'securwp-data';

	/** @var string|null Cached master key for the current request. */
	private static ?string $cache = null;

	/** @var array|null Last error from get() for downstream diagnostics. */
	private static ?array $last_error = null;

		/**
		 * Load the 32-byte master key or create it on first use.
		 *
		 * Checks SECURWP_MASTER_KEY first, then the key file.
		 *
		 * @return string Raw 32-byte key.
		 * @throws \RuntimeException If the key cannot be loaded or created.
		 */
	public static function get(): string {
		if ( null !== self::$cache ) {
			return self::$cache;
		}

		self::$last_error = null;

		// Explicit constant (managed hosting, containers, CI).
		if ( defined( 'SECURWP_MASTER_KEY' ) ) {
			$key = base64_decode( SECURWP_MASTER_KEY, true );
			if ( false === $key || strlen( $key ) !== self::KEY_BYTES ) {
				self::$last_error = [
					'code'    => 'key_constant_invalid',
					'message' => 'SECURWP_MASTER_KEY is not a valid base64-encoded ' . self::KEY_BYTES . '-byte key.',
				];
						// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Internal exception message.
						throw new \RuntimeException( self::$last_error['message'] );
			}
			self::$cache = $key;
			return $key;
		}

		// Key file at ABSPATH/securwp-key.php.
		$path = self::get_key_file_path();

		if ( file_exists( $path ) ) {
			// Do not gate on is_readable() - it can return false on some Apache/PHP-FPM
			// setups (open_basedir, suPHP ownership mismatches) even when the file is
			// accessible. Attempt the read directly via @file_get_contents instead.
			return self::load_from_file( $path );
		}

			// Create the key file on first use.
		try {
			$key = self::atomic_create_key_file( $path );
		} catch ( \RuntimeException $e ) {
			self::$last_error = [
				'code'    => 'key_write_failed',
				'path'    => $path,
				'message' => $e->getMessage(),
			];
			throw $e;
		}
		self::$cache = $key;
		return $key;
	}

	/**
	 * Last error set by get() before throwing.
	 *
	 * Returns null when get() succeeded or hasn't been called yet.
	 * Array keys: 'code' (string), 'path' (string|absent), 'message' (string).
	 *
	 * @return array<string, string>|null
	 */
	public static function get_last_error(): ?array {
		return self::$last_error;
	}

	/**
	 * Clear the in-memory cache so the next get() reads fresh from disk.
	 */
	public static function clear_cache(): void {
		self::$cache      = null;
		self::$last_error = null;
	}

	/**
	 * Override the in-memory key for the remainder of this request.
	 *
	 * Used by the key rotation tool to switch SecretManager to the new key
	 * for re-encryption before the request ends. The override takes effect
	 * because get() returns the cache before checking the constant or file.
	 *
	 * @param string $key Raw 32-byte key.
	 * @throws \InvalidArgumentException If key length is wrong.
	 */
		public static function set_override( string $key ): void {
			if ( strlen( $key ) !== self::KEY_BYTES ) {
				throw new \InvalidArgumentException( 'Override key must be exactly 32 bytes.' );
			}
			self::$cache = $key;
		}

	/**
	 * Absolute path to the key file.
	 *
	 * @return string
	 */
	public static function get_key_file_path(): string {
		return WP_CONTENT_DIR . '/' . self::DATA_DIR_NAME . '/' . self::KEY_FILE_NAME;
	}

	/**
	 * Key source identifier for diagnostics.
	 *
	 * @return string 'constant' or 'file'.
	 */
	public static function get_source(): string {
		return defined( 'SECURWP_MASTER_KEY' ) ? 'constant' : 'file';
	}

	/**
	 * Load the master key from the key file using file_get_contents + regex.
	 *
	 * @param string $path Absolute file path.
	 * @return string Raw 32-byte key.
	 * @throws \RuntimeException If the file is corrupt or unreadable.
	 */
	private static function load_from_file( string $path ): string {
		$content = @file_get_contents( $path );
		if ( $content !== false && preg_match( "/return\s+'([A-Za-z0-9+\/=]+)'/", $content, $matches ) ) {
			$key = base64_decode( $matches[1], true );
			if ( false !== $key && strlen( $key ) === self::KEY_BYTES ) {
				self::$cache = $key;
				return $key;
			}
		}
		// Two possible causes: the file is not readable by the PHP process (ownership /
		// open_basedir mismatch), or the content is genuinely corrupt.
		$readable = ( $content !== false );
		self::$last_error = [
			'code'    => $readable ? 'key_corrupt' : 'key_unreadable',
			'path'    => $path,
			'message' => $readable
				? 'Key file is corrupt or contains invalid data: ' . $path
				: 'Key file exists but could not be read: ' . $path
					. '. Check file ownership/permissions, or set define( \'SECURWP_MASTER_KEY\', \'<base64-key>\' ) in wp-config.php.',
		];
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is internal control flow, not rendered HTML output.
			throw new \RuntimeException( self::$last_error['message'] );
	}

	/**
	 * Atomically create the key file using exclusive-create mode.
	 *
	 * fopen($path, 'x') (O_CREAT|O_EXCL) is intentional: it fails if the file
	 * already exists, so two concurrent first-run requests cannot generate
	 * different keys and overwrite each other.
	 *
	 * Permissions are derived from wp-config.php in the same directory.
	 * WordPress must have read that file to boot, so matching its mode guarantees
	 * the key file is readable by the same PHP process.
	 *
	 * @param string $path Absolute file path.
	 * @return string Raw 32-byte key.
	 * @throws \RuntimeException On write failure.
	 */
	private static function atomic_create_key_file( string $path ): string {
		$dir = dirname( $path );
		if ( ! is_dir( $dir ) && ! wp_mkdir_p( $dir ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is internal control flow, not rendered HTML output.
			throw new \RuntimeException( 'Cannot create directory for key file: ' . $dir );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Exclusive-create mode preserves atomic first-write semantics for the master key file.
		$handle = @fopen( $path, 'x' );
		if ( $handle === false ) {
			if ( file_exists( $path ) ) {
				// Race condition: another concurrent request created the file first.
				return self::load_from_file( $path );
			}
			// fopen 'x' failed because the directory is not writable by the PHP process.
			throw new \RuntimeException(
				esc_html(
					sprintf(
						'Cannot create key file at %1$s. Check that the web server user has write permission on %2$s, or set define( \'SECURWP_MASTER_KEY\', \'<base64-key>\' ) in wp-config.php.',
						$path,
						$dir
					)
				)
			);
		}

		if ( ! self::harden_key_file_permissions( $path, $dir ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $handle );
			wp_delete_file( $path );
			throw new \RuntimeException(
				esc_html(
					sprintf(
						'Cannot apply restrictive permissions to key file at %s before writing secret data.',
						$path
					)
				)
			);
		}

		$key     = random_bytes( self::KEY_BYTES );
		$b64     = base64_encode( $key );
		$content = self::build_key_file_content( $b64, 'Generated' );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Exclusive-create handle is intentionally used for atomic master-key creation.
		$written = fwrite( $handle, $content );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing exclusive-create handle after atomic master-key write.
		fclose( $handle );

		if ( $written === false || $written < strlen( $content ) ) {
			wp_delete_file( $path );
			throw new \RuntimeException(
				esc_html(
					sprintf(
						'Failed to write key file (partial write): %s. Check available disk space.',
						$path
					)
				)
			);
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Matching the detected secure file mode for plugin-owned key material.
		@chmod( $path, self::key_file_mode( $dir ) );

		return $key;
	}

	/**
	 * Harden a key-file placeholder before writing key material.
	 */
	private static function harden_key_file_permissions( string $path, string $dir ): bool {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod
		@chmod( $path, self::key_file_mode( $dir ) );
		clearstatcache( true, $path );

		$perms = @fileperms( $path );
		if ( false === $perms ) {
			return false;
		}

		return ( $perms & 0007 ) === 0;
	}

	/**
	 * Build the PHP key file content string.
	 *
	 * @param string $b64    Base64-encoded key.
	 * @param string $action 'Generated' or 'Restored' (appears in the file header comment).
	 * @return string
	 */
		private static function build_key_file_content( string $b64, string $action ): string {
			return implode(
				"\n",
				[
					'<?php',
					'/**',
					' * SecurWP Encryption Key',
					' *',
					' * WARNING: DO NOT MODIFY OR DELETE THIS FILE.',
					' *',
					' * This file contains the master encryption key used by the SecurWP security',
					' * plugin. All cloud credentials and sensitive data stored by the plugin are',
					' * encrypted with a key derived from this value.',
					' *',
					' * If this file is deleted or modified:',
					' *  - Stored credentials will become unreadable and must be recovered.',
					' *  - Scheduled scans will stop working until the issue is resolved.',
					' *  - You will be prompted to recover or regenerate via the SecurWP admin panel.',
					' *',
					' * If you need to migrate this site, copy this file along with your WordPress files.',
					' * For container or managed-hosting deployments, you may set the',
					' * SECURWP_MASTER_KEY constant in wp-config.php instead.',
					' *',
					' * ' . $action . ' by SecurWP. Do not edit.',
					' */',
					"if ( ! defined( 'ABSPATH' ) ) { exit; }",
					"return '{$b64}';",
					'',
				]
			);
		}

	/**
	 * Determine the file mode to use for the key file.
	 *
	 * Mirrors common WAF storage approaches: probe an existing PHP file in the
	 * same directory to detect whether the server environment uses group-write.
	 * Start at 0600 (owner only) and add group-read/write only when the probe
	 * file has group-write - indicating the PHP process and web server share a group.
	 * World-access bits are never granted for key material.
	 *
	 * @param string $dir Directory that will contain the key file.
	 * @return int Octal file mode.
	 */
	private static function key_file_mode( string $dir ): int {
		// Probe plugin-owned files in the same securwp-data directory.
		// These were written by the same PHP process, so their mode reflects the
		// actual ownership/group of this environment - same technique used by security plugins.
		$probes = [ 'waf-config.php', 'bootstrap.php' ];
		foreach ( $probes as $probe ) {
			$probe_path = rtrim( $dir, '/' ) . '/' . $probe;
			if ( file_exists( $probe_path ) ) {
				$stat = @stat( $probe_path );
				if ( $stat !== false ) {
					// Start at 0600 (owner rw). Add group-rw only if the probe has
					// group-write - indicates PHP and web server share a group.
					$key_mode = 0600;
					if ( ( $stat[2] & 0020 ) === 0020 ) {
						$key_mode |= 0060;
					}
					return $key_mode;
				}
			}
		}
		return 0600; // owner only - most restrictive safe default.
	}

	/**
	 * Write a known key to the key file (for backup restoration).
	 *
	 * Removes any existing file first, then writes the provided key.
	 *
	 * @param string $key Raw 32-byte key.
	 * @return true|\WP_Error True on success, WP_Error on failure.
	 */
	public static function write_key_file( string $key ) {
		if ( strlen( $key ) !== self::KEY_BYTES ) {
			return new \WP_Error( 'invalid_key', 'Key must be exactly ' . self::KEY_BYTES . ' bytes.' );
		}

		// Cannot overwrite when key source is a constant.
		if ( defined( 'SECURWP_MASTER_KEY' ) ) {
			return new \WP_Error( 'key_is_constant', 'Key is managed via SECURWP_MASTER_KEY constant - update wp-config.php instead.' );
		}

		$path    = self::get_key_file_path();
		$dir     = dirname( $path );
		$content = self::build_key_file_content( base64_encode( $key ), 'Restored' );

		// Atomic write: temp file + rename prevents concurrent reads seeing partial content.
		$tmp_path = $path . '.tmp.' . bin2hex( random_bytes( 4 ) );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Exclusive-create temp file prevents clobbering concurrent key restoration writes.
		$handle = @fopen( $tmp_path, 'x' );
		if ( false === $handle ) {
			return new \WP_Error( 'write_failed', 'Could not create temporary key file. Check directory permissions.' );
		}

		if ( ! self::harden_key_file_permissions( $tmp_path, $dir ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose( $handle );
			wp_delete_file( $tmp_path );
			return new \WP_Error( 'write_failed', 'Could not apply restrictive permissions before writing key file.' );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Temporary file write is followed by an atomic rename to avoid partial master-key reads.
		$written = fwrite( $handle, $content );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing exclusive-create handle after atomic master-key write.
		fclose( $handle );
		if ( $written === false || $written < strlen( $content ) ) {
			wp_delete_file( $tmp_path );
			return new \WP_Error( 'write_failed', 'Could not write key file. Check available disk space.' );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic rename prevents readers from ever seeing a partially written master-key file.
		if ( ! @rename( $tmp_path, $path ) ) {
			wp_delete_file( $tmp_path );
			return new \WP_Error( 'write_failed', 'Could not finalize key file. Check directory permissions.' );
		}

		self::$cache      = $key;
		self::$last_error = null;

		return true;
	}
}
