<?php
/**
 * Collects live consent intake into ConsentSignal objects.
 *
 * @package Carticy\Conversions\Consent
 */

declare(strict_types=1);

namespace Carticy\Conversions\Consent;

use Carticy\Conversions\Domain\ConsentSignal;
use Carticy\Conversions\Domain\ConsentState;

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

/**
 * Normalizes WP Consent API, Consent Mode, first-party bus, and generic CMP rows.
 */
final class ConsentSignalCollector {

	public const SIGNAL_COOKIE       = 'carticy_conversions_consent_signals';
	public const CONSENT_MODE_COOKIE = 'carticy_conversions_consent_mode';

	/**
	 * Preset registry.
	 *
	 * @var GenericConsentPresetRegistry
	 */
	private GenericConsentPresetRegistry $registry;

	/**
	 * Constructor.
	 *
	 * @param GenericConsentPresetRegistry|null $registry Preset registry.
	 */
	public function __construct( ?GenericConsentPresetRegistry $registry = null ) {
		$this->registry = $registry ?? new GenericConsentPresetRegistry();
	}

	/**
	 * Collect every configured source for the current request.
	 *
	 * @return array<int, ConsentSignal>
	 */
	public function collect_from_request(): array {
		$signals = array_merge(
			$this->collect_wp_consent_api(),
			$this->collect_consent_mode(),
			$this->collect_first_party_bus(),
			$this->collect_generic_presets()
		);

		return array_values(
			array_filter(
				$signals,
				static function ( $signal ): bool {
					return $signal instanceof ConsentSignal;
				}
			)
		);
	}

	/**
	 * Build a signal from a first-party normalized array.
	 *
	 * @param array<string, mixed> $data Raw signal data.
	 * @return ConsentSignal|null
	 */
	public function signal_from_array( array $data ): ?ConsentSignal {
		$source = trim( (string) ( $data['source'] ?? '' ) );
		if ( '' === $source ) {
			return null;
		}

		// External integrations (the first-party bus, the PHP filter) cannot
		// be required to send a version: an empty one would later make the
		// ConsentRecord constructor throw inside checkout hooks. Default it
		// honestly instead of failing the order.
		$source_version = trim( (string) ( $data['source_version'] ?? '' ) );
		if ( '' === $source_version ) {
			$source_version = 'unversioned';
		}

		return new ConsentSignal(
			$source,
			$data['state'] ?? ConsentState::UNSET,
			(bool) ( $data['is_explicit'] ?? false ),
			is_array( $data['categories'] ?? null ) ? $data['categories'] : array(),
			is_array( $data['services'] ?? null ) ? $data['services'] : array(),
			$source_version,
			(string) ( $data['captured_at'] ?? '' ),
			(string) ( $data['consent_mode'] ?? '' ),
			(string) ( $data['tcf_string'] ?? '' )
		);
	}

	/**
	 * Browser adapters approved for first-party storefront capture.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function browser_adapters(): array {
		return $this->registry->browser_adapters();
	}

	/**
	 * Convert a Google Consent Mode v2 snapshot into scoped signals.
	 *
	 * @param array<string, mixed> $state Consent Mode state.
	 * @return ConsentSignal|null
	 */
	public function signal_from_consent_mode( array $state ): ?ConsentSignal {
		$analytics = $this->normalize_consent_mode_value( $state['analytics_storage'] ?? null );
		$ad_values = array(
			$this->normalize_consent_mode_value( $state['ad_storage'] ?? null ),
			$this->normalize_consent_mode_value( $state['ad_user_data'] ?? null ),
			$this->normalize_consent_mode_value( $state['ad_personalization'] ?? null ),
		);
		$marketing = $this->resolve_consent_mode_group( $ad_values );

		if ( ConsentState::UNSET === $analytics && ConsentState::UNSET === $marketing ) {
			return null;
		}

		$categories = array_filter(
			array(
				'statistics' => $analytics,
				'marketing'  => $marketing,
			),
			static function ( string $value ): bool {
				return ConsentState::UNSET !== $value;
			}
		);

		$services = array();
		if ( ConsentState::UNSET !== $analytics ) {
			$services['ga4'] = $analytics;
		}
		if ( ConsentState::UNSET !== $marketing ) {
			$services['google-ads'] = $marketing;
			$services['meta']       = $marketing;
		}

		return new ConsentSignal(
			'google-consent-mode-v2',
			$this->overall_from_scopes( $categories ),
			true,
			$categories,
			$services,
			'v2',
			'',
			wp_json_encode( $state )
		);
	}

