<?php

declare(strict_types=1);

namespace SiteFort\Modules\Headers;

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

use SiteFort\Includes\Interfaces\Module;
use SiteFort\Includes\Module_Loader;
use SiteFort\Includes\SettingsStore;

/**
 * Security Headers module controller.
 *
 * Manages the registration of hooks for sending HTTP security headers
 * and exposes configuration through the REST API.
 *
 */
class Headers implements Module {
	private const ALLOWED_CSP_PRESETS = [ 'strict', 'moderate', 'wordpress' ];
	private const ALLOWED_CSP_MODES = [ 'enforce', 'report-only' ];
	private const ALLOWED_XFO_VALUES = [ 'DENY', 'SAMEORIGIN' ];
	private const DEPRECATED_SETTINGS_KEYS = [ 'remove_server_header' ];

	/**
	 * The plugin identifier.
	 *
	 * @var string
	 */
	private string $plugin_name;

	/**
	 * The plugin version.
	 *
	 * @var string
	 */
	private string $version;

	/**
	 * Loaded and merged settings.
	 *
	 * @var array
	 */
	private array $settings;

	/**
	 * REST API handler instance.
	 *
	 * @var HeadersApi
	 */
	private HeadersApi $api;

	/**
	 * Settings table column for header settings.
	 */
	private const SETTINGS_COLUMN = SettingsStore::COL_HEADERS;

	/**
	 * Constructor.
	 *
	 * @param string $plugin_name The name of the plugin.
	 * @param string $version     The version of this plugin.
	 */
	public function __construct( string $plugin_name, string $version ) {
		$this->plugin_name = $plugin_name;
		$this->version     = $version;
		$this->load_settings();
	}

	/**
	 * Initialize the module by registering hooks with the loader.
	 *
	 * @param Module_Loader $loader The module loader instance.
	 * @return void
	 */
	public function initialize( Module_Loader $loader ): void {
		// Register the action that sends security headers on every request.
		$loader->add_action( 'send_headers', $this, 'apply_headers', 10, 0 );

		// Remove X-Generator meta tag early so it is gone before wp_head fires.
		if ( ! empty( $this->settings['remove_x_generator'] ) ) {
			remove_action( 'wp_head', 'wp_generator' );
		}

		// Initialize the REST API handler and register its routes.
		$this->api = new HeadersApi();
		$loader->add_action( 'rest_api_init', $this->api, 'register_routes', 10, 0 );
	}

	/**
	 * Load settings from the database, merged with defaults.
	 *
	 * @return void
	 */
	private function load_settings(): void {
		$defaults   = self::get_default_settings();
		$raw        = SettingsStore::get( self::SETTINGS_COLUMN, $defaults );
		$normalized = self::normalize_settings( $raw );

		$this->settings = $normalized;

		// In-memory normalization above serves this request; the stored row is
		// rewritten at most once per plugin version, not on every read.
		$this->persist_headers_normalization_once( $raw, $normalized );
	}

	/** State-column marker recording the version the stored headers row was normalized at. */
	private const NORMALIZED_VERSION_STATE_KEY = 'headers_normalized_ver';

	/**
	 * Persist the normalized headers settings at most once per plugin version.
	 *
	 * @param array $raw        Stored settings as read (defaults-merged, un-normalized).
	 * @param array $normalized Canonical settings to persist.
	 * @return void
	 */
	private function persist_headers_normalization_once( array $raw, array $normalized ): void {
		$version = defined( 'SITEFORT_VERSION' ) ? SITEFORT_VERSION : '';
		$marker  = (string) SettingsStore::get_field(
			SettingsStore::COL_STATE,
			self::NORMALIZED_VERSION_STATE_KEY,
			''
		);
		if ( $version !== '' && $marker === $version ) {
			return;
		}

		$saved = ( $normalized !== $raw )
			? SettingsStore::save( self::SETTINGS_COLUMN, $normalized )
			: true;

		if ( $saved && $version !== '' ) {
			SettingsStore::set_field(
				SettingsStore::COL_STATE,
				self::NORMALIZED_VERSION_STATE_KEY,
				$version
			);
		}
	}

