<?php
/**
 * Consent cookie reader/writer with HMAC signature.
 *
 * @package BlaminhorEssentials
 */

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

/**
 * Owns the first-party signed cookie that holds the visitor's consent choices.
 *
 * Format (base64-encoded JSON):
 *   {
 *     "v":  1,                                      // schema version
 *     "t":  1716800000,                             // unix timestamp
 *     "c":  {"essential":1,"preferences":0,...},    // category map
 *     "s":  {"google-analytics-4":1,...},           // per-service overrides (TCF)
 *     "p":  "2026.05",                              // policy version
 *     "l":  "fr_FR",                                // chosen language
 *     "src":"banner"                                // banner|revoke|gpc|reconsent
 *   }
 *
 * Followed by a `.` separator and the HMAC-SHA256 signature.
 */
class Blaminhor_Essentials_Cookies_Consent_Manager {

	const COOKIE_NAME = 'blaminhor_cookies_consent';
	const COOKIE_TTL  = 13 * MONTH_IN_SECONDS; // CNIL/EDPB ceiling.
	const HMAC_ALGO   = 'sha256';
	const SCHEMA_VER  = 1;

	const CATEGORY_ESSENTIAL   = 'essential';
	const CATEGORY_PREFERENCES = 'preferences';
	const CATEGORY_STATISTICS  = 'statistics';
	const CATEGORY_MARKETING   = 'marketing';

	/**
	 * @var string Policy version (bumping it forces a re-consent on next visit).
	 */
	protected $policy_version;

	public function __construct( $policy_version = '2026.05' ) {
		$this->policy_version = $policy_version;
	}

	/**
	 * Whether the visitor has a current, valid, non-expired consent cookie.
	 *
	 * @return bool
	 */
	public function has_valid_consent() {
		return (bool) $this->read_cookie();
	}