	/**
	 * Convert one generic preset row plus a raw cookie/global value into a signal.
	 *
	 * @param array<string, mixed> $preset Preset row.
	 * @param string               $raw_value Raw cookie/global value.
	 * @return ConsentSignal|null
	 */
	public function signal_from_generic_value( array $preset, string $raw_value ): ?ConsentSignal {
		$value      = rawurldecode( $raw_value );
		$categories = array();

		foreach ( is_array( $preset['categories'] ?? null ) ? $preset['categories'] : array() as $category => $rules ) {
			if ( ! is_array( $rules ) ) {
				continue;
			}

			$key = sanitize_key( (string) $category );
			if ( '' === $key ) {
				continue;
			}

			if ( isset( $rules['deny_pattern'] ) && 1 === preg_match( (string) $rules['deny_pattern'], $value ) ) {
				$categories[ $key ] = ConsentState::DENIED;
				continue;
			}

			if ( isset( $rules['grant_pattern'] ) && 1 === preg_match( (string) $rules['grant_pattern'], $value ) ) {
				$categories[ $key ] = ConsentState::GRANTED;
			}
		}

		if ( array() === $categories ) {
			return null;
		}

		// What an unmatched category means is preset data, not a global rule:
		// CMPs whose cookie enumerates every category exhaustively (Cookiebot,
		// OneTrust) declare "denied" - absence IS the decision there. The
		// generic default is "unset": a pattern miss on an unknown format must
		// not fabricate an explicit denial that deny-dominates a real grant
		// from another source.
		$missing_state = ConsentState::normalize( (string) ( $preset['missing_category_state'] ?? ConsentState::UNSET ) );

		$services = array();
		foreach ( is_array( $preset['services'] ?? null ) ? $preset['services'] : array() as $service => $category ) {
			$service_key  = sanitize_key( (string) $service );
			$category_key = sanitize_key( (string) $category );
			if ( '' === $service_key || '' === $category_key ) {
				continue;
			}

			$state = $categories[ $category_key ] ?? $missing_state;
			if ( ConsentState::UNSET === $state ) {
				continue; // Omission means unset; never store a fabricated decision.
			}
			$services[ $service_key ] = $state;
		}

		// A preset may also mark decisions non-explicit by pattern (e.g.
		// Cookiebot's method:'implied' - consent recorded without an
		// affirmative act must not satisfy the explicit allow-list).
		$is_explicit = true;
		if ( isset( $preset['explicit_pattern'] ) && 1 !== preg_match( (string) $preset['explicit_pattern'], $value ) ) {
			$is_explicit = false;
		}

		return new ConsentSignal(
			(string) ( $preset['source'] ?? 'generic-consent-adapter' ),
			$this->overall_from_scopes( $categories ),
			$is_explicit,
			$categories,
			$services,
			(string) ( $preset['source_version'] ?? 'preset' )
		);
	}

	/**
	 * Collect WP Consent API state when the API exists.
	 *
	 * @return array<int, ConsentSignal>
	 */
	private function collect_wp_consent_api(): array {
		if ( ! function_exists( 'wp_has_consent' ) ) {
			return array();
		}

		$categories = array();
		foreach ( array( 'marketing', 'statistics', 'preferences', 'functional' ) as $category ) {
			$categories[ $category ] = wp_has_consent( $category ) ? ConsentState::GRANTED : ConsentState::DENIED;
		}

		$is_explicit = (bool) apply_filters( 'carticy_conversions_wp_consent_api_is_explicit', false, $categories );
		$services    = array(
			'meta'       => $this->wp_service_consent_state( 'meta', 'marketing', $categories ),
			'google-ads' => $this->wp_service_consent_state( 'google-ads', 'marketing', $categories ),
			'ga4'        => $this->wp_service_consent_state( 'ga4', 'statistics', $categories ),
		);

		return array(
			new ConsentSignal(
				'wp-consent-api',
				$this->overall_from_scopes( $categories ),
				$is_explicit,
				$categories,
				$services,
				'wp-consent-api'
			),
		);
	}

	/**
	 * Resolve a WP Consent API service decision with category fallback.
	 *
	 * @param string                $service Service slug.
	 * @param string                $fallback_category Category slug used when the service API is unavailable.
	 * @param array<string, string> $categories Category consent states.
	 * @return string
	 */
	private function wp_service_consent_state( string $service, string $fallback_category, array $categories ): string {
		// wp_has_service_consent() returns false both for "denied" and for
		// "service never registered" - and no CMP registers this plugin's
		// service slugs by default. A false therefore falls back to the
		// category decision instead of fabricating a service-level denial
		// that would deny-dominate real grants from other sources.
		if ( function_exists( 'wp_has_service_consent' ) && wp_has_service_consent( $service ) ) {
			return ConsentState::GRANTED;
		}

		return $categories[ $fallback_category ] ?? ConsentState::UNSET;
	}