	/**
	 * Apply security headers to the current response.
	 *
	 * Hooked to the WordPress 'send_headers' action. Will skip execution
	 * when headers have already been sent, or when on an admin page and
	 * the csp_admin_skip setting is enabled.
	 *
	 * @return void
	 */
	public function apply_headers(): void {
		// Emergency kill switch: define('SITEFORT_DISABLE_HEADERS', true) in wp-config.php.
		if ( defined( 'SITEFORT_DISABLE_HEADERS' ) && SITEFORT_DISABLE_HEADERS ) {
			return;
		}

		// Never attempt to send headers after output has started.
		if ( headers_sent() ) {
			return;
		}

		// Reload settings so any changes via the REST API are picked up
		// without requiring a separate page load.
		$this->load_settings();

		// Skip CSP on admin pages: temporarily disable CSP in settings for admin page header generation.
		$settings = $this->settings;

		if ( ! empty( $settings['csp_admin_skip'] ) && is_admin() ) {
			$settings['csp_enabled'] = false;
		}

		// Login URLs can carry a short-lived auth token. Enforce no-referrer
		// there so the token path/query cannot leak via Referer headers.
		if ( $this->is_login_or_signup_request() ) {
			$settings['referrer_enabled'] = true;
			$settings['referrer_value']   = 'no-referrer';
		}

		$headers = HeaderPolicy::get_all_headers( $settings );

		foreach ( $headers as $name => $value ) {
			header( $name . ': ' . $value );
		}

		// Remove information disclosure headers from this response without mutating PHP globals.
		if ( ! empty( $settings['remove_x_powered_by'] ) ) {
			header_remove( 'X-Powered-By' );
		}
	}

	/**
	 * Get the current merged settings.
	 *
	 * @return array The settings array.
	 */
	public function get_settings(): array {
		return $this->settings;
	}

	/**
	 * Sanitize and merge a partial settings patch onto the current headers settings.
	 *
	 * @param array<string,mixed> $current Current settings.
	 * @param array<string,mixed> $patch   Incoming patch.
	 * @return array<string,mixed>
	 */
	public static function sanitize_settings_patch( array $current, array $patch ): array {
		$defaults  = self::get_default_settings();
		$sanitized = self::normalize_settings( array_merge( $defaults, $current ) );

		foreach ( $patch as $key => $value ) {
			$key = sanitize_key( (string) $key );
			if ( ! array_key_exists( $key, $defaults ) ) {
				continue;
			}

			$sanitized[ $key ] = self::sanitize_setting_value( $key, $value );
		}

		return self::normalize_settings( $sanitized );
	}

	/**
	 * Remove deprecated settings keys from the headers settings payload.
	 *
	 * @param array<string,mixed> $settings Settings array from storage or input.
	 * @return array<string,mixed>
	 */
	public static function normalize_settings( array $settings ): array {
		foreach ( self::DEPRECATED_SETTINGS_KEYS as $key ) {
			unset( $settings[ $key ] );
		}

		return $settings;
	}

	/**
	 * @param mixed $value
	 * @return mixed
	 */
	private static function sanitize_setting_value( string $key, $value ) {
		$boolean_fields = [
			'csp_enabled',
			'csp_admin_skip',
			'hsts_enabled',
			'hsts_subdomains',
			'hsts_preload',
			'xcto_enabled',
			'xfo_enabled',
			'referrer_enabled',
			'permissions_enabled',
			'remove_x_powered_by',
			'remove_x_generator',
		];

		if ( in_array( $key, $boolean_fields, true ) ) {
			return rest_sanitize_boolean( $value );
		}

		if ( 'hsts_max_age' === $key ) {
			return min( absint( $value ), 63072000 );
		}

		if ( 'csp_preset' === $key ) {
			$clean = sanitize_text_field( (string) $value );
			return in_array( $clean, self::ALLOWED_CSP_PRESETS, true ) ? $clean : 'wordpress';
		}

		if ( 'csp_mode' === $key ) {
			$clean = sanitize_text_field( (string) $value );
			return in_array( $clean, self::ALLOWED_CSP_MODES, true ) ? $clean : 'enforce';
		}

		if ( 'xfo_value' === $key ) {
			$clean = strtoupper( sanitize_text_field( (string) $value ) );
			return in_array( $clean, self::ALLOWED_XFO_VALUES, true ) ? $clean : 'SAMEORIGIN';
		}

		if ( 'referrer_value' === $key ) {
			$clean = sanitize_text_field( (string) $value );
			$valid = HeaderPolicy::get_valid_referrer_values();
			return in_array( $clean, $valid, true ) ? $clean : 'strict-origin-when-cross-origin';
		}

		if ( 'csp_custom_directives' === $key || 'permissions_directives' === $key ) {
			return self::sanitize_directive_array( $value );
		}

		return sanitize_text_field( (string) $value );
	}

