<?php
/**
 * Session Utilities
 *
 * Shared session, cookie, and IP resolution helpers used by frontend analytics modules.
 *
 * @package Niwa_AI
 * @since   0.10.25
 */

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

class Niwa_AI_Session_Utils {

	const CREDENTIAL_COOKIE = 'niwa_ai_session_credential';
	const CREDENTIAL_TTL    = 30 * DAY_IN_SECONDS;

	/**
	 * Resolve user session for anonymous frontend analytics.
	 *
	 * @return string
	 */
	public static function resolve_user_session() {
		return self::resolve_verified_session( true );
	}

	/**
	 * Resolve the server-owned frontend session from its signed HttpOnly cookie.
	 * Caller-provided session IDs are never trusted for anonymous access. A
	 * logged-in administrator may migrate a legacy session explicitly because
	 * that principal can already inspect frontend conversations in wp-admin.
	 *
	 * @param bool   $create_if_missing Issue a fresh credential when needed.
	 * @param string $legacy_session    Optional legacy session proposed by JS.
	 * @return string
	 */
	public static function resolve_verified_session( $create_if_missing = true, $legacy_session = '' ) {
		$credential = isset( $_COOKIE[ self::CREDENTIAL_COOKIE ] )
			? sanitize_text_field( wp_unslash( $_COOKIE[ self::CREDENTIAL_COOKIE ] ) )
			: '';
		$session    = self::verify_session_credential( $credential );
		if ( '' !== $session ) {
			return $session;
		}

		if ( ! $create_if_missing ) {
			return '';
		}

		$legacy_session = self::normalize_session_id( $legacy_session );
		if (
			'' !== $legacy_session
			&& function_exists( 'current_user_can' )
			&& current_user_can( 'manage_options' )
		) {
			$session = $legacy_session;
		} else {
			$session = 'frontend__guest_' . str_replace( '-', '', wp_generate_uuid4() );
		}

		self::set_session_cookie( $session );
		return $session;
	}

	/**
	 * Create a signed session credential. Public for focused regression tests.
	 *
	 * @param string $session Session ID.
	 * @param int    $issued_at Optional issue timestamp.
	 * @return string
	 */
	public static function create_session_credential( $session, $issued_at = 0 ) {
		$session = self::normalize_session_id( $session );
		if ( '' === $session ) {
			return '';
		}

		$payload = self::base64url_encode(
			wp_json_encode(
				array(
					'v'   => 1,
					'sid' => $session,
					'iat' => $issued_at > 0 ? (int) $issued_at : time(),
				)
			)
		);
		$mac     = hash_hmac( 'sha256', $payload, self::credential_secret() );

		return 'v1.' . $payload . '.' . $mac;
	}

	/**
	 * Verify a signed credential and return its session ID.
	 *
	 * @param string $credential Signed credential.
	 * @param int    $now Optional current timestamp.
	 * @return string
	 */
	public static function verify_session_credential( $credential, $now = 0 ) {
		$parts = explode( '.', trim( (string) $credential ) );
		if ( 3 !== count( $parts ) || 'v1' !== $parts[0] || ! preg_match( '/^[a-f0-9]{64}$/', $parts[2] ) ) {
			return '';
		}

		$expected = hash_hmac( 'sha256', $parts[1], self::credential_secret() );
		if ( ! hash_equals( $expected, $parts[2] ) ) {
			return '';
		}

		$decoded = json_decode( self::base64url_decode( $parts[1] ), true );
		$issued  = is_array( $decoded ) && isset( $decoded['iat'] ) ? (int) $decoded['iat'] : 0;
		$now     = $now > 0 ? (int) $now : time();
		if ( 1 !== (int) ( $decoded['v'] ?? 0 ) || $issued <= 0 || $issued > $now + 300 || $issued + self::CREDENTIAL_TTL < $now ) {
			return '';
		}

		return self::normalize_session_id( $decoded['sid'] ?? '' );
	}

