509318396
­­­­­­­­­­­­­­­­­­
<!DOCTYPE html>
<html>
<?php

namespace PixelYourSite;

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

require_once PYS_FREE_PATH . '/modules/openai/function-helpers.php';

use PixelYourSite\OpenAI\Helpers;

/**
 * OpenAI Ads pixel module (Free edition).
 */
class OpenAI extends Settings implements Pixel {

    private static $_instance;

    private $configured;

    private $moduleName = 'OpenAI';

    public static function instance() {

        if ( is_null( self::$_instance ) ) {
            self::$_instance = new self();
        }

        return self::$_instance;
    }

    public function __construct() {

        parent::__construct( 'openai' );

        $this->locateOptions(
            PYS_FREE_PATH . '/modules/openai/options_fields.json',
            PYS_FREE_PATH . '/modules/openai/options_defaults.json'
        );

        add_action( 'pys_register_pixels', function ( $core ) {
            $core->registerPixel( $this );
        } );
    }

    public function getModuleName() {
        return $this->moduleName;
    }

    public function enabled() {
        return $this->getOption( 'enabled' );
    }

    public function configured() {

        if ( $this->configured === null ) {

            $pixel_id = $this->getPixelIDs();

            $this->configured = $this->enabled()
                                && count( $pixel_id ) > 0
                                && ! empty( $pixel_id[0] );
        }

        return $this->configured;
    }

    /**
     * A single primary pixel
     *
     * @return array
     */
    public function getPixelIDs() {

        $ids = (array) $this->getOption( 'pixel_id' );

        if ( count( $ids ) == 0 || empty( $ids[0] ) ) {
            return apply_filters( 'pys_openai_ids', [] );
        }

        $id = array_shift( $ids );

        return apply_filters( 'pys_openai_ids', array( $id ) );
    }

    /**
     * Front-end options for this pixel.
     *
     * `debug` is the module's own switch rather than the plugin-wide debug
     * option: it is what the OpenAI Pixel Helper add-on reads, so it has to be
     * settable for this pixel alone.
     *
     * @return array
     */
    public function getPixelOptions() {

        $options = array(
            'pixelIds'            => $this->getPixelIDs(),
            'serverApiEnabled'    => $this->isServerApiEnabled(),
            'debug'               => (bool) $this->getOption( 'debug_enabled' ),
            'opt_out'             => $this->getConsentMode(),
            // The browser AddToCart needs this to choose between the parent id and the
            // selected variation, the way every other pixel's handler does.
            'wooVariableAsSimple' => $this->getOption( 'woo_variable_as_simple' ),
        );

        if ( $this->getOption( 'advanced_matching_enabled' ) ) {

            $advanced_matching = $this->getAdvancedMatchingParams();

            if ( ! empty( $advanced_matching ) ) {
                $options['advanced_matching'] = $advanced_matching;
            }
        }

        return $options;
    }


    /**
     * Is the pixel in OpenAI's restricted mode -- firing, but opted out of
     * user-level personalisation?
     *
     * @return bool
     */
    public function getConsentMode() {
        return (bool) apply_filters( 'pys_openai_consent_mode', false );
    }

    /**
     * Is the Conversions API switched on AND usable (a key is present)?
     *
     * @return bool
     */
    public function isServerApiEnabled() {

        if ( ! $this->enabled() || ! $this->getOption( 'use_server_api' ) ) {
            return false;
        }

        $tokens = array_filter( (array) $this->getOption( 'server_access_api_token' ) );

        return ! empty( $tokens );
    }

    /**
     * Conversions API keys, keyed by the pixel they belong to.
     *
     * @return array
     */
    public function getApiTokens() {

        $tokens = array();

        $pixel_ids = $this->getPixelIDs();

        if ( count( $pixel_ids ) > 0 && $this->isServerApiEnabled() ) {
            $server_tokens = (array) $this->getOption( 'server_access_api_token' );
            $tokens[ $pixel_ids[0] ] = reset( $server_tokens );
        }

        return $tokens;
    }

    /**
     * Send events for validation only, without recording them.
     *
     * @return bool
     */
    public function isServerValidateOnly() {
        return (bool) $this->getOption( 'server_validate_only' );
    }

    const MATCHING_LIMITS = array(
        'email_sha256'        => 3,
        'phone_number_sha256' => 2,
        'external_id_sha256'  => 1,
        'first_name_sha256'   => 1,
        'last_name_sha256'    => 1,
        'country'             => 1,
        'city'                => 1,
        'region'              => 1,
        'postal_code'         => 1,
    );

    /**
     * Identifying fields for the visitor, in the shape OpenAI documents.
     *
     * @param int|null $wooOrder Explicit Woo order id, from trusted internal code.
     * @param int|null $eddOrder Explicit EDD payment id, from trusted internal code.
     * @return array
     */
    public function getAdvancedMatchingParams( $wooOrder = null, $eddOrder = null ) {
        return $this->flattenMatchingValues( $this->getMatchingValues( $wooOrder, $eddOrder ) );
    }

