<?php

declare(strict_types=1);

namespace SiteFort\Modules\Tools;

use SiteFort\Includes\FilesystemMutation;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Salts regeneration tool.
 *
 * Regenerates all WordPress authentication salt constants in wp-config.php
 * to invalidate existing sessions and strengthen authentication security.
 *
 * Uses random_int() as the primary salt generator with
 * wp_generate_password() as a fallback. Writes are applied through a temporary
 * file and rename, with validation before and after the update.
 *
 */
class SaltsRegeneration {

	/**
	 * WordPress salt constant names.
	 */
	private const SALT_CONSTANTS = [
		'AUTH_KEY',
		'SECURE_AUTH_KEY',
		'LOGGED_IN_KEY',
		'NONCE_KEY',
		'AUTH_SALT',
		'SECURE_AUTH_SALT',
		'LOGGED_IN_SALT',
		'NONCE_SALT',
	];

	/**
	 * Whether wp-config.php can be rewritten directly without filesystem credentials.
	 *
	 * Returns true for non-permission blockers so callers can surface the real
	 * operational error instead of prompting for credentials that will not help.
	 */
	public static function can_regenerate_without_credentials(): bool {
		if ( ! ConfigFilePolicy::can_write_to_files() ) {
			return true;
		}

		$wp_config = ConfigFilePolicy::get_wp_config_path();
		if ( ! is_string( $wp_config ) || $wp_config === '' || ! file_exists( $wp_config ) ) {
			return true;
		}

		return wp_is_writable( $wp_config ) || wp_is_writable( dirname( $wp_config ) );
	}

