<?php
/**
 * A single normalized consent signal from one intake source.
 *
 * @package Carticy\Conversions\Domain
 */

declare(strict_types=1);

namespace Carticy\Conversions\Domain;

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

/**
 * One consent reading, normalized out of a heterogeneous intake source.
 *
 * The plugin accepts consent from several places - the WP Consent API, Google
 * Consent Mode v2, a merchant-configured SaaS cookie / JS global, the
 * first-party hook, and a merchant attestation. Each is adapted into this
 * uniform shape before {@see ConsentResolver} decides the authoritative record.
 *
 * The decisive field is {@see self::is_explicit()}: a real recorded decision
 * versus a library default. Only explicit signals can grant or deny - a default
 * (e.g. WP Consent API returning "allowed" with no opt-in regime configured)
 * never satisfies the allow-list and never suppresses a real grant.
 */
final class ConsentSignal {

	/**
	 * Source label (e.g. "wp-consent-api").
	 *
	 * @var string
	 */
	private string $source;

	/**
	 * Source/CMP version.
	 *
	 * @var string
	 */
	private string $source_version;

	/**
	 * Overall consent state for this source.
	 *
	 * @var string
	 */
	private string $state;

	/**
	 * Per-category states (e.g. "marketing", "statistics").
	 *
	 * @var array<string, string>
	 */
	private array $categories;

	/**
	 * Per-service states (e.g. "meta", "google-ads", "ga4").
	 *
	 * @var array<string, string>
	 */
	private array $services;

	/**
	 * True only for a real recorded decision (not a library default).
	 *
	 * @var bool
	 */
	private bool $is_explicit;

	/**
	 * GMT capture timestamp.
	 *
	 * @var string
	 */
	private string $captured_at;

	/**
	 * Google Consent Mode snapshot.
	 *
	 * @var string
	 */
	private string $consent_mode;

	/**
	 * TCF string when present.
	 *
	 * @var string
	 */
	private string $tcf_string;

	/**
	 * Constructor.
	 *
	 * @param string               $source Source label (e.g. "wp-consent-api").
	 * @param mixed                $state Overall consent state for this source.
	 * @param bool                 $is_explicit True only for a real recorded decision.
	 * @param array<string, mixed> $categories Per-category states.
	 * @param array<string, mixed> $services Per-service states.
	 * @param string               $source_version Source/CMP version.
	 * @param string               $captured_at GMT timestamp.
	 * @param string               $consent_mode Google Consent Mode snapshot.
	 * @param string               $tcf_string TCF string when present.
	 *
	 * @throws \InvalidArgumentException When the source label is empty.
	 */
	public function __construct(
		string $source,
		$state = ConsentState::UNSET,
		bool $is_explicit = false,
		array $categories = array(),
		array $services = array(),
		string $source_version = '',
		string $captured_at = '',
		string $consent_mode = '',
		string $tcf_string = ''
	) {
		$source = trim( $source );
		if ( '' === $source ) {
			throw new \InvalidArgumentException( 'A consent signal must declare its source.' );
		}

		$this->source         = $source;
		$this->state          = ConsentState::normalize( $state );
		$this->is_explicit    = $is_explicit;
		$this->categories     = $this->normalize_map( $categories );
		$this->services       = $this->normalize_map( $services );
		$this->source_version = trim( $source_version );
		$this->captured_at    = '' !== trim( $captured_at ) ? trim( $captured_at ) : gmdate( 'Y-m-d H:i:s' );
		$this->consent_mode   = trim( $consent_mode );
		$this->tcf_string     = trim( $tcf_string );
	}