	/**
	 * Read and validate the consent cookie.
	 *
	 * @return array|false The decoded payload (with 'choices', 'services', 'policy_version', 'lang', 'timestamp') or false.
	 */
	public function read_cookie() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a public read of an unauthenticated cookie.
		if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) {
			return false;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Cookie content is base64+HMAC and is decoded/validated below.
		$raw = (string) $_COOKIE[ self::COOKIE_NAME ];
		return $this->decode_payload( $raw );
	}

	/**
	 * Decode + verify a cookie payload string.
	 *
	 * @param string $raw Raw cookie value.
	 * @return array|false
	 */
	public function decode_payload( $raw ) {
		if ( ! is_string( $raw ) || false === strpos( $raw, '.' ) ) {
			return false;
		}

		list( $payload_b64, $sig ) = explode( '.', $raw, 2 );

		$expected = hash_hmac( self::HMAC_ALGO, $payload_b64, $this->get_hmac_key() );
		if ( ! hash_equals( $expected, $sig ) ) {
			return false;
		}

		$json = base64_decode( strtr( $payload_b64, '-_', '+/' ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		if ( false === $json ) {
			return false;
		}

		$data = json_decode( $json, true );
		if ( ! is_array( $data ) || empty( $data['v'] ) ) {
			return false;
		}

		// Policy version mismatch => force re-consent.
		if ( ! empty( $data['p'] ) && $data['p'] !== $this->policy_version ) {
			return false;
		}

		$ttl_max = (int) apply_filters( 'blaminhor_cookies_consent_ttl', self::COOKIE_TTL );
		if ( ! empty( $data['t'] ) && ( time() - (int) $data['t'] ) > $ttl_max ) {
			return false;
		}

		return array(
			'schema_version' => (int) $data['v'],
			'timestamp'      => isset( $data['t'] ) ? (int) $data['t'] : 0,
			'choices'        => isset( $data['c'] ) && is_array( $data['c'] ) ? array_map( 'absint', $data['c'] ) : array(),
			'services'       => isset( $data['s'] ) && is_array( $data['s'] ) ? array_map( 'absint', $data['s'] ) : array(),
			'policy_version' => isset( $data['p'] ) ? (string) $data['p'] : '',
			'lang'           => isset( $data['l'] ) ? (string) $data['l'] : '',
			'source'         => isset( $data['src'] ) ? (string) $data['src'] : '',
		);
	}

	/**
	 * Build the cookie value for a given choice set.
	 *
	 * @param array  $choices  Map of category => 0|1.
	 * @param array  $services Map of service handle => 0|1.
	 * @param string $lang     Locale.
	 * @param string $source   banner|revoke|gpc|reconsent.
	 * @return string The cookie string to set.
	 */
	public function encode_payload( array $choices, array $services = array(), $lang = '', $source = 'banner' ) {
		$payload = array(
			'v'   => self::SCHEMA_VER,
			't'   => time(),
			'c'   => $this->normalize_choices( $choices ),
			's'   => array_map( 'absint', $services ),
			'p'   => $this->policy_version,
			'l'   => $lang,
			'src' => $source,
		);

		$payload_b64 = rtrim( strtr( base64_encode( wp_json_encode( $payload ) ), '+/', '-_' ), '=' );
		$sig         = hash_hmac( self::HMAC_ALGO, $payload_b64, $this->get_hmac_key() );
		return $payload_b64 . '.' . $sig;
	}

	/**
	 * Ensure 'essential' is always 1 and unknown keys are stripped.
	 *
	 * @param array $choices Raw choices.
	 * @return array Normalized choices.
	 */
	public function normalize_choices( array $choices ) {
		$keys = $this->get_categories();
		$out  = array();
		foreach ( $keys as $k ) {
			$out[ $k ] = ! empty( $choices[ $k ] ) ? 1 : 0;
		}
		$out[ self::CATEGORY_ESSENTIAL ] = 1; // Always granted.
		return $out;
	}

	/**
	 * Standard consent categories.
	 *
	 * @return string[]
	 */
	public function get_categories() {
		return array(
			self::CATEGORY_ESSENTIAL,
			self::CATEGORY_PREFERENCES,
			self::CATEGORY_STATISTICS,
			self::CATEGORY_MARKETING,
		);
	}

	/**
	 * Whether the visitor's browser sends a Global Privacy Control signal.
	 *
	 * @return bool
	 */
	public function has_gpc_signal() {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Header is read-only and compared exactly.
		if ( isset( $_SERVER['HTTP_SEC_GPC'] ) && '1' === (string) $_SERVER['HTTP_SEC_GPC'] ) {
			return true;
		}
		return false;
	}

	/**
	 * Compute a stable, salted hash of the visitor IP (one-way).
	 *
	 * @param string|null $ip Override IP for testing.
	 * @return string 64-char hex sha256.
	 */
	public function get_visitor_hash( $ip = null ) {
		if ( null === $ip ) {
			$ip = $this->get_client_ip();
		}
		return hash( 'sha256', $ip . '|' . wp_salt( 'auth' ) );
	}

	/**
	 * Stable hash of a user-agent string.
	 *
	 * @param string|null $ua Override UA for testing.
	 * @return string
	 */
	public function get_ua_hash( $ua = null ) {
		if ( null === $ua ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- We only hash it.
			$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
		}
		return hash( 'sha256', $ua );
	}

	/**
	 * Determine client IP honoring common reverse-proxy headers (read-only, no trust).
	 *
	 * @return string
	 */
	public function get_client_ip() {
		$candidates = array(
			'HTTP_CLIENT_IP',
			'HTTP_X_FORWARDED_FOR',
			'HTTP_X_REAL_IP',
			'HTTP_CF_CONNECTING_IP',
			'REMOTE_ADDR',
		);
		foreach ( $candidates as $key ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Validated by filter_var below.
			if ( ! empty( $_SERVER[ $key ] ) ) {
				$value = is_string( $_SERVER[ $key ] ) ? $_SERVER[ $key ] : '';
				$value = trim( explode( ',', $value )[0] );
				if ( filter_var( $value, FILTER_VALIDATE_IP ) ) {
					return $value;
				}
			}
		}
		return '0.0.0.0';
	}

	/**
	 * Derive a stable HMAC key from WP auth salts.
	 *
	 * @return string
	 */
	protected function get_hmac_key() {
		return wp_salt( 'auth' ) . '|blaminhor_cookies';
	}

	/**
	 * @return string Current policy version (bump to force re-consent).
	 */
	public function get_policy_version() {
		return $this->policy_version;
	}
}