	/**
	 * Acquire an atomic mutex lock using INSERT IGNORE.
	 *
	 * @param string $name    Lock name.
	 * @param int    $expires TTL in seconds.
	 * @return bool True if the lock was acquired.
	 */
	private function acquire_lock( string $name, int $expires = 180 ): bool {
		global $wpdb;
		$options_table = $this->table_identifier( $wpdb->options );

		$lock_option = 'sitefort_lock_' . $name;
		$now         = time();
		$release_at  = $now + $expires;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$insert_sql = $wpdb->prepare(
			"INSERT IGNORE INTO {$options_table} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
			$lock_option,
			(string) $release_at
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex acquisition uses an atomic INSERT IGNORE on the options table.
		$inserted = $wpdb->query( $insert_sql );

		if ( $inserted ) {
			return true;
		}

		// Check if expired and atomically reclaim with a WHERE clause.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$lock_sql = $wpdb->prepare(
			"SELECT option_value FROM {$options_table} WHERE option_name = %s LIMIT 1",
			$lock_option
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
		$locked_until = (int) $wpdb->get_var( $lock_sql );
		if ( $locked_until < $now ) {
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			$reclaim_sql = $wpdb->prepare(
				"UPDATE {$options_table} SET option_value = %s WHERE option_name = %s AND option_value = %s",
				(string) $release_at,
				$lock_option,
				(string) $locked_until
			);
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex reclamation updates a single options row atomically.
			$reclaimed = $wpdb->query( $reclaim_sql );
			return $reclaimed > 0;
		}

		return false;
	}

	/**
	 * Release a mutex lock.
	 *
	 * @param string $name Lock name.
	 */
	private function release_lock( string $name ): void {
		delete_option( 'sitefort_lock_' . $name );
	}

	private function table_identifier( string $table_name ): string {
		if ( $table_name === '' || strlen( $table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table_name ) ) {
			throw new \InvalidArgumentException( 'Invalid table identifier.' );
		}

		return '`' . $table_name . '`';
	}

	/**
	 * Generate a single cryptographically secure salt string.
	 *
	 * Primary: random_int() with a full printable character set (matching
	 * the WordPress.org secret-key API's charset). Fallback: wp_generate_password().
	 *
	 * @param int $length Salt length in characters.
	 * @return string The generated salt.
	 */
	private function generate_salt( int $length = 64 ): string {
		$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_ []{}<>~`+=,.;:/?|';
		$max   = strlen( $chars ) - 1;

		try {
			$salt = '';
			for ( $i = 0; $i < $length; $i++ ) {
				$salt .= $chars[ random_int( 0, $max ) ];
			}
			return $salt;
		} catch ( \Exception $e ) {
			// Fallback: wp_generate_password uses PHP's CSPRNG internally
			// but with a smaller character set.
			return wp_generate_password( $length, true, true );
		}
	}

	/**
	 * Escape a salt value for safe embedding inside a PHP single-quoted string.
	 *
	 * @param string $salt Raw salt value.
	 * @return string Escaped salt.
	 */
	private function escape_salt_for_php( string $salt ): string {
		// Escape backslashes first, then single quotes - stable order.
		$salt = str_replace( '\\', '\\\\', $salt );
		$salt = str_replace( "'", "\\'", $salt );
		return $salt;
	}

	/**
	 * Build a define() line for a salt constant.
	 *
	 * @param string $const       Constant name.
	 * @param string $escaped_salt Already-escaped salt value.
	 * @return string The full define() statement.
	 */
	private function build_define_line( string $const, string $escaped_salt ): string {
		return "define('" . $const . "', '" . $escaped_salt . "');";
	}

	/**
	 * Find the best insertion point for missing salt defines.
	 *
	 * Strategy (in order):
	 * 1. After the last existing salt/key define() line.
	 * 2. Before the "That's all, stop editing!" comment (WordPress standard).
	 * 3. Before the ABSPATH define.
	 *
	 * @param string $content wp-config.php content.
	 * @return int|false Byte offset for insertion, or false if no safe point found.
	 */
	private function find_injection_point( string $content ) {
		// Strategy 1: After the last existing salt define.
		$last_offset = 0;
		$found_any   = false;
		foreach ( self::SALT_CONSTANTS as $const ) {
			$pattern = "/define\s*\(\s*['\"]" . preg_quote( $const, '/' ) . "['\"]\s*,\s*['\"].*?['\"]\s*\)\s*;[^\n]*\n?/";
			if ( preg_match( $pattern, $content, $m, PREG_OFFSET_CAPTURE ) ) {
				$end = $m[0][1] + strlen( $m[0][0] );
				if ( $end > $last_offset ) {
					$last_offset = $end;
					$found_any   = true;
				}
			}
		}
		if ( $found_any ) {
			return $last_offset;
		}

		// Strategy 2: Before "That's all, stop editing!" comment.
		if ( preg_match( "/^[\/\*#\s]*That's all, stop editing!/m", $content, $m, PREG_OFFSET_CAPTURE ) ) {
			return $m[0][1];
		}

		// Strategy 3: Before the ABSPATH check.
		if ( preg_match( '/if\s*\(\s*!\s*defined\s*\(\s*[\'"]ABSPATH[\'"]\s*\)/', $content, $m, PREG_OFFSET_CAPTURE ) ) {
			return $m[0][1];
		}

		return false;
	}

	/**
	 * Locate wp-config.php, which may reside one directory above ABSPATH.
	 *
	 * WordPress allows wp-config.php to be moved one level up from the
	 * WordPress root for security. This mirrors the detection logic used
	 * by WordPress core during bootstrap.
	 *
	 * @return string|false Full path to wp-config.php, or false if not found.
	 */
	private function locate_wp_config() {
		return ConfigFilePolicy::get_wp_config_path();
	}

	/**
	 * Regenerate all WordPress authentication salts.
	 *
	 * Replaces existing salt defines and injects any that are missing.
	 * Detects WordPress placeholder values ('put your unique phrase here')
	 * and treats them as needing replacement. Generates CSPRNG salts,
	 * performs an atomic write via temp file + rename, and verifies the
	 * result by re-reading the written file. A mutex lock prevents
	 * concurrent execution. A backup is created and restored on any failure.
	 *
	 * @return true|\WP_Error True on success, WP_Error on failure.
	 */
	public function regenerate() {
		if ( ! ConfigFilePolicy::can_write_to_files() ) {
			return new \WP_Error(
				'writes_disabled',
				'Write to Files is disabled in SiteFort Settings. Enable it to regenerate salts automatically, or update wp-config.php manually.'
			);
		}

		$wp_config = $this->locate_wp_config();
		if ( $wp_config === false ) {
			return new \WP_Error(
				'config_not_found',
				'Could not locate wp-config.php in ABSPATH or one directory above.'
			);
		}
		$config_dir = dirname( $wp_config );

		if ( ! file_exists( $wp_config ) || ! wp_is_writable( $wp_config ) ) {
			return new \WP_Error(
				'not_writable',
				'wp-config.php is not writable.'
			);
		}
		if ( ! wp_is_writable( $config_dir ) ) {
			return new \WP_Error(
				'dir_not_writable',
				'wp-config.php parent directory is not writable (required for safe atomic write).'
			);
		}

		// Acquire mutex to prevent concurrent execution.
		if ( ! $this->acquire_lock( 'salts_regeneration' ) ) {
			return new \WP_Error(
				'locked',
				'Salt regeneration is already running. Please try again in a few minutes.'
			);
		}

		$content = FilesystemMutation::read_file_contents( $wp_config );
		if ( ! $content ) {
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'read_failed',
				'Could not read wp-config.php.'
			);
		}

		$backup = $wp_config . '.sitefort-salts-backup-' . bin2hex( random_bytes( 8 ) );
		if ( ! FilesystemMutation::copy_path( $wp_config, $backup ) ) {
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'backup_failed',
				'Could not create backup of wp-config.php.'
			);
		}

		// ── Categorize each constant: exists (replace) or missing (inject) ─
		$to_replace = [];
		$to_inject  = [];

		foreach ( self::SALT_CONSTANTS as $const ) {
			$pattern = "/define\s*\(\s*['\"]" . preg_quote( $const, '/' ) . "['\"]\s*,\s*['\"].*?['\"]\s*\)/";
			if ( preg_match( $pattern, $content ) ) {
				$to_replace[] = $const;
			} else {
				$to_inject[] = $const;
			}
		}

		$replaced_count = 0;

		foreach ( $to_replace as $const ) {
			$new_salt     = $this->generate_salt( 64 );
			$escaped_salt = $this->escape_salt_for_php( $new_salt );
			$pattern      = "/define\s*\(\s*['\"]" . preg_quote( $const, '/' ) . "['\"]\s*,\s*['\"].*?['\"]\s*\)/";
			$count        = 0;

			// Use preg_replace_callback to avoid dollar sign interpretation.
			$const_captured = $const;
			$salt_captured  = $escaped_salt;

			$content = preg_replace_callback( $pattern, function () use ( $const_captured, $salt_captured ) {
				return "define('" . $const_captured . "', '" . $salt_captured . "')";
			}, $content, 1, $count );

			$replaced_count += $count;
		}

		// Verify all targeted replacements succeeded.
		if ( $replaced_count !== count( $to_replace ) ) {
			FilesystemMutation::delete_path( $backup );
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'incomplete_replacement',
				'Only ' . $replaced_count . ' of ' . count( $to_replace ) . ' existing salt constants were replaced. Aborting to prevent inconsistent state.'
			);
		}

		if ( ! empty( $to_inject ) ) {
			$injection_point = $this->find_injection_point( $content );
			if ( $injection_point === false ) {
				FilesystemMutation::delete_path( $backup );
				$this->release_lock( 'salts_regeneration' );
				return new \WP_Error(
					'no_injection_point',
					'Could not find a safe location in wp-config.php to inject missing salt constants: ' . implode( ', ', $to_inject ) . '.'
				);
			}

			$lines = [];
			foreach ( $to_inject as $const ) {
				$new_salt     = $this->generate_salt( 64 );
				$escaped_salt = $this->escape_salt_for_php( $new_salt );
				$lines[]      = $this->build_define_line( $const, $escaped_salt );
			}

			$injection = "\n" . implode( "\n", $lines ) . "\n";
			$content   = substr_replace( $content, $injection, $injection_point, 0 );
		}

		// ── Atomic write: temp file → rename ───────────────────────────
		$temp_file = tempnam( $config_dir, '.wp-config-sitefort-' );
		if ( $temp_file === false ) {
			FilesystemMutation::delete_path( $backup );
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'temp_failed',
				'Could not create temporary file for atomic write.'
			);
		}

		// Preserve original file permissions on the temp file.
		$original_perms = fileperms( $wp_config );
		if ( $original_perms !== false ) {
			FilesystemMutation::apply_file_permissions( $temp_file, $original_perms & 0777 );
		}

		$bytes_written  = FilesystemMutation::write_bytes_to_file( $temp_file, $content, is_int( $original_perms ) ? ( $original_perms & 0777 ) : null )
			? strlen( $content )
			: false;
		$expected_bytes = strlen( $content );

		if ( $bytes_written === false || $bytes_written !== $expected_bytes ) {
			FilesystemMutation::delete_path( $temp_file );
			FilesystemMutation::copy_path( $backup, $wp_config, is_int( $original_perms ) ? ( $original_perms & 0777 ) : null );
			FilesystemMutation::delete_path( $backup );
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'write_failed',
				'Could not write to temporary file (wrote ' . ( $bytes_written ?: 0 ) . ' of ' . $expected_bytes . ' bytes). Backup restored.'
			);
		}

		// Atomic rename (overwrites target on POSIX systems).
		if ( ! FilesystemMutation::move_path( $temp_file, $wp_config ) ) {
			FilesystemMutation::delete_path( $temp_file );
			FilesystemMutation::copy_path( $backup, $wp_config, is_int( $original_perms ) ? ( $original_perms & 0777 ) : null );
			FilesystemMutation::delete_path( $backup );
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'rename_failed',
				'Atomic rename of wp-config.php failed. Backup restored.'
			);
		}

		// Re-read the written file and verify it's syntactically valid PHP
		// and still contains all 8 salt constants.
		$verification = FilesystemMutation::read_file_contents( $wp_config );
		if ( ! $verification ) {
			// Cannot verify - restore backup as a safety measure.
			FilesystemMutation::copy_path( $backup, $wp_config, is_int( $original_perms ) ? ( $original_perms & 0777 ) : null );
			FilesystemMutation::delete_path( $backup );
			$this->release_lock( 'salts_regeneration' );
			return new \WP_Error(
				'verify_read_failed',
				'Could not re-read wp-config.php for verification. Backup restored.'
			);
		}

		// Verify all 8 constants are still present in the written file.
		foreach ( self::SALT_CONSTANTS as $const ) {
			$pattern = "/define\s*\(\s*['\"]" . preg_quote( $const, '/' ) . "['\"]\s*,\s*['\"].*?['\"]\s*\)/";
			if ( ! preg_match( $pattern, $verification ) ) {
				FilesystemMutation::copy_path( $backup, $wp_config, is_int( $original_perms ) ? ( $original_perms & 0777 ) : null );
				FilesystemMutation::delete_path( $backup );
				$this->release_lock( 'salts_regeneration' );
				return new \WP_Error(
					'verify_failed',
					'Post-write verification failed: constant "' . $const . '" not found. Backup restored.'
				);
			}
		}

		// Clean up backup (successful write verified).
		FilesystemMutation::delete_path( $backup );

		/**
		 * Fires after WordPress authentication salts have been regenerated.
		 *
		 * All existing sessions are now invalid because the HMAC keys have changed.
		 * Allows other plugins and modules to perform related cleanup or logging.
		 *
		 */
		do_action( 'sitefort_salts_regenerated' );

		$this->release_lock( 'salts_regeneration' );

		return true;
	}

	/**
	 * Get the list of salt constant names.
	 *
	 * @return array
	 */
	public static function get_salt_constants(): array {
		return self::SALT_CONSTANTS;
	}
}