	/**
	 * Collect Google Consent Mode snapshot from the first-party bridge cookie.
	 *
	 * @return array<int, ConsentSignal>
	 */
	private function collect_consent_mode(): array {
		$data = $this->read_json_cookie( self::CONSENT_MODE_COOKIE );
		if ( array() === $data ) {
			return array();
		}

		$signal = $this->signal_from_consent_mode( $data );

		return $signal instanceof ConsentSignal ? array( $signal ) : array();
	}

	/**
	 * Collect signals from the first-party JS bus and PHP filter.
	 *
	 * @return array<int, ConsentSignal>
	 */
	private function collect_first_party_bus(): array {
		$signals = array();
		$payload = $this->read_json_cookie( self::SIGNAL_COOKIE );

		foreach ( $this->normalize_signal_payload( $payload ) as $signal_data ) {
			$signal = $this->signal_from_array( $signal_data );
			if ( $signal instanceof ConsentSignal ) {
				$signals[] = $signal;
			}
		}

		$filtered = apply_filters( 'carticy_conversions_consent_signals', array() );
		foreach ( $this->normalize_signal_payload( is_array( $filtered ) ? $filtered : array() ) as $signal_data ) {
			$signal = $this->signal_from_array( $signal_data );
			if ( $signal instanceof ConsentSignal ) {
				$signals[] = $signal;
			}
		}

		return $signals;
	}

	/**
	 * Collect data-driven generic CMP preset signals.
	 *
	 * @return array<int, ConsentSignal>
	 */
	private function collect_generic_presets(): array {
		$signals = array();

		foreach ( $this->registry->presets() as $preset ) {
			$cookie_name = (string) ( $preset['cookie_name'] ?? '' );
			if ( '' === $cookie_name || ! isset( $_COOKIE[ $cookie_name ] ) ) {
				continue;
			}

			$signal = $this->signal_from_generic_value( $preset, sanitize_text_field( wp_unslash( (string) $_COOKIE[ $cookie_name ] ) ) );
			if ( $signal instanceof ConsentSignal ) {
				$signals[] = $signal;
			}
		}

		return $signals;
	}

	/**
	 * Read a JSON cookie safely.
	 *
	 * @param string $name Cookie name.
	 * @return array<string|int, mixed>
	 */
	private function read_json_cookie( string $name ): array {
		if ( ! isset( $_COOKIE[ $name ] ) ) {
			return array();
		}

		$decoded = json_decode( sanitize_text_field( wp_unslash( (string) $_COOKIE[ $name ] ) ), true );

		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * Normalize list-or-single signal payloads into a list of arrays.
	 *
	 * @param array<string|int, mixed> $payload Raw payload.
	 * @return array<int, array<string, mixed>>
	 */
	private function normalize_signal_payload( array $payload ): array {
		if ( array() === $payload ) {
			return array();
		}

		if ( isset( $payload['source'] ) ) {
			return array( $payload );
		}

		$signals = array();
		foreach ( $payload as $entry ) {
			if ( is_array( $entry ) && isset( $entry['source'] ) ) {
				$signals[] = $entry;
			}
		}

		return $signals;
	}

	/**
	 * Normalize Consent Mode values.
	 *
	 * @param mixed $value Raw value.
	 * @return string
	 */
	private function normalize_consent_mode_value( $value ): string {
		return ConsentState::normalize( $value );
	}

	/**
	 * Resolve a Consent Mode service group.
	 *
	 * @param array<int, string> $states States.
	 * @return string
	 */
	private function resolve_consent_mode_group( array $states ): string {
		if ( in_array( ConsentState::DENIED, $states, true ) ) {
			return ConsentState::DENIED;
		}

		return in_array( ConsentState::GRANTED, $states, true ) ? ConsentState::GRANTED : ConsentState::UNSET;
	}

	/**
	 * Overall signal state from scoped category/service decisions.
	 *
	 * @param array<string, string> $scopes Scoped states.
	 * @return string
	 */
	private function overall_from_scopes( array $scopes ): string {
		if ( in_array( ConsentState::DENIED, $scopes, true ) ) {
			return ConsentState::DENIED;
		}

		return in_array( ConsentState::GRANTED, $scopes, true ) ? ConsentState::GRANTED : ConsentState::UNSET;
	}
}