	/**
	 * Build a synthetic, explicit grant representing a merchant attestation.
	 *
	 * Used when the store's posture is "consent not required": the gate still
	 * only ever passes on an explicit grant, so the attestation is modeled as a
	 * first-class explicit signal (logged, sourced, timestamped) rather than a
	 * silent bypass of the gate.
	 *
	 * @param string               $plugin_version Plugin version recording the attestation.
	 * @param string               $captured_at GMT timestamp of the attestation.
	 * @param array<string, mixed> $attestation Stored attestation details (actor, timestamp).
	 * @return self
	 */
	public static function merchant_attestation( string $plugin_version = '', string $captured_at = '', array $attestation = array() ): self {
		$plugin_version = '' !== trim( $plugin_version ) ? trim( $plugin_version ) : '0.0.0';

		// Tie the per-order signal to the recorded attestation so the audit
		// trail can prove WHO attested and WHEN, not just that a grant exists.
		$actor = isset( $attestation['actor_user_id'] ) ? (int) $attestation['actor_user_id'] : 0;
		if ( $actor > 0 ) {
			$plugin_version .= '|attested-by:' . $actor;
		}
		if ( '' === trim( $captured_at ) && ! empty( $attestation['attested_at'] ) ) {
			$captured_at = (string) $attestation['attested_at'];
		}

		return new self(
			'merchant-attestation',
			ConsentState::GRANTED,
			true,
			array(),
			array(),
			$plugin_version,
			$captured_at
		);
	}

	/**
	 * Source label.
	 *
	 * @return string
	 */
	public function source(): string {
		return $this->source;
	}

	/**
	 * Source/CMP version.
	 *
	 * @return string
	 */
	public function source_version(): string {
		return $this->source_version;
	}

	/**
	 * Overall consent state.
	 *
	 * @return string
	 */
	public function state(): string {
		return $this->state;
	}

	/**
	 * Whether this signal is a real recorded decision (not a library default).
	 *
	 * @return bool
	 */
	public function is_explicit(): bool {
		return $this->is_explicit;
	}

	/**
	 * Resolve the state this signal contributes for a given scope.
	 *
	 * Scope is "overall", a category key, or a service key. A non-explicit
	 * signal contributes nothing (UNSET) so it can never tip the allow-list.
	 *
	 * @param string $scope Resolution scope.
	 * @return string
	 */
	public function state_for_scope( string $scope ): string {
		if ( ! $this->is_explicit ) {
			return ConsentState::UNSET;
		}

		if ( 'overall' === $scope || '' === $scope ) {
			return $this->state;
		}

		if ( isset( $this->categories[ $scope ] ) ) {
			return $this->categories[ $scope ];
		}

		if ( isset( $this->services[ $scope ] ) ) {
			return $this->services[ $scope ];
		}

		return ConsentState::UNSET;
	}

	/**
	 * Category keys this signal carries.
	 *
	 * @return array<int, string>
	 */
	public function category_keys(): array {
		return array_keys( $this->categories );
	}

	/**
	 * Service keys this signal carries.
	 *
	 * @return array<int, string>
	 */
	public function service_keys(): array {
		return array_keys( $this->services );
	}

	/**
	 * Google Consent Mode snapshot, if this source provided one.
	 *
	 * @return string
	 */
	public function consent_mode(): string {
		return $this->consent_mode;
	}

	/**
	 * TCF string, if this source provided one.
	 *
	 * @return string
	 */
	public function tcf_string(): string {
		return $this->tcf_string;
	}

	/**
	 * Capture timestamp.
	 *
	 * @return string
	 */
	public function captured_at(): string {
		return $this->captured_at;
	}

	/**
	 * Serialize to the audit-trail entry shape stored on the record.
	 *
	 * @return array<string, mixed>
	 */
	public function to_audit_array(): array {
		return array(
			'source'         => $this->source,
			'source_version' => $this->source_version,
			'scope'          => 'overall',
			'state'          => $this->state,
			'categories'     => $this->categories,
			'services'       => $this->services,
			'is_explicit'    => $this->is_explicit,
			'captured_at'    => $this->captured_at,
			'consent_mode'   => $this->consent_mode,
			'tcf_string'     => $this->tcf_string,
		);
	}

	/**
	 * Normalize a category or service state map.
	 *
	 * @param array<string, mixed> $map Raw state map.
	 * @return array<string, string>
	 */
	private function normalize_map( array $map ): array {
		$normalized = array();

		foreach ( $map as $key => $state ) {
			$name = sanitize_key( (string) $key );
			if ( '' === $name ) {
				continue;
			}

			$normalized[ $name ] = ConsentState::normalize( $state );
		}

		return $normalized;
	}
}