    /**
     * One value per field, as the Pixel takes them, with the public filter
     * applied.
     *
     * @param array $lists Field => ordered values.
     * @return array
     */
    private function flattenMatchingValues( $lists ) {

        $params = array();

        foreach ( $lists as $field => $values ) {
            if ( ! empty( $values ) ) {
                $params[ $field ] = reset( $values );
            }
        }

        return apply_filters( 'pys_openai_advanced_matching', $params );
    }

    /**
     * Every matching value this request knows about, normalised, hashed where it
     * has to be, de-duplicated and capped -- keyed by the Pixel's field name.
     *
     * @param int|null $wooOrder Explicit Woo order id, from trusted internal code.
     * @param int|null $eddOrder Explicit EDD payment id, from trusted internal code.
     * @return array<string, array<int, string>>
     */
    public function getMatchingValues( $wooOrder = null, $eddOrder = null ) {

        $sources = $this->collectMatchingSources( $wooOrder, $eddOrder );

        $resolved = get_persistence_user_data(
            $this->firstMatchingValue( $sources['email'] ),
            $this->firstMatchingValue( $sources['first_name'] ),
            $this->firstMatchingValue( $sources['last_name'] ),
            $this->firstMatchingValue( $sources['phone'] )
        );

        $typed = get_persistence_user_data( '', '', '', '' );

        $candidates = array(
            'email_sha256'        => array_merge( array( isset( $resolved['em'] ) ? $resolved['em'] : '' ), $sources['email'], array( isset( $typed['em'] ) ? $typed['em'] : '' ) ),
            'phone_number_sha256' => array_merge( array( isset( $resolved['tel'] ) ? $resolved['tel'] : '' ), $sources['phone'], array( isset( $typed['tel'] ) ? $typed['tel'] : '' ) ),
            'first_name_sha256'   => array_merge( array( isset( $resolved['fn'] ) ? $resolved['fn'] : '' ), $sources['first_name'], array( isset( $typed['fn'] ) ? $typed['fn'] : '' ) ),
            'last_name_sha256'    => array_merge( array( isset( $resolved['ln'] ) ? $resolved['ln'] : '' ), $sources['last_name'], array( isset( $typed['ln'] ) ? $typed['ln'] : '' ) ),
            'external_id_sha256'  => array(),
            'country'             => $sources['country'],
            'city'                => $sources['city'],
            'region'              => $sources['region'],
            'postal_code'         => $sources['postal_code'],
        );

        if ( EventsManager::isTrackExternalId() ) {

            $external_id = $this->getExternalId( $wooOrder, $eddOrder );

            if ( ! empty( $external_id ) ) {
                $candidates['external_id_sha256'] = array( hash( 'sha256', (string) $external_id ) );
            }
        }

        // field => the function that turns a raw value into the wire value, or
        // null when the value is already final.
        $rules = array(
            'email_sha256'        => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_hash_email',
            'phone_number_sha256' => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_hash_phone',
            'first_name_sha256'   => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_hash_name',
            'last_name_sha256'    => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_hash_name',
            'external_id_sha256'  => null,
            'country'             => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_normalize_country',
            'city'                => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_normalize_city',
            'region'              => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_normalize_region',
            'postal_code'         => 'PixelYourSite\\OpenAI\\Helpers\\pys_openai_normalize_postal_code',
        );

        $values = array();

        foreach ( $rules as $field => $rule ) {

            $limit = self::MATCHING_LIMITS[ $field ];
            $kept  = array();

            foreach ( (array) $candidates[ $field ] as $raw ) {

                if ( count( $kept ) >= $limit ) {
                    break;
                }

                $value = ( null === $rule ) ? (string) $raw : call_user_func( $rule, (string) $raw );

                if ( $value !== '' && ! in_array( $value, $kept, true ) ) {
                    $kept[] = $value;
                }
            }

            $values[ $field ] = $kept;
        }

        return apply_filters( 'pys_openai_matching_values', $values, $wooOrder, $eddOrder );
    }

    /**
     * The matching values for the Conversions API copy: lists, reconciled with
     * whatever `pys_openai_advanced_matching` decided.
     *
     * @param int|null $wooOrder Explicit Woo order id, from trusted internal code.
     * @param int|null $eddOrder Explicit EDD payment id, from trusted internal code.
     * @return array<string, array<int, string>>
     */
    public function getServerMatchingValues( $wooOrder = null, $eddOrder = null ) {

        $lists = $this->getMatchingValues( $wooOrder, $eddOrder );
        $flat  = $this->flattenMatchingValues( $lists );

        $out = array();

        foreach ( $lists as $field => $values ) {

            // The filter removed the field: it does not travel, in either copy.
            if ( ! isset( $flat[ $field ] ) || '' === $flat[ $field ] ) {
                continue;
            }

            $leading = (string) $flat[ $field ];
            $values  = array_values( array_diff( $values, array( $leading ) ) );

            array_unshift( $values, $leading );

            $out[ $field ] = array_slice( $values, 0, self::MATCHING_LIMITS[ $field ] );
        }

        // A field the filter ADDED has no list of its own; it still travels.
        foreach ( $flat as $field => $value ) {
            if ( ! isset( $out[ $field ] ) && is_scalar( $value ) && '' !== (string) $value ) {
                $out[ $field ] = array( (string) $value );
            }
        }

        return $out;
    }

