<?php
/**
 * Shared Captcha Skip Rules
 *
 * Roadmap item #28: role-based + IP-based skip rules. Centralised so every
 * captcha module applies the same semantics; modules opt in by calling
 * {@see self::shouldSkip()} at the very top of their verify path.
 *
 * Skip is additive: a request is skipped if ANY of the enabled rules match.
 * Loggedoff/anonymous users never short-circuit via role rules (they have no
 * roles) — only IP allowlist can skip them.
 *
 * The helper is intentionally inert until a captcha module calls it, so it
 * adds zero load when all captcha modules are disabled.
 *
 * @package WP_PowerSuite\Captcha\Shared
 * @since 1.1.0
 */

namespace WP_PowerSuite\Captcha\Shared;

/**
 * Skip rule evaluator.
 *
 * Options shape (inside each module's `skip_rules` array):
 *   [
 *     'enabled'    => bool,
 *     'roles'       => array<string> (role slugs, e.g. ['administrator','editor'])
 *     'ips'         => array<string> (IPs or CIDR)
 *     'user_agents' => array<string> (substring match against HTTP_USER_AGENT)
 *   ]
 */
final class SkipRules {

	/**
	 * Evaluate skip rules for the current request.
	 *
	 * @param array<string,mixed> $rules  Skip rule settings for this module.
	 * @return bool True when the captcha challenge should be bypassed.
	 */
	public static function shouldSkip( array $rules ): bool {
		if ( empty( $rules['enabled'] ) ) {
			return false;
		}

		if ( ! empty( $rules['roles'] ) && is_array( $rules['roles'] ) && self::userHasRole( $rules['roles'] ) ) {
			return true;
		}

		if ( ! empty( $rules['ips'] ) && is_array( $rules['ips'] ) && self::ipMatches( $rules['ips'] ) ) {
			return true;
		}

		if ( ! empty( $rules['user_agents'] ) && is_array( $rules['user_agents'] ) && self::userAgentMatches( $rules['user_agents'] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Return true when the current user has any of the given roles.
	 *
	 * @param array<string> $roles Role slugs.
	 */
	private static function userHasRole( array $roles ): bool {
		if ( ! is_user_logged_in() ) {
			return false;
		}
		$user = wp_get_current_user();
		if ( ! $user || empty( $user->roles ) ) {
			return false;
		}
		foreach ( $roles as $role ) {
			$role = (string) $role;
			if ( $role !== '' && in_array( $role, (array) $user->roles, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array<string> $fragments Substrings; empty entries ignored.
	 */
	private static function userAgentMatches( array $fragments ): bool {
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( $ua === '' ) {
			return false;
		}
		foreach ( $fragments as $frag ) {
			$frag = trim( (string) $frag );
			if ( $frag !== '' && str_contains( $ua, $frag ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Return true when the current client IP matches any allowlist entry.
	 *
	 * @param array<string> $entries IPv4/IPv6 or CIDR strings.
	 */
	private static function ipMatches( array $entries ): bool {
		$ip = ClientIp::resolve();
		if ( $ip === '' ) {
			return false;
		}
		foreach ( $entries as $entry ) {
			$entry = trim( (string) $entry );
			if ( $entry === '' ) {
				continue;
			}
			if ( strpos( $entry, '/' ) !== false ) {
				if ( self::cidrMatch( $ip, $entry ) ) {
					return true;
				}
				continue;
			}
			if ( hash_equals( $entry, $ip ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * CIDR check supporting IPv4 and IPv6.
	 *
	 * @param string $ip   Client IP.
	 * @param string $cidr CIDR entry, e.g. `10.0.0.0/24` or `2001:db8::/32`.
	 */
	private static function cidrMatch( string $ip, string $cidr ): bool {
		$parts = explode( '/', $cidr, 2 );
		if ( count( $parts ) !== 2 ) {
			return false;
		}
		$subnet = $parts[0];
		$prefix = (int) $parts[1];
		$ip_bin     = @inet_pton( $ip );
		$subnet_bin = @inet_pton( $subnet );
		if ( $ip_bin === false || $subnet_bin === false || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
			return false;
		}
		$bits   = strlen( $ip_bin ) * 8;
		$prefix = max( 0, min( $prefix, $bits ) );
		$bytes  = intdiv( $prefix, 8 );
		$remain = $prefix % 8;
		if ( $bytes > 0 && substr( $ip_bin, 0, $bytes ) !== substr( $subnet_bin, 0, $bytes ) ) {
			return false;
		}
		if ( $remain === 0 ) {
			return true;
		}
		$mask     = chr( 0xFF << ( 8 - $remain ) );
		$ip_part  = isset( $ip_bin[ $bytes ] ) ? $ip_bin[ $bytes ] : "\0";
		$sub_part = isset( $subnet_bin[ $bytes ] ) ? $subnet_bin[ $bytes ] : "\0";
		return ( ( $ip_part & $mask ) === ( $sub_part & $mask ) );
	}

	/**
	 * Normalise an admin-supplied newline-separated list into a clean array.
	 *
	 * @param string $raw  Raw textarea contents.
	 * @return array<string>
	 */
	public static function parseList( string $raw ): array {
		$lines = preg_split( '/\r\n|\r|\n/', $raw );
		if ( ! is_array( $lines ) ) {
			return [];
		}
		$out = [];
		foreach ( $lines as $line ) {
			$line = trim( (string) $line );
			if ( $line !== '' ) {
				$out[] = $line;
			}
		}
		return $out;
	}
}
