<?php
/**
 * Stored consent record.
 *
 * @package Carticy\Conversions\Domain
 */

declare(strict_types=1);

namespace Carticy\Conversions\Domain;

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

/**
 * Per-order proof-of-consent snapshot.
 */
final class ConsentRecord {

	/**
	 * WooCommerce order ID this proof belongs to.
	 *
	 * @var int
	 */
	private int $order_id;

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

	/**
	 * Category consent states.
	 *
	 * @var array<string, string>
	 */
	private array $categories;

	/**
	 * Service consent states.
	 *
	 * @var array<string, string>
	 */
	private array $services;

	/**
	 * CMP/source label.
	 *
	 * @var string
	 */
	private string $source;

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

	/**
	 * 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;

	/**
	 * Audit trail of every signal the resolver considered for this order.
	 *
	 * Multi-source intake means the authoritative record is a *resolution* of
	 * several signals (WP Consent API, Consent Mode, a SaaS cookie, the
	 * first-party hook, a merchant attestation). This preserves the losers as
	 * well as the winner so the admin "consent audit" view can prove why the
	 * gate decided as it did. Each entry is a {@see ConsentSignal} array.
	 *
	 * @var array<int, array<string, mixed>>
	 */
	private array $sources;

	/**
	 * Constructor.
	 *
	 * @param int                  $order_id WooCommerce order ID.
	 * @param mixed                $state Consent state.
	 * @param array<string, mixed> $categories Category states.
	 * @param array<string, mixed> $services Service states.
	 * @param string               $source CMP/source label.
	 * @param string               $source_version CMP/source version.
	 * @param string               $captured_at GMT timestamp.
	 * @param string               $consent_mode Google Consent Mode snapshot.
	 * @param string               $tcf_string TCF string when present.
	 * @param array<int, mixed>    $sources Contributing signals (audit trail).
	 *
	 * @throws \InvalidArgumentException When the order ID is not positive or an explicit record lacks a source label/version.
	 */
	public function __construct(
		int $order_id,
		$state = ConsentState::UNSET,
		array $categories = array(),
		array $services = array(),
		string $source = '',
		string $source_version = '',
		string $captured_at = '',
		string $consent_mode = '',
		string $tcf_string = '',
		array $sources = array()
	) {
		if ( $order_id <= 0 ) {
			throw new \InvalidArgumentException( 'Consent record must include a positive order ID.' );
		}

		$this->state = ConsentState::normalize( $state );
		if ( ConsentState::UNSET !== $this->state && '' === trim( $source ) ) {
			throw new \InvalidArgumentException( 'Explicit consent records must include a CMP/source label.' );
		}
		if ( ConsentState::UNSET !== $this->state && '' === trim( $source_version ) ) {
			throw new \InvalidArgumentException( 'Explicit consent records must include a CMP/source version.' );
		}

		$this->order_id       = $order_id;
		$this->categories     = $this->normalize_map( $categories );
		$this->services       = $this->normalize_map( $services );
		$this->source         = trim( $source );
		$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 );
		$this->sources        = $this->normalize_sources( $sources );
	}

	/**
	 * Build an unset consent snapshot for an order.
	 *
	 * @param int $order_id WooCommerce order ID.
	 * @return self
	 */
	public static function unset_for_order( int $order_id ): self {
		return new self( $order_id );
	}

	/**
	 * Rebind this record to a different order.
	 *
	 * WooCommerce Subscriptions copies parent-order meta onto subscriptions and
	 * renewal orders, leaving a consent snapshot whose embedded order id points
	 * at the parent. The decision, categories, services, and audit trail still
	 * describe the customer's choice, so the record is rebound rather than
	 * discarded; only the order linkage changes.
	 *
	 * @param int $order_id Target order ID.
	 * @return self
	 */
	public function for_order( int $order_id ): self {
		if ( $order_id === $this->order_id ) {
			return $this;
		}

		return new self(
			$order_id,
			$this->state,
			$this->categories,
			$this->services,
			$this->source,
			$this->source_version,
			$this->captured_at,
			$this->consent_mode,
			$this->tcf_string,
			$this->sources
		);
	}

	/**
	 * Rebuild from serialized data.
	 *
	 * @param array<string, mixed> $data Serialized consent record.
	 * @param int                  $fallback_order_id Order ID fallback when the snapshot lacks one.
	 * @return self
	 */
	public static function from_array( array $data, int $fallback_order_id = 0 ): self {
		$order_id = isset( $data['order_id'] ) ? (int) $data['order_id'] : $fallback_order_id;

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

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

	/**
	 * Get the order ID this proof belongs to.
	 *
	 * @return int
	 */
	public function order_id(): int {
		return $this->order_id;
	}

	/**
	 * Check whether this record permits server sending.
	 *
	 * @return bool
	 */
	public function allows_server_send(): bool {
		return ConsentState::GRANTED === $this->state;
	}

	/**
	 * Check whether a specific destination service may receive a server send.
	 *
	 * Per-service gating (Meta vs Google Ads vs GA4) honors an explicit service
	 * decision when the resolver captured one; otherwise it falls back to the
	 * overall state. Still an allow-list: only an explicit grant passes.
	 *
	 * @param string $service Service key (e.g. "meta", "google-ads", "ga4").
	 * @return bool
	 */
	public function allows_service_send( string $service ): bool {
		$key = sanitize_key( $service );

		if ( '' !== $key && isset( $this->services[ $key ] ) ) {
			return ConsentState::GRANTED === $this->services[ $key ];
		}

		return $this->allows_server_send();
	}

	/**
	 * Get the contributing signals (audit trail).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	public function sources(): array {
		return $this->sources;
	}

	/**
	 * Serialize for storage.
	 *
	 * @return array<string, mixed>
	 */
	public function to_array(): array {
		return array(
			'order_id'       => $this->order_id,
			'state'          => $this->state,
			'categories'     => $this->categories,
			'services'       => $this->services,
			'source'         => $this->source,
			'source_version' => $this->source_version,
			'captured_at'    => $this->captured_at,
			'consent_mode'   => $this->consent_mode,
			'tcf_string'     => $this->tcf_string,
			'sources'        => $this->sources,
		);
	}

	/**
	 * Normalize category or service states.
	 *
	 * @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;
	}

	/**
	 * Normalize the audit trail into a deterministic, round-trip-stable shape.
	 *
	 * Idempotent by design: re-normalizing an already-serialized list reproduces
	 * it byte-for-byte, so {@see self::from_array()} of {@see self::to_array()}
	 * stays symmetric regardless of how many times a record is rehydrated.
	 *
	 * @param array<int, mixed> $sources Raw contributing signals.
	 * @return array<int, array<string, mixed>>
	 */
	private function normalize_sources( array $sources ): array {
		$normalized = array();

		foreach ( $sources as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$normalized[] = array(
				'source'         => (string) ( $entry['source'] ?? '' ),
				'source_version' => (string) ( $entry['source_version'] ?? '' ),
				'scope'          => (string) ( $entry['scope'] ?? '' ),
				'state'          => ConsentState::normalize( $entry['state'] ?? ConsentState::UNSET ),
				'categories'     => $this->normalize_map( is_array( $entry['categories'] ?? null ) ? $entry['categories'] : array() ),
				'services'       => $this->normalize_map( is_array( $entry['services'] ?? null ) ? $entry['services'] : array() ),
				'is_explicit'    => (bool) ( $entry['is_explicit'] ?? false ),
				'captured_at'    => (string) ( $entry['captured_at'] ?? '' ),
				'consent_mode'   => (string) ( $entry['consent_mode'] ?? '' ),
				'tcf_string'     => (string) ( $entry['tcf_string'] ?? '' ),
			);
		}

		return $normalized;
	}
}