    /**
     * The raw values each source has, per dimension, strongest source first.
     *
     * @param int|null $wooOrder Explicit Woo order id, from trusted internal code.
     * @param int|null $eddOrder Explicit EDD payment id, from trusted internal code.
     * @return array<string, array<int, string>>
     */
    private function collectMatchingSources( $wooOrder = null, $eddOrder = null ) {

        $sources = array(
            'email'       => array(),
            'phone'       => array(),
            'first_name'  => array(),
            'last_name'   => array(),
            'country'     => array(),
            'city'        => array(),
            'region'      => array(),
            'postal_code' => array(),
        );

        /**
         * Append a source's values, skipping the gaps it has.
         *
         * @param array $sources
         * @param array $row Dimension => raw value.
         * @return void
         */
        $add = function ( &$sources, $row ) {
            foreach ( $row as $dimension => $value ) {
                if ( is_scalar( $value ) && trim( (string) $value ) !== '' ) {
                    $sources[ $dimension ][] = (string) $value;
                }
            }
        };

        // --- the WooCommerce order -------------------------------------------
        if ( isWooCommerceActive() ) {

            if ( $wooOrder ) {
                $order_id = (int) $wooOrder;
            } else {
                $order_id = wooGetOrderIdFromRequest();

                // 'pii' is the stricter context: reading an address needs more
                // than the right to know the order exists.
                if ( $order_id > 0 && ! pysWooRequestCanAccessOrder( $order_id, 'pii' ) ) {
                    $order_id = 0;
                }
            }

            if ( $order_id > 0 ) {

                $order = wc_get_order( $order_id );

                if ( $order ) {
                    $add( $sources, array(
                        'email'       => $order->get_billing_email(),
                        'phone'       => $order->get_billing_phone(),
                        'first_name'  => $order->get_billing_first_name(),
                        'last_name'   => $order->get_billing_last_name(),
                        'country'     => $order->get_billing_country(),
                        'city'        => $order->get_billing_city(),
                        'region'      => $order->get_billing_state(),
                        'postal_code' => $order->get_billing_postcode(),
                    ) );
                }
            }
        }

        // --- the EDD payment -------------------------------------------------
        if ( isEddActive() ) {

            $order_id = $eddOrder ? (int) $eddOrder : (int) edd_get_purchase_id_by_key( getEddPaymentKey() );

            if ( $order_id > 0 && ! pysEddRequestCanAccessOrder( $order_id ) ) {
                $order_id = 0;
            }

            if ( $order_id > 0 ) {

                $userEdd = edd_get_payment_meta_user_info( $order_id );
                $address = isset( $userEdd['address'] ) ? (array) $userEdd['address'] : array();

                // EDD core collects no phone number, so there is nothing to read.
                $add( $sources, array(
                    'email'       => isset( $userEdd['email'] ) ? $userEdd['email'] : '',
                    'first_name'  => isset( $userEdd['first_name'] ) ? $userEdd['first_name'] : '',
                    'last_name'   => isset( $userEdd['last_name'] ) ? $userEdd['last_name'] : '',
                    'country'     => isset( $address['country'] ) ? $address['country'] : '',
                    'city'        => isset( $address['city'] ) ? $address['city'] : '',
                    'region'      => isset( $address['state'] ) ? $address['state'] : '',
                    'postal_code' => isset( $address['zip'] ) ? $address['zip'] : '',
                ) );
            }
        }

        // --- the WordPress profile -------------------------------------------
        $user = wp_get_current_user();

        if ( $user && $user->ID ) {

            $add( $sources, array(
                'email'       => $user->get( 'user_email' ),
                'first_name'  => $user->first_name,
                'last_name'   => $user->last_name,
                'phone'       => get_user_meta( $user->ID, 'billing_phone', true ),
                'country'     => get_user_meta( $user->ID, 'billing_country', true ),
                'city'        => get_user_meta( $user->ID, 'billing_city', true ),
                'region'      => get_user_meta( $user->ID, 'billing_state', true ),
                'postal_code' => get_user_meta( $user->ID, 'billing_postcode', true ),
            ) );
        }

        return $sources;
    }

    /**
     * The first value a dimension has, or an empty string.
     *
     * @param array $values
     * @return string
     */
    private function firstMatchingValue( $values ) {
        return empty( $values ) ? '' : (string) reset( $values );
    }

    /**
     * The stable pseudonymous visitor id behind external_id_sha256.
     *
     * Same sources the other Free pixels use, so one visitor keeps one identity
     * across destinations: the order's own `external_id` meta when the event
     * belongs to an order, otherwise the `pbid` cookie. Returned unhashed --
     * the caller hashes it.
     *
     * @param int|null $wooOrder
     * @param int|null $eddOrder
     * @return string
     */
    public function getExternalId( $wooOrder = null, $eddOrder = null ) {

        $external_id = '';

        if ( $eddOrder && isEddActive() ) {
            $external_id = edd_get_payment_meta( (int) $eddOrder, 'external_id', true );
        }

        if ( empty( $external_id ) && $wooOrder && isWooCommerceActive() ) {

            $order = wc_get_order( (int) $wooOrder );

            if ( $order ) {
                $external_id = $order->get_meta( 'external_id' );
            }
        }

        if ( empty( $external_id ) ) {

            $user = wp_get_current_user();

            if ( $user && $user->ID && $user->get( 'external_id' ) ) {
                $external_id = $user->get( 'external_id' );
            }
        }

        if ( empty( $external_id ) ) {
            $external_id = PYS()->get_pbid();
        }

        return is_string( $external_id ) ? $external_id : '';
    }