	/**
	 * Set session cookie.
	 *
	 * @param string $session Session ID.
	 */
	public static function set_session_cookie( $session ) {
		$session = self::normalize_session_id( $session );
		if ( '' === $session || headers_sent() ) {
			return;
		}

		$expires = time() + ( Niwa_AI_Constants::SESSION_COOKIE_DAYS * DAY_IN_SECONDS );
		$path    = defined( 'COOKIEPATH' ) && COOKIEPATH ? COOKIEPATH : '/';
		$domain  = defined( 'COOKIE_DOMAIN' ) ? COOKIE_DOMAIN : '';
		$secure  = is_ssl();

		$credential = self::create_session_credential( $session );
		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				self::CREDENTIAL_COOKIE,
				$credential,
				array(
					'expires'  => $expires,
					'path'     => $path,
					'domain'   => $domain,
					'secure'   => $secure,
					'httponly' => true,
					'samesite' => 'Lax',
				)
			);
		} else {
			setcookie( self::CREDENTIAL_COOKIE, $credential, $expires, $path . '; samesite=Lax', $domain, $secure, true );
		}

		$_COOKIE[ self::CREDENTIAL_COOKIE ] = $credential;

		// Public ID remains for legacy attribution/UI only. It is not accepted as
		// proof of ownership by any protected endpoint.
		if ( PHP_VERSION_ID >= 70300 ) {
			setcookie(
				Niwa_AI_Constants::SESSION_COOKIE_NAME,
				$session,
				array(
					'expires'  => $expires,
					'path'     => $path,
					'domain'   => $domain,
					'secure'   => $secure,
					'httponly' => false,
					'samesite' => 'Lax',
				)
			);
		} else {
			setcookie( Niwa_AI_Constants::SESSION_COOKIE_NAME, $session, $expires, $path . '; samesite=Lax', $domain, $secure, false );
		}
		$_COOKIE[ Niwa_AI_Constants::SESSION_COOKIE_NAME ] = $session;
	}

	/**
	 * Atomically consume a fixed-window budget using the native options table.
	 * The previous bucket is deleted opportunistically, so counters do not grow.
	 *
	 * @param string $scope       Counter namespace.
	 * @param string $fingerprint Stable subject fingerprint.
	 * @param int    $limit       Maximum requests per window.
	 * @param int    $window      Window size in seconds.
	 * @return bool
	 */
	public static function consume_fixed_window_limit( $scope, $fingerprint, $limit, $window ) {
		global $wpdb;

		$limit  = max( 1, (int) $limit );
		$window = max( 1, (int) $window );
		$bucket = (int) floor( time() / $window );
		$base      = 'niwa_ai_rl_' . substr( sanitize_key( (string) $scope ), 0, 30 ) . '_' . md5( (string) $fingerprint ) . '_';
		$transient = $base . $bucket;
		$key       = '_transient_' . $transient;
		$timeout   = '_transient_timeout_' . $transient;
		delete_option( '_transient_' . $base . ( $bucket - 2 ) );
		delete_option( '_transient_timeout_' . $base . ( $bucket - 2 ) );
		add_option( $timeout, time() + ( 2 * $window ), '', false );

		if ( add_option( $key, 1, '', false ) ) {
			return true;
		}
		if ( ! isset( $wpdb ) || ! isset( $wpdb->options ) ) {
			$current = max( 0, (int) get_option( $key, 0 ) );
			return $current < $limit && update_option( $key, $current + 1, false );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->options} SET option_value = CAST(option_value AS UNSIGNED) + 1 WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d",
				$key,
				$limit
			)
		);
		wp_cache_delete( $transient, 'transient' );

		return 1 === (int) $updated;
	}

	/**
	 * Bind a browser-provided analytics ID to the verified server principal.
	 */
	public static function bind_public_id( $namespace, $public_id, $verified_session ) {
		$public_id = substr( preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $public_id ), 0, 96 );
		if ( '' === $public_id || '' === self::normalize_session_id( $verified_session ) ) {
			return '';
		}

		return substr( sanitize_key( $namespace ), 0, 16 ) . '_' . substr( hash_hmac( 'sha256', $public_id, self::credential_secret() . '|' . $verified_session ), 0, 48 );
	}

	private static function normalize_session_id( $session ) {
		$session = preg_replace( '/[^a-zA-Z0-9_\-]/', '', (string) $session );
		if ( '' === $session ) {
			return '';
		}
		$session = 0 === strpos( $session, 'frontend__' ) ? $session : 'frontend__' . $session;
		return substr( $session, 0, Niwa_AI_Constants::SESSION_MAX_LENGTH );
	}

	private static function credential_secret() {
		return wp_salt( 'auth' ) . '|niwa-ai-frontend-session-v1';
	}

	private static function base64url_encode( $value ) {
		return rtrim( strtr( base64_encode( (string) $value ), '+/', '-_' ), '=' );
	}

	private static function base64url_decode( $value ) {
		$value = strtr( (string) $value, '-_', '+/' );
		return (string) base64_decode( $value . str_repeat( '=', ( 4 - strlen( $value ) % 4 ) % 4 ), true );
	}

	/**
	 * Get client IP address.
	 *
	 * @return string
	 */
	public static function get_client_ip() {
		$remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0';

		/**
		 * Filter the list of trusted proxy IP addresses.
		 *
		 * Only when REMOTE_ADDR matches a trusted proxy will forwarded headers
		 * (X-Forwarded-For, CF-Connecting-IP, etc.) be consulted.
		 * Return an empty array to trust only REMOTE_ADDR (default).
		 *
		 * @param string[] $proxies List of trusted proxy IPs or CIDR ranges.
		 */
		$trusted_proxies = apply_filters( 'niwa_ai_trusted_proxies', array() );

		// Also honour wp-config constant for simple setups.
		if ( empty( $trusted_proxies ) && defined( 'NIWA_AI_TRUSTED_PROXIES' ) ) {
			$trusted_proxies = array_map( 'trim', explode( ',', NIWA_AI_TRUSTED_PROXIES ) );
		}

		// If no trusted proxies configured or REMOTE_ADDR is not a trusted proxy,
		// return REMOTE_ADDR directly — prevents header spoofing.
		if ( empty( $trusted_proxies ) || ! self::is_trusted_proxy( $remote_addr, $trusted_proxies ) ) {
			return filter_var( $remote_addr, FILTER_VALIDATE_IP ) ? $remote_addr : '0.0.0.0';
		}

		// REMOTE_ADDR is a trusted proxy — check forwarded headers.
		$forwarded_headers = array(
			'HTTP_CF_CONNECTING_IP', // Cloudflare.
			'HTTP_X_REAL_IP',        // Nginx proxy.
			'HTTP_X_FORWARDED_FOR',  // Proxy / load balancer.
			'HTTP_CLIENT_IP',
		);

		foreach ( $forwarded_headers as $header ) {
			if ( ! empty( $_SERVER[ $header ] ) ) {
				$raw = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
				// X-Forwarded-For can be comma-separated; take the first (client) IP.
				$ip = trim( explode( ',', $raw )[0] );
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		return filter_var( $remote_addr, FILTER_VALIDATE_IP ) ? $remote_addr : '0.0.0.0';
	}

	/**
	 * Check whether an IP matches a list of trusted proxies.
	 *
	 * @param string   $ip      IP address to check.
	 * @param string[] $proxies List of trusted IPs or CIDR ranges.
	 * @return bool
	 */
	private static function is_trusted_proxy( $ip, $proxies ) {
		foreach ( $proxies as $proxy ) {
			$proxy = trim( $proxy );
			if ( '' === $proxy ) {
				continue;
			}
			// Exact match.
			if ( $ip === $proxy ) {
				return true;
			}
			// CIDR match.
			if ( strpos( $proxy, '/' ) !== false && self::ip_in_cidr( $ip, $proxy ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Check whether an IP address falls within a CIDR range.
	 *
	 * @param string $ip   IP address.
	 * @param string $cidr CIDR notation (e.g. 10.0.0.0/8).
	 * @return bool
	 */
	private static function ip_in_cidr( $ip, $cidr ) {
		list( $subnet, $bits ) = array_pad( explode( '/', $cidr, 2 ), 2, '32' );
		$ip_long     = ip2long( $ip );
		$subnet_long = ip2long( $subnet );
		if ( false === $ip_long || false === $subnet_long ) {
			return false;
		}
		$mask = -1 << ( 32 - intval( $bits ) );
		return ( $ip_long & $mask ) === ( $subnet_long & $mask );
	}
}