	/**
	 * @param mixed $directives
	 * @return array<string,string>
	 */
	private static function sanitize_directive_array( $directives ): array {
		if ( ! is_array( $directives ) ) {
			return [];
		}

		$sanitized = [];
		foreach ( $directives as $directive => $value ) {
			$clean_key = sanitize_text_field( (string) $directive );
			if ( '' === $clean_key ) {
				continue;
			}

			$sanitized[ $clean_key ] = sanitize_text_field( (string) $value );
		}

		return $sanitized;
	}

	/**
	 * Preserve request path bytes for routing checks while removing invalid controls.
	 *
	 * @param mixed $value
	 */
	public static function sanitize_server_request_scalar( $value ): string {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$value = str_replace( "\0", '', (string) $value );
		$value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value );

		return is_string( $value ) ? $value : '';
	}

	/**
	 * Return true for wp-login.php/wp-signup.php requests.
	 *
	 * Uses path-only matching (query excluded) and strips subdirectory
	 * install prefixes so checks remain correct on /blog/ style installs.
	 */
	private function is_login_or_signup_request(): bool {
		$request_uri = filter_input(
			INPUT_SERVER,
			'REQUEST_URI',
			FILTER_CALLBACK,
			[ 'options' => [ self::class, 'sanitize_server_request_scalar' ] ]
		);
		$request_uri = is_string( $request_uri ) ? $request_uri : '';
		$request_uri = (string) preg_replace( '|//+|', '/', $request_uri );
		$path        = trim( (string) wp_parse_url( $request_uri, PHP_URL_PATH ), '/' );

		$site_base = trim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' );
		if ( '' !== $site_base ) {
			$prefix = $site_base . '/';
			if ( strpos( $path, $prefix ) === 0 ) {
				$path = substr( $path, strlen( $prefix ) );
			} elseif ( $path === $site_base ) {
				$path = '';
			}
		}

		if ( $path === 'wp-login.php' || $path === 'wp-signup.php' ) {
			return true;
		}

		$decoded = trim( urldecode( $path ), '/' );
		return $decoded === 'wp-login.php' || $decoded === 'wp-signup.php';
	}

	/**
	 * Get the default settings for the Headers module.
	 *
	 * @return array Default settings with all keys defined.
	 */
	public static function get_default_settings(): array {
		return [
			// Content-Security-Policy
			'csp_enabled'           => false,
			'csp_mode'              => 'enforce',       // 'enforce' or 'report-only'
			'csp_preset'            => 'wordpress',     // 'strict', 'moderate', 'wordpress'
			'csp_custom_directives' => [],
			'csp_admin_skip'        => true,

			// Strict-Transport-Security
			'hsts_enabled'    => false,
			'hsts_max_age'    => 31536000, // 1 year in seconds
			'hsts_subdomains' => false,
			'hsts_preload'    => false,

			// X-Content-Type-Options
			'xcto_enabled' => false,

			// X-Frame-Options
			'xfo_enabled' => false,
			'xfo_value'   => 'SAMEORIGIN', // 'DENY' or 'SAMEORIGIN'

			// Referrer-Policy
			'referrer_enabled' => false,
			'referrer_value'   => 'strict-origin-when-cross-origin',

			// Permissions-Policy
			'permissions_enabled'    => false,
			'permissions_directives' => [
				'camera'      => '()',
				'microphone'  => '()',
				'geolocation' => '(self)',
				'payment'     => '(self)',
				'usb'         => '()',
				'fullscreen'  => '(self)',
				'autoplay'    => '()',
			],

			// Information disclosure header removal
			'remove_x_powered_by' => false,
			'remove_x_generator'  => false,
		];
	}
}