    /**
     * Build the platform events for one PYS event.
     *
     * This is the only entry point the events manager uses -- addParamsToEvent()
     * is reached through here, not called directly -- so a pixel that returns an
     * empty list here fires nothing at all, however complete its mappers are.
     *
     * The source event is cloned because the manager hands the same instance to
     * every registered pixel in turn.
     *
     * @param SingleEvent $event
     * @return SingleEvent[]
     */
    public function generateEvents( $event ) {

        $pixelEvents = array();

        if ( ! $this->configured() ) {
            return array();
        }

        $pixel_ids = $this->getPixelIDs();

        if ( count( $pixel_ids ) == 0 ) {
            return array();
        }

        $pixelEvent = clone $event;

        if ( $this->addParamsToEvent( $pixelEvent ) ) {
            $pixelEvent->addPayload( array(
                'pixelIds' => $pixel_ids,
                'eventID'  => EventIdGenerator::guidv4(),
            ) );
            $pixelEvents[] = $pixelEvent;
        }

        return $pixelEvents;
    }

    /**
     * Attach this pixel's parameters to an event.
     *
     * `false` means "this pixel adds nothing", which is what the events manager
     * expects from a pixel that does not handle the event. WooCommerce and EDD
     * events join this switch in F3 / F4.
     *
     * @param SingleEvent $event
     * @return bool
     */
    public function addParamsToEvent( &$event ) {

        if ( ! $this->configured() ) {
            return false;
        }

        $isActive = false;

        switch ( $event->getId() ) {

            case 'init_event':
                $isActive = $this->addPageViewParams( $event );
                break;

            case 'custom_event':
                $isActive = $this->add_custom_event_params( $event );
                break;

            case 'woo_view_content':
                $isActive = $this->addWooViewContentParams( $event );
                break;

            // All three add-to-cart catch methods are one logical action for
            // OpenAI, so they share the woo_add_to_cart_enabled toggle.
            case 'woo_add_to_cart_on_button_click':
            case 'woo_add_to_cart_on_cart_page':
            case 'woo_add_to_cart_on_checkout_page':
                $isActive = $this->addWooAddToCartParams( $event );
                break;

            case 'woo_initiate_checkout':
                $isActive = $this->addWooInitiateCheckoutParams( $event );
                break;

            case 'woo_purchase':
                $isActive = $this->addWooPurchaseParams( $event );
                break;

            case 'edd_view_content':
                $isActive = $this->addEddViewContentParams( $event );
                break;

            case 'edd_add_to_cart_on_button_click':
                $isActive = $this->addEddAddToCartOnButtonParams( $event );
                break;

            // Cart-shaped EDD events differ only by name, toggle and which core
            // value settings drive the amount.
            case 'edd_add_to_cart_on_checkout_page':
                $isActive = $this->addEddCartParams( $event, 'items_added', 'edd_add_to_cart_enabled' );
                break;

            case 'edd_initiate_checkout':
                $isActive = $this->addEddCartParams( $event, 'checkout_started', 'edd_initiate_checkout_enabled' );
                break;

            case 'edd_purchase':
                $isActive = $this->addEddCartParams( $event, 'order_created', 'edd_purchase_enabled' );
                break;

            // edd_view_category and edd_remove_from_cart have no counterpart in
            // OpenAI's closed taxonomy, so they are deliberately not handled --
            // the same two events Pro leaves out.
        }

        return $isActive;
    }

    /**
     * A user-configured event.
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function add_custom_event_params( &$event ) {

        /**
         * @var CustomEvent $customEvent
         */
        $customEvent = $event->args;

        if ( ! $customEvent instanceof CustomEvent || ! $customEvent->isOpenAIEnabled() ) {
            return false;
        }

        $name = $customEvent->getOpenAIEventType();

        if ( empty( $name ) ) {
            PYS()->getLog()->debug( 'OpenAI custom event skipped - no usable event name', array(
                'event' => $customEvent->getTitle(),
            ) );

            return false;
        }

        $event_type = $customEvent->openai_event_type;
        $data_type  = CustomEvent::getOpenAIDataType( $event_type );

        $params = $customEvent->getOpenAIParams();

        $amount   = $this->customEventValue( $params, 'amount' );
        $currency = $this->customEventValue( $params, 'currency' );

        $plan_id = in_array( $data_type, array( 'plan_enrollment', 'custom' ), true )
            ? $this->customEventValue( $params, 'plan_id' )
            : '';

        $contents = $data_type === 'customer_action'
            ? array()                       // this shape has no contents[] at all
            : $this->customEventContents( $params, $currency );

