<?php

namespace IPWhitelistBlacklist;

use WP_PowerSuite\Captcha\Shared\ClientIp;
use WP_PowerSuite\Core\AbstractModule;
use WP_PowerSuite\Core\OptionStoreModuleSettingsRepository;

class Module extends AbstractModule {

	private const MODULE_ID = 'ip-whitelist-blacklist';
	private const MODULE_VERSION = '1.2.1';

	/** @var list<string> Headers that must not be used without a trusted reverse proxy. */
	private const BLOCKED_IP_HEADERS = [
		'HTTP_X_FORWARDED_FOR',
		'HTTP_X_FORWARDED',
		'HTTP_FORWARDED_FOR',
		'HTTP_FORWARDED',
		'HTTP_CLIENT_IP',
		'HTTP_X_CLUSTER_CLIENT_IP',
	];

	/** @var array<string, mixed>|null Full module settings (request cache). */
	private ?array $settings_cache = null;

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'IP Whitelist/Blacklist', 'wppowersuite-lite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Allow or block visitors by IP address—ideal for office-only dashboards or shutting out known troublemakers. Rules apply site-wide, including wp-admin and login.', 'wppowersuite-lite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax', 'rest' ];
	}

	public function getDependencies(): array {
		return [];
	}

	protected function registerHooks(): void {
		if ( ! $this->isLicenseActive() ) {
			return;
		}

		if ( defined( 'WP_CLI' ) && WP_CLI ) {
			return;
		}

		if ( is_admin() ) {
			add_filter( 'upload_mimes', [ $this, 'allowHtmlUploadMime' ] );
		}

		if ( is_admin() && ! wp_doing_ajax() ) {
			if ( $this->hasConfiguredIpRules() ) {
				add_action( 'admin_init', [ $this, 'enforceAdminAccess' ], 0 );
			}
			return;
		}

		if ( ! $this->hasConfiguredIpRules() ) {
			return;
		}

		add_action( 'login_init', [ $this, 'enforceLoginAccess' ], 0 );
		add_action( 'init', [ $this, 'enforceFrontendAccess' ], 0 );
		add_action( 'wp_loaded', [ $this, 'enforceAjaxAndXmlRpc' ], 0 );
		add_action( 'rest_api_init', [ $this, 'registerRestProtection' ], 1 );
	}

	/**
	 * Allow .html uploads for the blocked-page template (trusted admins, upload AJAX only).
	 *
	 * @param array<string, string> $mimes Mime types.
	 * @return array<string, string>
	 */
	public function allowHtmlUploadMime( array $mimes ): array {
		if ( ! $this->canManagePlugin() || ! $this->isHtmlUploadContext() ) {
			return $mimes;
		}
		$mimes['html'] = 'text/html';
		$mimes['htm']  = 'text/html';
		return $mimes;
	}

	private function isHtmlUploadContext(): bool {
		if ( ! wp_doing_ajax() ) {
			return false;
		}
		$action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( (string) $_REQUEST['action'] ) ) : '';
		return in_array( $action, [ 'upload-attachment', 'async-upload', 'media-upload' ], true );
	}

	public function enforceLoginAccess(): void {
		$this->maybeBlockRequest( 'html' );
	}

	public function enforceFrontendAccess(): void {
		if ( is_admin() ) {
			return;
		}
		if ( wp_doing_ajax() ) {
			return;
		}
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return;
		}
		$this->maybeBlockRequest( 'html' );
	}

	public function enforceAdminAccess(): void {
		$this->maybeBlockRequest( 'admin' );
	}

	public function enforceAjaxAndXmlRpc(): void {
		if ( wp_doing_ajax() ) {
			$this->maybeBlockRequest( 'ajax' );
			return;
		}
		if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
			$this->maybeBlockRequest( 'xmlrpc' );
		}
	}

	public function registerRestProtection(): void {
		add_filter( 'rest_authentication_errors', [ $this, 'protectRestApi' ], 99 );
	}

	/**
	 * @param mixed $result Existing REST auth result.
	 * @return mixed
	 */
	public function protectRestApi( $result ) {
		if ( $this->shouldBypassEnforcement() ) {
			return $result;
		}

		$mode = $this->getBlockModeForCurrentIp();
		if ( $mode === null ) {
			return $result;
		}

		return new \WP_Error(
			'wpps_ip_access_denied',
			$this->getDeniedMessage( $mode ),
			[ 'status' => 403 ]
		);
	}

	/**
	 * @param 'html'|'admin'|'ajax'|'xmlrpc' $channel Response channel.
	 */
	private function maybeBlockRequest( string $channel ): void {
		if ( $this->shouldBypassEnforcement() ) {
			return;
		}

		$mode = $this->getBlockModeForCurrentIp();
		if ( $mode === null ) {
			return;
		}

		$this->denyAccess( $mode, $channel );
	}

	private function shouldBypassEnforcement(): bool {
		if ( wp_installing() ) {
			return true;
		}
		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			return true;
		}
		if ( defined( 'WPPS_IP_ACCESS_BYPASS' ) && WPPS_IP_ACCESS_BYPASS ) {
			return true;
		}
		if ( apply_filters( 'wpps_tl_is_token_login_request', false ) ) {
			return true;
		}
		if ( apply_filters( 'wp_powersuite_ip_whitelist_blacklist_bypass', false ) ) {
			return true;
		}
		return false;
	}

	/** @return 'blacklist'|'whitelist'|null */
	private function getBlockModeForCurrentIp(): ?string {
		$settings = $this->getSettings();
		$mode     = $settings['mode'] ?? 'blacklist';
		$ip_list  = $settings['ip_addresses'] ?? [];

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

		$client_ip  = self::getResolvedClientIp();
		$is_in_list = self::isClientIpInList( $client_ip, $ip_list );

		if ( $mode === 'blacklist' && $is_in_list ) {
			return 'blacklist';
		}
		if ( $mode === 'whitelist' && ! $is_in_list ) {
			return 'whitelist';
		}

		return null;
	}

	/**
	 * Whether a client IP matches any entry in the list (exact or CIDR).
	 *
	 * @param list<string> $ip_list
	 */
	public static function isClientIpInList( string $ip, array $ip_list ): bool {
		$ip_bin = @inet_pton( $ip );
		foreach ( $ip_list as $entry ) {
			$entry = trim( (string) $entry );
			if ( $entry === '' ) {
				continue;
			}

			if ( strpos( $entry, '/' ) !== false ) {
				if ( self::ipMatchesCIDR( $ip, $entry ) ) {
					return true;
				}
				continue;
			}

			if ( $ip_bin !== false ) {
				$entry_bin = @inet_pton( $entry );
				if ( $entry_bin !== false && hash_equals( $ip_bin, $entry_bin ) ) {
					return true;
				}
			}
			if ( $ip === $entry ) {
				return true;
			}
		}

		return false;
	}

	public static function ipMatchesCIDR( string $ip, string $cidr ): bool {
		$parts = explode( '/', $cidr, 2 );
		if ( count( $parts ) !== 2 ) {
			return false;
		}

		$subnet = $parts[0];
		$mask   = (int) $parts[1];

		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) || ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
			return false;
		}

		$is_ipv4        = filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 );
		$is_subnet_ipv4 = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 );

		if ( $is_ipv4 !== $is_subnet_ipv4 ) {
			return false;
		}

		$max_mask = $is_ipv4 ? 32 : 128;
		if ( $mask < 1 || $mask > $max_mask ) {
			return false;
		}

		$ip_bin     = inet_pton( $ip );
		$subnet_bin = inet_pton( $subnet );
		if ( $ip_bin === false || $subnet_bin === false ) {
			return false;
		}

		$bytes_to_compare = (int) floor( $mask / 8 );
		$bits_to_compare  = $mask % 8;

		if ( $bytes_to_compare > 0 && substr( $ip_bin, 0, $bytes_to_compare ) !== substr( $subnet_bin, 0, $bytes_to_compare ) ) {
			return false;
		}

		if ( $bits_to_compare > 0 ) {
			$byte_len = strlen( $ip_bin );
			if ( $bytes_to_compare >= $byte_len ) {
				return false;
			}
			$ip_byte     = ord( $ip_bin[ $bytes_to_compare ] );
			$subnet_byte = ord( $subnet_bin[ $bytes_to_compare ] );
			$mask_byte   = 0xFF << ( 8 - $bits_to_compare );

			if ( ( $ip_byte & $mask_byte ) !== ( $subnet_byte & $mask_byte ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * @param 'blacklist'|'whitelist' $mode
	 * @param 'html'|'admin'|'ajax'|'xmlrpc' $channel
	 */
	private function denyAccess( string $mode, string $channel ): void {
		/**
		 * Fires when a request is blocked by IP rules.
		 *
		 * @param string $client_ip Resolved client IP.
		 * @param string $mode      'blacklist' or 'whitelist'.
		 * @param string $channel   Response channel.
		 */
		do_action(
			'wp_powersuite_ip_whitelist_blacklist_blocked',
			self::getResolvedClientIp(),
			$mode,
			$channel
		);

		status_header( 403 );
		nocache_headers();

		if ( $channel === 'ajax' ) {
			wp_send_json_error(
				[ 'message' => $this->getDeniedMessage( $mode ) ],
				403
			);
		}

		if ( $channel === 'admin' || $channel === 'xmlrpc' ) {
			wp_die(
				esc_html( $this->getDeniedMessage( $mode ) ),
				esc_html__( 'Access Denied', 'wppowersuite-lite' ),
				[ 'response' => 403 ]
			);
		}

		$this->renderBlockedPage( $mode );
	}

	/** @param 'blacklist'|'whitelist' $mode */
	private function getDeniedMessage( string $mode ): string {
		return $mode === 'blacklist'
			? __( 'You are not allowed to visit this website. Your IP address has been blocked.', 'wppowersuite-lite' )
			: __( 'You are not allowed to visit this website. Your IP address is not whitelisted.', 'wppowersuite-lite' );
	}

	/** @param 'blacklist'|'whitelist' $mode */
	private function renderBlockedPage( string $mode ): void {
		$settings = $this->getSettings();
		$blocked  = $this->getBlockedPageSettings( $settings );

		$tpl = $blocked['blocked_template'] ?? 'default';
		if ( $tpl === 'html' && ! empty( $blocked['blocked_html_attachment_id'] ) ) {
			$this->trySendBlockedHtmlAttachment( (int) $blocked['blocked_html_attachment_id'] );
		}

		$blocked['mode'] = $mode;
		if ( empty( $blocked['headline'] ) ) {
			$blocked['headline'] = __( 'Access Denied', 'wppowersuite-lite' );
		}
		if ( empty( $blocked['message'] ) ) {
			$blocked['message'] = $this->getDeniedMessage( $mode );
		}

		$template = __DIR__ . '/views/blocked-page.php';
		if ( file_exists( $template ) && is_readable( $template ) ) {
			include $template;
		} else {
			wp_die(
				esc_html( $blocked['message'] ),
				esc_html( $blocked['headline'] ),
				[ 'response' => 403 ]
			);
		}
		exit;
	}

	private function trySendBlockedHtmlAttachment( int $attachment_id ): void {
		$att = get_post( $attachment_id );
		if ( ! $att || $att->post_type !== 'attachment' ) {
			return;
		}

		$path = get_attached_file( $attachment_id );
		if ( ! $path || ! is_readable( $path ) ) {
			return;
		}

		$file_ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
		if ( ! in_array( $file_ext, [ 'html', 'htm' ], true ) ) {
			return;
		}

		$mime = (string) get_post_mime_type( $att );
		if ( $mime !== '' && ! str_contains( $mime, 'html' ) && $mime !== 'text/plain' ) {
			return;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local validated path.
		$html = file_get_contents( $path );
		if ( $html === false ) {
			return;
		}

		status_header( 403 );
		nocache_headers();

		// Run through wp_kses_post() regardless of whether the upload is a full
		// document or a fragment: this strips <' . 'script>/<' . 'style>/event handlers and
		// any DOCTYPE/html/head/body wrapper tags, leaving only safe inner markup.
		echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body>';
		echo wp_kses_post( $html ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_kses_post() sanitizes.
		echo '</body></html>';
		exit;
	}

	private function getBlockedPageSettings( array $settings ): array {
		$blocked = $settings['blocked_page'] ?? [];
		if ( ! is_array( $blocked ) ) {
			$blocked = [];
		}

		$defaults = [
			'blocked_template'             => 'default',
			'blocked_html_attachment_id'   => 0,
			'title'                        => __( 'Access Denied', 'wppowersuite-lite' ),
			'headline'                     => __( 'Access Denied', 'wppowersuite-lite' ),
			'message'                      => '',
			'logo_attachment_id'           => 0,
			'logo_url'                     => '',
			'bg_color'                     => '#f0f0f1',
			'text_color'                   => '#1d2327',
		];

		$out = array_merge( $defaults, $blocked );

		$tpl = isset( $out['blocked_template'] ) ? sanitize_key( (string) $out['blocked_template'] ) : 'default';
		if ( $tpl === 'page' ) {
			$tpl = 'default';
		}
		if ( ! in_array( $tpl, [ 'default', 'html' ], true ) ) {
			$tpl = 'default';
		}
		$out['blocked_template'] = $tpl;
		unset( $out['blocked_page_id'] );

		$logo_id = isset( $out['logo_attachment_id'] ) ? (int) $out['logo_attachment_id'] : 0;
		if ( $logo_id > 0 ) {
			$url = wp_get_attachment_image_url( $logo_id, 'full' );
			if ( ! $url ) {
				$url = wp_get_attachment_url( $logo_id );
			}
			if ( $url ) {
				$out['logo_url'] = $url;
			}
		} elseif ( ! empty( $out['logo_url'] ) && is_string( $out['logo_url'] ) ) {
			$out['logo_url'] = esc_url_raw( $out['logo_url'] );
		} else {
			$out['logo_url'] = '';
		}

		return $out;
	}

	public static function isValidListEntry( string $entry ): bool {
		$entry = trim( $entry );
		if ( $entry === '' ) {
			return false;
		}

		if ( strpos( $entry, '/' ) !== false ) {
			$parts = explode( '/', $entry, 2 );
			if ( count( $parts ) !== 2 ) {
				return false;
			}
			$subnet = $parts[0];
			if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
				return false;
			}
			if ( ! is_numeric( $parts[1] ) ) {
				return false;
			}
			$mask = (int) $parts[1];
			$max  = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? 32 : 128;
			return $mask >= 1 && $mask <= $max;
		}

		return (bool) filter_var( $entry, FILTER_VALIDATE_IP );
	}

	/**
	 * @param list<string> $lines
	 * @return list<string>
	 */
	public static function filterValidListEntries( array $lines ): array {
		$valid = [];
		foreach ( $lines as $line ) {
			$line = trim( (string) $line );
			if ( $line !== '' && self::isValidListEntry( $line ) ) {
				$valid[] = $line;
			}
		}
		return array_values( $valid );
	}

	private function hasConfiguredIpRules(): bool {
		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			return false;
		}
		return self::filterValidListEntries( self::parseIpAddressLines( $settings['ip_addresses'] ?? null ) ) !== [];
	}

	/**
	 * @param mixed $raw Stored ip_addresses value.
	 * @return list<string>
	 */
	private static function parseIpAddressLines( mixed $raw ): array {
		if ( is_array( $raw ) ) {
			return array_values(
				array_filter(
					array_map( 'trim', $raw ),
					static fn( $ip ) => is_string( $ip ) && $ip !== ''
				)
			);
		}
		if ( is_string( $raw ) && trim( $raw ) !== '' ) {
			$lines = explode( "\n", $raw );
			return array_values(
				array_filter(
					array_map( 'trim', $lines ),
					static fn( $ip ) => $ip !== ''
				)
			);
		}
		return [];
	}

	/**
	 * Resolve the client IP used for enforcement.
	 *
	 * Order: custom header when REMOTE_ADDR is a trusted proxy → ClientIp chain → REMOTE_ADDR.
	 * Override with filter `wp_powersuite_ip_whitelist_blacklist_client_ip`.
	 *
	 * @param array<string, mixed> $settings_override Optional unsaved settings (e.g. during save validation).
	 */
	public static function getResolvedClientIp( array $settings_override = [] ): string {
		$ip_header = '';
		if ( array_key_exists( 'ip_header', $settings_override ) ) {
			$ip_header = self::normalizeIpHeader( $settings_override['ip_header'] );
		} else {
			$stored    = OptionStoreModuleSettingsRepository::forModule( self::MODULE_ID )->all( [] );
			$ip_header = self::normalizeIpHeader( is_array( $stored ) ? ( $stored['ip_header'] ?? '' ) : '' );
		}

		if ( $ip_header !== '' && class_exists( ClientIp::class ) && ClientIp::isFromTrustedProxy() ) {
			$custom_ip = ClientIp::validatedIpFromServer( $ip_header );
			if ( $custom_ip !== '' ) {
				return self::applyClientIpFilter( $custom_ip );
			}
		}

		if ( class_exists( ClientIp::class ) ) {
			$ip = ClientIp::resolveOrDefault();
		} else {
			$ip = self::readServerIp( 'REMOTE_ADDR' );
			if ( $ip === '' ) {
				$ip = '0.0.0.0';
			}
		}

		return self::applyClientIpFilter( $ip );
	}

	/**
	 * Human-readable label for how the detected IP was resolved (admin UI).
	 */
	public static function getIpSourceLabel( array $settings_override = [] ): string {
		$ip_header = '';
		if ( array_key_exists( 'ip_header', $settings_override ) ) {
			$ip_header = self::normalizeIpHeader( $settings_override['ip_header'] );
		} else {
			$stored    = OptionStoreModuleSettingsRepository::forModule( self::MODULE_ID )->all( [] );
			$ip_header = self::normalizeIpHeader( is_array( $stored ) ? ( $stored['ip_header'] ?? '' ) : '' );
		}

		if (
			$ip_header !== ''
			&& class_exists( ClientIp::class )
			&& ClientIp::isFromTrustedProxy()
			&& ClientIp::validatedIpFromServer( $ip_header ) !== ''
		) {
			/* translators: %s: PHP $_SERVER header key */
			return sprintf( __( 'Custom header (%s)', 'wppowersuite-lite' ), $ip_header );
		}
		if ( self::readServerIp( 'HTTP_CF_CONNECTING_IP' ) !== '' ) {
			return __( 'Cloudflare (CF-Connecting-IP)', 'wppowersuite-lite' );
		}
		if ( class_exists( ClientIp::class ) && ClientIp::resolve() !== '' && ClientIp::resolve() !== self::readServerIp( 'REMOTE_ADDR' ) ) {
			return __( 'Trusted proxy header', 'wppowersuite-lite' );
		}
		return __( 'Direct connection (REMOTE_ADDR)', 'wppowersuite-lite' );
	}

	private static function applyClientIpFilter( string $ip ): string {
		$filtered = apply_filters( 'wp_powersuite_ip_whitelist_blacklist_client_ip', $ip );
		if ( is_string( $filtered ) && filter_var( $filtered, FILTER_VALIDATE_IP ) ) {
			return $filtered;
		}
		return $ip;
	}

	private static function readServerIp( string $header_key ): string {
		if ( empty( $_SERVER[ $header_key ] ) ) {
			return '';
		}
		$raw = sanitize_text_field( wp_unslash( (string) $_SERVER[ $header_key ] ) );
		if ( strpos( $raw, ',' ) !== false ) {
			$parts = explode( ',', $raw );
			$raw   = trim( (string) $parts[0] );
		}
		if ( filter_var( $raw, FILTER_VALIDATE_IP ) ) {
			return $raw;
		}
		return '';
	}

	/**
	 * @param mixed $raw $_SERVER-style header name.
	 */
	public static function normalizeIpHeader( $raw ): string {
		$header = strtoupper( str_replace( '-', '_', trim( (string) $raw ) ) );
		if ( $header === '' ) {
			return '';
		}
		if ( ! preg_match( '/^[A-Z][A-Z0-9_]*$/', $header ) ) {
			return '';
		}
		if ( in_array( $header, self::BLOCKED_IP_HEADERS, true ) ) {
			return '';
		}
		return $header;
	}

	private function isLicenseActive(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	private function getSettings(): array {
		if ( $this->settings_cache !== null ) {
			return $this->settings_cache;
		}

		$settings = $this->moduleSettings()->all( [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		$ip_addresses = self::filterValidListEntries( self::parseIpAddressLines( $settings['ip_addresses'] ?? null ) );

		$blocked_page = $settings['blocked_page'] ?? [];
		if ( ! is_array( $blocked_page ) ) {
			$blocked_page = [];
		}

		$mode = isset( $settings['mode'] ) ? (string) $settings['mode'] : 'blacklist';
		if ( ! in_array( $mode, [ 'blacklist', 'whitelist' ], true ) ) {
			$mode = 'blacklist';
		}

		$this->settings_cache = [
			'mode'         => $mode,
			'ip_addresses' => $ip_addresses,
			'ip_header'    => self::normalizeIpHeader( $settings['ip_header'] ?? '' ),
			'blocked_page' => $blocked_page,
		];

		return $this->settings_cache;
	}

}