        $attached = $this->attachEvent(
            $event,
            $event_type === 'custom' ? 'custom' : $name,
            $data_type,
            $amount === '' ? null : $amount,
            $currency,
            $contents,
            $plan_id,
        );

        if ( $attached && $event_type === 'custom' ) {
            $event->addParams( array( 'custom_event_name' => $name ) );
        }

        return $attached;
    }

    /**
     * One configured param, as a plain string.
     *
     * @param array  $params
     * @param string $key
     * @return string
     */
    private function customEventValue( $params, $key ) {

        if ( ! isset( $params[ $key ] ) ) {
            return '';
        }

        $param = $params[ $key ];

        // A Pro-shaped payload (an import, an older row) posts { value, … }.
        return is_array( $param ) ? ( $param[ 'value' ] ?? '' ) : (string) $param;
    }

    /**
     * The single contents[] item the editor can describe, if any.
     *
     * @param array  $params
     * @param string $currency
     * @return array
     */
    private function customEventContents( $params, $currency ) {

        $fields = array(
            'id'           => $this->customEventValue( $params, 'content_id' ),
            'name'         => $this->customEventValue( $params, 'content_name' ),
            'content_type' => $this->customEventValue( $params, 'content_type' ),
            'quantity'     => $this->customEventValue( $params, 'quantity' ),
        );

        $configured = false;

        foreach ( $fields as $value ) {
            if ( $value !== '' && $value !== null ) {
                $configured = true;
                break;
            }
        }

        if ( ! $configured ) {
            return array();
        }

        return array(
            Helpers\pys_openai_content_item(
                $fields['id'],
                $fields['name'],

                $fields['content_type'],
                $fields['quantity'],
                null,
                $currency
            ),
        );
    }

    /**
     * page_viewed — the OpenAI name for the page view.
     *
     * The get_post_type() gate is deliberate: without it the event also fires on
     * requests that are not pages at all (a missing .js.map, for instance), which
     * in Pro filled the queue with hundreds of junk page views.
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addPageViewParams( &$event ) {

        if ( ! get_post_type() ) {
            return false;
        }

        $event->addPayload( [ 'name' => 'page_viewed' ] );
        $event->addParams( [ 'data' => [ 'type' => 'contents' ] ] );

        return true;
    }

    /**
     * Attach an OpenAI event to a PYS event.
     *
     * `data` is a closed object: only the fields OpenAI documents may appear, and
     * one unknown key fails the whole Conversions API request. Money is always in
     * integer minor units, and `currency` travels with `amount` or not at all.
     *
     * @param SingleEvent $event
     * @param string      $name     OpenAI event name.
     * @param string      $type     data.type: contents | customer_action | custom.
     * @param mixed       $amount   Decimal total, converted here.
     * @param string      $currency
     * @param array       $contents
     * @return bool
     */
    private function attachEvent( &$event, $name, $type, $amount = null, $currency = '', $contents = array(), $plan_id = '' ) {

        $data = array( 'type' => $type );

        if ( in_array( $type, array( 'plan_enrollment', 'custom' ), true ) && $plan_id !== '' && $plan_id !== null ) {
            $data[ 'plan_id' ] = (string) $plan_id;
        }

        $minor_units = Helpers\pys_openai_to_minor_units( $amount, $currency );

        if ( $minor_units !== null ) {

            if ( Helpers\pys_openai_is_valid_currency( $currency ) ) {
                $data['amount']   = $minor_units;
                $data['currency'] = $currency;
            } elseif ( $currency !== '' && $currency !== null ) {
                PYS()->getLog()->debug( 'OpenAI: the amount was dropped, OpenAI does not accept this currency', array(
                    'event'    => $name,
                    'currency' => is_scalar( $currency ) ? (string) $currency : gettype( $currency ),
                ) );
            }
        }

        $item_fields = PYS_Event_Definitions::get_openai_content_item_fields();

        $contents = array_values( array_filter( (array) $contents, function ( $item ) use ( $item_fields ) {
            return is_array( $item ) && ! empty( array_intersect( array_keys( $item ), $item_fields ) );
        } ) );

        if ( ! empty( $contents ) && $type !== 'customer_action' ) {
            $data['contents'] = $contents;
        }

		$event->addPayload( array( 'name' => $name ) );
        $event->addParams( array( 'data' => $data ) );

        return true;
    }

    /**
     * woo_view_content -> contents_viewed
     *
     * Free resolves the product from the global post, as its other modules do;
     * Pro reads it from the event args. Same event, different plumbing.
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addWooViewContentParams( &$event ) {

        global $post;

        if ( ! $this->getOption( 'woo_view_content_enabled' ) || empty( $post ) ) {
            return false;
        }

        $product = pysWooGetProduct( $post->ID );

        if ( ! $product ) {
            return false;
        }

        $currency   = get_woocommerce_currency();
        $product_id = Helpers\getOpenAiWooVariableToSimpleProductId( $product );
        $content_id = Helpers\getOpenAiWooProductContentId( $product_id );
        $unit_price = getWooProductPriceToDisplay( $product_id );

        $amount = null;

        if ( PYS()->getOption( 'woo_view_content_value_enabled' ) ) {
            $amount = getWooEventValue(
                PYS()->getOption( 'woo_view_content_value_option' ),
                PYS()->getOption( 'woo_view_content_value_global', 0 ),
                100,
                $product_id,
                1
            );
        }

        $contents = array(
            Helpers\pys_openai_content_item( $content_id, $product->get_name(), 'product', 1, $unit_price, $currency ),
        );

        return $this->attachEvent( $event, 'contents_viewed', 'contents', $amount, $currency, $contents );
    }

    /**
     * woo_add_to_cart_* -> items_added
     *
     * The button-click variant carries the product in the event args; the cart
     * and checkout variants describe the whole cart.
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addWooAddToCartParams( &$event ) {

        if ( ! PYS()->getOption( 'woo_add_to_cart_enabled' ) || ! $this->getOption( 'woo_add_to_cart_enabled' ) ) {
            return false;
        }

        $currency = get_woocommerce_currency();

        if ( $event->getId() === 'woo_add_to_cart_on_button_click' ) {

            $product_id = isset( $event->args['productId'] ) ? $event->args['productId'] : null;

            if ( ! $product_id ) {
                return false;
            }

            $product = pysWooGetProduct( $product_id );

            if ( ! $product ) {
                return false;
            }

            $quantity   = isset( $event->args['quantity'] ) ? (int) $event->args['quantity'] : 1;
            $product_id = Helpers\getOpenAiWooVariableToSimpleProductId( $product );
            $content_id = Helpers\getOpenAiWooProductContentId( $product_id );
            $unit_price = getWooProductPriceToDisplay( $product_id );

            $contents = array(
                Helpers\pys_openai_content_item( $content_id, $product->get_name(), 'product', $quantity, $unit_price, $currency ),
            );

            $amount = null;

            if ( PYS()->getOption( 'woo_add_to_cart_value_enabled' ) ) {
                $amount = getWooEventValue(
                    PYS()->getOption( 'woo_add_to_cart_value_option' ),
                    PYS()->getOption( 'woo_add_to_cart_value_global', 0 ),
                    100,
                    $product_id,
                    $quantity
                );
            }

            return $this->attachEvent( $event, 'items_added', 'contents', $amount, $currency, $contents );
        }

        $cart = $this->getCartContents( $currency );

        if ( $cart === null ) {
            return false;
        }

        return $this->attachEvent( $event, 'items_added', 'contents', $cart['amount'], $currency, $cart['contents'] );
    }

    /**
     * woo_initiate_checkout -> checkout_started
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addWooInitiateCheckoutParams( &$event ) {

        if ( ! $this->getOption( 'woo_initiate_checkout_enabled' ) ) {
            return false;
        }

        $currency = get_woocommerce_currency();
        $cart     = $this->getCartContents( $currency );

        if ( $cart === null ) {
            return false;
        }

        return $this->attachEvent( $event, 'checkout_started', 'contents', $cart['amount'], $currency, $cart['contents'] );
    }

    /**
     * woo_purchase -> order_created
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addWooPurchaseParams( &$event ) {

        if ( ! $this->getOption( 'woo_purchase_enabled' ) ) {
            return false;
        }

        $order_id = wooGetOrderIdFromRequest();

        if ( $order_id < 1 ) {
            return false;
        }

        $order = wc_get_order( $order_id );

        if ( ! $order ) {
            return false;
        }

        // The order reference travels with the event: the browser copy carries it
        // back, and RestAPIGuard rejects a purchase that cannot be bound to a
        // real order ("missing_order").
        $event->addPayload( array(
            'woo_order' => $order_id,
            'order_key' => $order->get_order_key(),
        ) );

        // The order's own currency, not the shop's: they differ on multi-currency
        // shops, and OpenAI reads amount and currency together.
        $currency = $order->get_currency();
        $contents = array();

        foreach ( $order->get_items( 'line_item' ) as $line_item ) {

            $product_id = Helpers\getOpenAiWooProductDataId( array(
                'type'       => empty( $line_item['variation_id'] ) ? 'simple' : 'variation',
                'parent_id'  => $line_item['product_id'],
                'product_id' => empty( $line_item['variation_id'] ) ? $line_item['product_id'] : $line_item['variation_id'],
            ) );

            $content_id = Helpers\getOpenAiWooProductContentId( $product_id );
            $product    = pysWooGetProduct( $product_id );
            $quantity   = (int) $line_item['qty'];

            $contents[] = Helpers\pys_openai_content_item(
                $content_id,
                $product ? $product->get_name() : '',
                'product',
                $quantity,
                getWooProductPriceToDisplay( $product_id ),
                $currency
            );
        }

        $amount = getWooEventValueOrder(
            PYS()->getOption( 'woo_purchase_value_option' ),
            $order,
            PYS()->getOption( 'woo_purchase_value_global', 0 )
        );

        return $this->attachEvent( $event, 'order_created', 'contents', $amount, $currency, $contents );
    }

    /**
     * edd_view_content -> contents_viewed
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addEddViewContentParams( &$event ) {

        global $post;

        if ( ! $this->getOption( 'edd_view_content_enabled' ) || empty( $post ) ) {
            return false;
        }

        $download_id = $post->ID;
        $currency    = edd_get_currency();
        $unit_price  = getEddDownloadPriceToDisplay( $download_id );

        // No price index: a download page shows the default variation, and the
        // content id must match what the catalogue calls the parent item.
        $content_id = Helpers\getOpenAiEddDownloadContentId( $download_id );

        $value  = $this->getEddValueSettings( 'edd_view_content' );
        $amount = $value['enabled'] ? getEddEventValue( $value['option'], $unit_price, $value['global'] ) : null;

        $contents = array(
            Helpers\pys_openai_content_item( $content_id, get_the_title( $download_id ), 'product', 1, $unit_price, $currency ),
        );

        return $this->attachEvent( $event, 'contents_viewed', 'contents', $amount, $currency, $contents );
    }

    /**
     * edd_add_to_cart_on_button_click -> items_added
     *
     * This one is a dynamic event: it is registered once with no args, and the
     * real payload is substituted from pysEddProductData when the button is
     * clicked. The arg-less pass must still return true, or the slot never
     * appears in options.dynamicEvents and the click fires nothing.
     *
     * The id arrives as "<download_id>_<price_index>" for downloads with price
     * variations.
     *
     * @param SingleEvent $event
     * @return bool
     */
    private function addEddAddToCartOnButtonParams( &$event ) {

        if ( ! $this->getOption( 'edd_add_to_cart_enabled' )
            || ! PYS()->getOption( 'edd_add_to_cart_on_button_click' ) ) {
            return false;
        }

        if ( empty( $event->args ) ) {
            return $this->attachEvent( $event, 'items_added', 'contents' );
        }

        $download_id = $event->args;
        $price_index = null;

        if ( strpos( (string) $download_id, '_' ) !== false ) {
            list( $download_id, $price_index ) = explode( '_', (string) $download_id, 2 );
        }

        $download_id = (int) $download_id;

        if ( ! $download_id ) {
            return $this->attachEvent( $event, 'items_added', 'contents' );
        }

        $currency   = edd_get_currency();
        $unit_price = getEddDownloadPriceToDisplay( $download_id, $price_index );
        $content_id = Helpers\getOpenAiEddDownloadContentId( $download_id, $price_index );

        $value  = $this->getEddValueSettings( 'edd_add_to_cart_on_checkout_page' );
        $amount = $value['enabled'] ? getEddEventValue( $value['option'], $unit_price, $value['global'] ) : null;

        $contents = array(
            Helpers\pys_openai_content_item( $content_id, get_the_title( $download_id ), 'product', 1, $unit_price, $currency ),
        );

        return $this->attachEvent( $event, 'items_added', 'contents', $amount, $currency, $contents );
    }

    /**
     * Every EDD event built from a cart: items_added on the checkout page,
     * checkout_started and order_created.
     *
     * The purchase reads the stored order rather than the live cart, and its line
     * options sit one level deeper -- that is the only structural difference
     * between the two sources.
     *
     * @param SingleEvent $event
     * @param string      $name   OpenAI event name.
     * @param string      $option Module option gating the event.
     * @return bool
     */
    private function addEddCartParams( &$event, $name, $option ) {

        if ( ! $this->getOption( $option ) ) {
            return false;
        }

        $is_purchase = $event->getId() === 'edd_purchase';

        if ( $is_purchase ) {

            $order_id = (int) edd_get_purchase_id_by_key( getEddPaymentKey() );

            // The same access check the core makes before building the event: an
            // order the request may not see must not reach OpenAI either.
            if ( $order_id < 1 || ! pysEddRequestCanAccessOrder( $order_id ) ) {
                return false;
            }

            $event->addPayload( array( 'edd_order' => $order_id ) );

            $cart = edd_get_payment_meta_cart_details( $order_id, true );
        } else {
            $cart = edd_get_cart_contents();
        }

        if ( empty( $cart ) ) {
            return false;
        }

        $currency = edd_get_currency();
        $contents = array();
        $total    = 0;

        foreach ( $cart as $cart_item_key => $cart_item ) {

            $download_id  = (int) $cart_item['id'];
            $item_options = $is_purchase
                ? ( $cart_item['item_number']['options'] ?? array() )
                : ( $cart_item['options'] ?? array() );

            // 0 is a real price variation (the first one); null means the download
            // has no price options at all, and only then is the index omitted.
            $price_index = isset( $item_options['price_id'] ) && $item_options['price_id'] !== ''
                ? $item_options['price_id']
                : null;

            $quantity   = isset( $cart_item['quantity'] ) ? (int) $cart_item['quantity'] : 1;
            $content_id = Helpers\getOpenAiEddDownloadContentId( $download_id, $price_index );
            $unit_price = getEddDownloadPriceToDisplay( $download_id, $price_index );

            $contents[] = Helpers\pys_openai_content_item(
                $content_id,
                get_the_title( $download_id ),
                'product',
                $quantity,
                $unit_price,
                $currency
            );

            // The order line already carries what was actually paid; the cart has
            // to be asked for the final price, discounts and fees included.
            $total += $is_purchase
                ? ( $cart_item['price'] ?? 0 )
                : edd_get_cart_item_final_price( $cart_item_key );
        }

        $value  = $this->getEddValueSettings( $event->getId() );
        $amount = $value['enabled'] ? getEddEventValue( $value['option'], $total, $value['global'] ) : null;

        return $this->attachEvent( $event, $name, 'contents', $amount, $currency, $contents );
    }

    /**
     * Core value settings driving the amount of one EDD event.
     *
     * edd_purchase_value_enabled does not exist in Free -- the purchase always
     * carries its value -- so it defaults to true, which is what Pro does too. An
     * order_created with no amount would be worthless to OpenAI.
     *
     * Free has no *_value_percent option, so getEddEventValue() keeps its own 100%
     * default and the "percent" mode behaves as "price".
     *
     * @param string $event_id
     * @return array
     */
    private function getEddValueSettings( $event_id ) {

        $map = array(
            'edd_view_content'                 => 'edd_view_content',
            'edd_add_to_cart_on_checkout_page' => 'edd_add_to_cart',
            'edd_initiate_checkout'            => 'edd_initiate_checkout',
            'edd_purchase'                     => 'edd_purchase',
        );

        $prefix = isset( $map[ $event_id ] ) ? $map[ $event_id ] : 'edd_purchase';

        return array(
            'enabled' => PYS()->getOption( $prefix . '_value_enabled', $prefix === 'edd_purchase' ),
            'option'  => PYS()->getOption( $prefix . '_value_option' ),
            'global'  => PYS()->getOption( $prefix . '_value_global', 0 ),
        );
    }

    /**
     * The current cart as OpenAI contents plus its total.
     *
     * @param string $currency
     * @return array|null null when there is no usable cart.
     */
    private function getCartContents( $currency ) {

        if ( ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) {
            return null;
        }

        $contents = array();

        foreach ( WC()->cart->get_cart() as $cart_item ) {

            $product_id = Helpers\getOpenAiWooCartProductId( array(
                'product_id' => empty( $cart_item['variation_id'] ) ? $cart_item['product_id'] : $cart_item['variation_id'],
                'parent_id'  => empty( $cart_item['variation_id'] ) ? 0 : $cart_item['product_id'],
            ) );

            $product = pysWooGetProduct( $product_id );

            $contents[] = Helpers\pys_openai_content_item(
                Helpers\getOpenAiWooProductContentId( $product_id ),
                $product ? $product->get_name() : '',
                'product',
                (int) $cart_item['quantity'],
                getWooProductPriceToDisplay( $product_id ),
                $currency
            );
        }

        return array(
            'contents' => $contents,
            'amount'   => WC()->cart->get_cart_contents_total(),
        );
    }

    /**
     * @param string $eventType
     * @param mixed  $args
     * @return bool
     */
    public function getEventData( $eventType, $args = null ) {
        return false;
    }

    /**
     * Image-tag fallback for visitors without JavaScript.
     *
     * @return void
     */
    public function outputNoScriptEvents() {

        if ( ! $this->configured() || ! Consent()->checkConsent( 'openai' ) ) {
            return;
        }

        $eventsManager = PYS()->getEventsManager();

        if ( ! $eventsManager ) {
            return;
        }

        $staticEvents = $eventsManager->getStaticEvents( $this->getSlug() );

        if ( empty( $staticEvents ) ) {
            return;
        }

        $oppref = Helpers\pys_openai_get_click_id();

        foreach ( $staticEvents as $events ) {
            foreach ( $events as $event ) {

                // Only the page view: an image tag loads with the page and cannot
                // observe anything that happens afterwards, so a commerce event
                // has nothing to report here.
                if ( ( $event['name'] ?? '' ) !== 'page_viewed' ) {
                    continue;
                }

                $data = $event['params']['data'] ?? array();

                if ( empty( $data['type'] ) ) {
                    continue;
                }

                foreach ( (array) ( $event['pixelIds'] ?? array() ) as $pixelID ) {

                    $args = array(
                        'pid'   => $pixelID,
                        'event' => $event['name'],
                    );

                    foreach ( $data as $field => $value ) {
                        // contents[] has to travel as JSON inside one parameter;
                        // bracketed sub-parameters are rejected as unknown.
                        $args[ 'data[' . $field . ']' ] = is_array( $value ) ? wp_json_encode( $value ) : $value;
                    }

                    // The same id the browser and the Conversions API use, so the
                    // firings deduplicate against each other.
                    if ( ! empty( $event['eventID'] ) ) {
                        $args['event_id'] = $event['eventID'];
                    }

                    if ( $oppref !== '' ) {
                        $args['oppref'] = $oppref;
                    }

                    // The endpoint accepts pid, event, data[...], event_id and
                    // oppref and nothing else. It has no user object, and OpenAI's
                    // docs forbid personal data in a query string, so no Advanced
                    // Matching is attached here.
                    $src = 'https://bzr.openai.com/v1/sdk/events?' . http_build_query( $args, '', '&', PHP_QUERY_RFC3986 );

                    echo '<noscript><img height="1" width="1" style="display:none" alt="" src="' . esc_url( $src ) . '"></noscript>' . "\r\n";
                }
            }
        }
    }

}

/**
 * @return OpenAI
 */
function OpenAI() {
    return OpenAI::instance();
}

OpenAI();
