<?php
defined( 'ABSPATH' ) || exit;

/**
 * Settings: defaults, registration, sanitization, getters.
 *
 * All mapping-style settings share one format:
 *     label : value1, value2, value3
 *
 * Sections:
 *   - parameter_aliases         : dimension key : URL param aliases (non-standard keys are custom dims)
 *   - referrer_classification   : medium name  : referrer hostnames (first match sets visit medium)
 *   - click_ids                 : source/medium : click-ID param names
 *   - channels                  : channel label : source/medium pairs
 */
class BW_Lead_AI_Settings {

	const OPTION_GROUP = 'bw_lead_ai_option_group';

	/** Standard dimension keys that map to built-in visit fields. */
	const STANDARD_ALIAS_KEYS = array( 'source', 'medium', 'campaign', 'term', 'content', 'adgroup' );

	/** Merge tags reserved by the plugin — custom dimension keys must not collide with these. */
	const RESERVED_TAG_NAMES = array(
		'source_medium', 'channel', 'first_channel', 'first_source', 'first_medium',
		'first_page', 'last_page', 'submit_page', 'visits', 'pages', 'tagged_visits',
		'summary', 'summary_detailed', 'events', 'events_list', 'ga_client_id',
	);

	/**
	 * Interaction event types. Every one is opt-in per site — the stored
	 * `event_types` setting is a CSV of the enabled keys, empty by default, so a
	 * client who doesn't want interaction tracking sees nothing extra anywhere.
	 *
	 * Stored as a scalar CSV rather than an array on purpose: the other admin
	 * tabs round-trip every scalar setting through hidden inputs, so a scalar
	 * survives a save from any tab. An array would be skipped by that loop and
	 * silently reset. See render_targets_tab().
	 */
	const EVENT_TYPES = array(
		'video', 'download', 'phone', 'email', 'social', 'outbound', 'custom', 'scroll', 'form_start',
	);

	private static $instance = null;

	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function register() {
		add_action( 'admin_init', array( $this, 'register_settings' ) );
		add_action( 'admin_init', array( $this, 'maybe_seed_defaults' ) );
	}

	/**
	 * Defaults for the main settings option.
	 */
	public static function defaults() {
		return array(
			'parameter_aliases'       => self::default_parameter_aliases_text(),
			'referrer_classification' => self::default_referrer_classification_text(),
			'click_ids'               => self::default_click_ids_text(),
			'channels'                => self::default_channels_text(),

			// Form field targets (legacy non-GF support).
			'field_targets'           => array(
				'summary'      => array( 'attr' => '', 'val' => '' ),
				'source'       => array( 'attr' => '', 'val' => '' ),
				'medium'       => array( 'attr' => '', 'val' => '' ),
				'sources'      => array( 'attr' => '', 'val' => '' ),
				'terms'        => array( 'attr' => '', 'val' => '' ),
				'first_page'   => array( 'attr' => '', 'val' => '' ),
			),

			// Merging behavior.
			'source_medium_separator' => ' / ',

			// Interaction events. `event_types` empty = nothing tracked, nothing
			// enqueued, nothing added to the summary. The rest are only consulted
			// when their type is enabled.
			'event_types'             => '',
			'event_downloads'         => 'pdf, doc, docx, xls, xlsx, ppt, pptx, csv, zip, rtf, txt',
			'event_social_hosts'      => 'facebook.com, instagram.com, youtube.com, twitter.com, x.com, linkedin.com, pinterest.com, tiktok.com, reddit.com, threads.net',
			'event_video_milestones'  => '25, 50, 75, 100',
			'event_scroll_thresholds' => '25, 50, 75, 100',
			'event_custom_selectors'  => '',

			// Read the GA4 client ID out of the visitor's `_ga` cookie at merge-tag
			// resolution time. Never stored by us — it only flows wherever the site
			// owner points {bw:ga_client_id}.
			'capture_ga_client_id'    => 0,

			// Cross-domain handoff. Off by default; every sub-setting is inert
			// until `handoff_enabled` and at least one mode are on.
			'handoff_enabled'         => 0,
			'handoff_mode_data'       => 0,
			'handoff_mode_link'       => 0,
			'handoff_ack'             => 0,
			'handoff_domains'         => '',
			'handoff_origins'         => '',
			'handoff_param'           => 'bwlai',
			'handoff_ttl'             => 60,
			'handoff_retention'       => '90',
			'handoff_datapoints'      => 'channel, source, medium, campaign, term',

			'debug'                   => 0,
		);
	}

	public function register_settings() {
		register_setting(
			self::OPTION_GROUP,
			BW_LEAD_AI_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize' ),
				'default'           => self::defaults(),
			)
		);
		register_setting(
			self::OPTION_GROUP . '_utm',
			BW_LEAD_AI_UTM_OPTION,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_utm' ),
				'default'           => array(),
			)
		);
	}

	public function maybe_seed_defaults() {
		if ( false === get_option( BW_LEAD_AI_OPTION ) ) {
			update_option( BW_LEAD_AI_OPTION, self::defaults() );
		}
	}

	public function sanitize( $input ) {
		$defaults = self::defaults();
		if ( ! is_array( $input ) ) {
			return $defaults;
		}
		$out = array();

		// Parameter aliases can arrive either combined (hidden input passthrough from
		// other tabs) or split across three fields (Settings tab: dedicated source and
		// medium inputs plus a textarea for everything else). Rebuild the combined
		// string when the split form is present so that source/medium always exist as
		// the first two rows.
		$has_split = isset( $input['parameter_aliases_source'] )
			|| isset( $input['parameter_aliases_medium'] )
			|| isset( $input['parameter_aliases_other'] );
		if ( $has_split ) {
			$out['parameter_aliases'] = $this->build_parameter_aliases_from_split( $input, $defaults );
		} elseif ( isset( $input['parameter_aliases'] ) ) {
			$out['parameter_aliases'] = $this->sanitize_multiline( $input['parameter_aliases'] );
		} else {
			$out['parameter_aliases'] = $defaults['parameter_aliases'];
		}

		$out['referrer_classification'] = isset( $input['referrer_classification'] )
			? $this->sanitize_multiline( $input['referrer_classification'] )
			: $defaults['referrer_classification'];

		$out['click_ids'] = isset( $input['click_ids'] )
			? $this->sanitize_multiline( $input['click_ids'] )
			: $defaults['click_ids'];

		$out['channels'] = isset( $input['channels'] )
			? $this->sanitize_multiline( $input['channels'] )
			: $defaults['channels'];

		// Preserve spaces in the separator — `sanitize_text_field` trims whitespace,
		// which would silently convert the default " / " into "/" on first save.
		$out['source_medium_separator'] = isset( $input['source_medium_separator'] )
			? $this->sanitize_separator( $input['source_medium_separator'] )
			: $defaults['source_medium_separator'];

		$out['debug']                = ! empty( $input['debug'] ) ? 1 : 0;
		$out['capture_ga_client_id'] = ! empty( $input['capture_ga_client_id'] ) ? 1 : 0;

		// Handoff.
		foreach ( array( 'handoff_enabled', 'handoff_mode_data', 'handoff_mode_link', 'handoff_ack' ) as $flag ) {
			$out[ $flag ] = ! empty( $input[ $flag ] ) ? 1 : 0;
		}

		$out['handoff_domains'] = isset( $input['handoff_domains'] )
			? $this->sanitize_csv( str_replace( array( "\r\n", "\n", "\r" ), ',', (string) $input['handoff_domains'] ) )
			: $defaults['handoff_domains'];

		$out['handoff_origins'] = isset( $input['handoff_origins'] )
			? $this->sanitize_csv( str_replace( array( "\r\n", "\n", "\r" ), ',', (string) $input['handoff_origins'] ) )
			: $defaults['handoff_origins'];

		// Only a bare query-parameter name — it goes straight into a URL.
		if ( isset( $input['handoff_param'] ) ) {
			$param                = sanitize_key( $input['handoff_param'] );
			$out['handoff_param'] = ( '' === $param ) ? $defaults['handoff_param'] : $param;
		} else {
			$out['handoff_param'] = $defaults['handoff_param'];
		}

		// Pending TTL is an abuse control, so it is clamped rather than trusted.
		$out['handoff_ttl'] = isset( $input['handoff_ttl'] )
			? min( 240, max( 5, absint( $input['handoff_ttl'] ) ) )
			: $defaults['handoff_ttl'];

		// Confirmed retention may legitimately be unlimited — lead cycles run years.
		// A custom day count, when supplied, wins over the preset dropdown.
		$custom_retention = isset( $input['handoff_retention_custom'] ) ? trim( (string) $input['handoff_retention_custom'] ) : '';
		if ( '' !== $custom_retention && is_numeric( $custom_retention ) ) {
			$out['handoff_retention'] = (string) max( 1, absint( $custom_retention ) );
		} elseif ( isset( $input['handoff_retention'] ) ) {
			$retention = strtolower( trim( (string) $input['handoff_retention'] ) );
			if ( 'unlimited' === $retention ) {
				$out['handoff_retention'] = 'unlimited';
			} else {
				$days                     = max( 1, absint( $retention ) );
				$out['handoff_retention'] = (string) $days;
			}
		} else {
			$out['handoff_retention'] = $defaults['handoff_retention'];
		}

		$out['handoff_datapoints'] = isset( $input['handoff_datapoints_present'] )
			? implode( ', ', array_keys( array_filter( (array) ( isset( $input['handoff_datapoints_checked'] ) ? $input['handoff_datapoints_checked'] : array() ) ) ) )
			: ( isset( $input['handoff_datapoints'] ) ? $this->sanitize_csv( $input['handoff_datapoints'] ) : $defaults['handoff_datapoints'] );

		// Interaction events. The Events tab posts a presence marker plus one
		// checkbox per type; every other tab round-trips the stored CSV through a
		// hidden input. Without the marker we cannot tell "unchecked everything"
		// apart from "this tab never rendered the checkboxes".
		if ( isset( $input['event_types_present'] ) ) {
			$checked = ( isset( $input['event_types_checked'] ) && is_array( $input['event_types_checked'] ) )
				? $input['event_types_checked']
				: array();
			$enabled = array();
			foreach ( self::EVENT_TYPES as $event_type ) {
				if ( ! empty( $checked[ $event_type ] ) ) {
					$enabled[] = $event_type;
				}
			}
			$out['event_types'] = implode( ', ', $enabled );
		} elseif ( isset( $input['event_types'] ) ) {
			$out['event_types'] = $this->sanitize_csv( $input['event_types'] );
		} else {
			$out['event_types'] = $defaults['event_types'];
		}

		foreach ( array( 'event_downloads', 'event_social_hosts', 'event_video_milestones', 'event_scroll_thresholds' ) as $csv_key ) {
			$out[ $csv_key ] = isset( $input[ $csv_key ] )
				? $this->sanitize_csv( $input[ $csv_key ] )
				: $defaults[ $csv_key ];
		}

		$out['event_custom_selectors'] = isset( $input['event_custom_selectors'] )
			? $this->sanitize_multiline( $input['event_custom_selectors'] )
			: $defaults['event_custom_selectors'];

		// Field targets.
		$out['field_targets'] = $defaults['field_targets'];
		if ( isset( $input['field_targets'] ) && is_array( $input['field_targets'] ) ) {
			foreach ( $out['field_targets'] as $key => $def ) {
				if ( isset( $input['field_targets'][ $key ] ) && is_array( $input['field_targets'][ $key ] ) ) {
					$attr = isset( $input['field_targets'][ $key ]['attr'] ) ? sanitize_key( $input['field_targets'][ $key ]['attr'] ) : '';
					$val  = isset( $input['field_targets'][ $key ]['val'] ) ? sanitize_text_field( $input['field_targets'][ $key ]['val'] ) : '';
					if ( ! in_array( $attr, array( '', 'id', 'class', 'name', 'selector' ), true ) ) {
						$attr = '';
					}
					$out['field_targets'][ $key ] = array( 'attr' => $attr, 'val' => $val );
				}
			}
		}

		$out['parameter_aliases'] = $this->validate_parameter_aliases( $out['parameter_aliases'] );

		return $out;
	}

	/**
	 * Check parameter_aliases for duplicate labels and custom dim keys that
	 * collide with reserved merge-tag names. Emits settings errors for problems
	 * and returns a cleaned string with any invalid rows stripped, so the stored
	 * option never carries rows the parser would ignore anyway.
	 */
	private function validate_parameter_aliases( $text ) {
		$lines = preg_split( '/\r\n|\r|\n/', (string) $text );
		if ( empty( $lines ) ) {
			return $text;
		}
		$seen     = array();
		$reserved = self::RESERVED_TAG_NAMES;
		$out      = array();
		foreach ( $lines as $line ) {
			$trim = trim( $line );
			if ( '' === $trim ) {
				continue;
			}
			$split_pos = self::find_label_separator( $trim );
			if ( false === $split_pos ) {
				// Keep lines the parser can't understand — users may be mid-edit.
				$out[] = $line;
				continue;
			}
			$label = sanitize_key( trim( substr( $trim, 0, $split_pos ) ) );
			if ( '' === $label ) {
				continue;
			}
			if ( isset( $seen[ $label ] ) ) {
				add_settings_error(
					BW_LEAD_AI_OPTION,
					'bw_lead_ai_alias_duplicate_' . $label,
					sprintf(
						/* translators: %s is a label key. */
						esc_html__( 'Parameter Aliases: duplicate label "%s" — each label may only appear once. The extra row was dropped.', 'bw-lead-ai' ),
						esc_html( $label )
					)
				);
				continue;
			}
			$seen[ $label ] = 1;

			if ( ! in_array( $label, self::STANDARD_ALIAS_KEYS, true )
				&& in_array( $label, $reserved, true ) ) {
				add_settings_error(
					BW_LEAD_AI_OPTION,
					'bw_lead_ai_alias_reserved_' . $label,
					sprintf(
						/* translators: %s is a label key. */
						esc_html__( 'Parameter Aliases: "%s" is a reserved merge tag name and cannot be used as a custom dimension key. The row was dropped — pick a different key.', 'bw-lead-ai' ),
						esc_html( $label )
					)
				);
				continue;
			}
			$out[] = $line;
		}
		return implode( "\n", $out );
	}

	public function sanitize_utm( $input ) {
		if ( ! is_array( $input ) ) {
			return array();
		}
		$out = array();
		foreach ( $input as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$out[] = array(
				'link_to_uri'  => isset( $item['link_to_uri'] ) ? esc_url_raw( $item['link_to_uri'] ) : '',
				'utm_source'   => isset( $item['utm_source'] ) ? sanitize_text_field( $item['utm_source'] ) : '',
				'utm_medium'   => isset( $item['utm_medium'] ) ? sanitize_text_field( $item['utm_medium'] ) : '',
				'utm_campaign' => isset( $item['utm_campaign'] ) ? sanitize_text_field( $item['utm_campaign'] ) : '',
				'utm_term'     => isset( $item['utm_term'] ) ? sanitize_text_field( $item['utm_term'] ) : '',
				'utm_content'  => isset( $item['utm_content'] ) ? sanitize_text_field( $item['utm_content'] ) : '',
				'note'         => isset( $item['note'] ) ? sanitize_textarea_field( $item['note'] ) : '',
			);
		}
		return $out;
	}

	/**
	 * Build the parameter_aliases string from the Settings tab's split inputs.
	 * Source and medium always occupy the first two rows; if the user clears
	 * them they fall back to the default values so visit capture keeps working.
	 */
	private function build_parameter_aliases_from_split( $input, $defaults ) {
		$source_raw = isset( $input['parameter_aliases_source'] ) ? (string) $input['parameter_aliases_source'] : '';
		$medium_raw = isset( $input['parameter_aliases_medium'] ) ? (string) $input['parameter_aliases_medium'] : '';
		$other_raw  = isset( $input['parameter_aliases_other'] )  ? (string) $input['parameter_aliases_other']  : '';

		$source_csv = $this->sanitize_csv( $source_raw );
		$medium_csv = $this->sanitize_csv( $medium_raw );

		if ( '' === $source_csv ) {
			$defaults_aliases = self::parse_parameter_aliases( $defaults['parameter_aliases'] );
			$source_csv       = implode( ', ', $defaults_aliases['standard']['source'] );
		}
		if ( '' === $medium_csv ) {
			$defaults_aliases = isset( $defaults_aliases ) ? $defaults_aliases : self::parse_parameter_aliases( $defaults['parameter_aliases'] );
			$medium_csv       = implode( ', ', $defaults_aliases['standard']['medium'] );
		}

		$lines   = array( 'source : ' . $source_csv, 'medium : ' . $medium_csv );
		$cleaned = $this->sanitize_multiline( $other_raw );
		if ( '' !== $cleaned ) {
			// Drop any user-typed source/medium rows from the "other" textarea — those
			// always come from the dedicated inputs and must not appear twice.
			foreach ( preg_split( '/\n/', $cleaned ) as $line ) {
				$parts = explode( ':', $line, 2 );
				if ( count( $parts ) === 2 ) {
					$label = sanitize_key( trim( $parts[0] ) );
					if ( 'source' === $label || 'medium' === $label ) {
						continue;
					}
				}
				$lines[] = $line;
			}
		}
		return implode( "\n", $lines );
	}

	/**
	 * Minimal sanitizer for the source/medium separator: strip tags, drop control
	 * characters, but preserve leading/trailing whitespace so users can keep
	 * separators like " / " or " • " intact. Avoids `sanitize_text_field` and
	 * `wp_strip_all_tags` because both of those trim whitespace.
	 */
	private function sanitize_separator( $val ) {
		$val = (string) $val;
		$val = wp_check_invalid_utf8( $val );
		// Strip tags without trimming. `strip_tags` leaves whitespace alone.
		$val = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $val );
		$val = strip_tags( $val );
		// Drop control characters (including \r\n\t) but keep the space (0x20).
		$val = preg_replace( '/[\x00-\x1F\x7F]/u', '', $val );
		return $val;
	}

	private function sanitize_csv( $val ) {
		$parts = array_map( 'trim', explode( ',', (string) $val ) );
		$parts = array_filter( $parts, 'strlen' );
		$parts = array_map( 'sanitize_text_field', $parts );
		return implode( ', ', $parts );
	}

	private function sanitize_multiline( $val ) {
		$val   = (string) $val;
		$lines = preg_split( '/\r\n|\r|\n/', $val );
		$clean = array();
		foreach ( $lines as $line ) {
			$line = trim( $line );
			if ( '' === $line ) {
				continue;
			}
			$clean[] = sanitize_text_field( $line );
		}
		return implode( "\n", $clean );
	}

	/**
	 * Get the full settings array, merged with defaults.
	 */
	public static function get() {
		$opt = get_option( BW_LEAD_AI_OPTION, array() );
		if ( ! is_array( $opt ) ) {
			$opt = array();
		}
		return wp_parse_args( $opt, self::defaults() );
	}

	/**
	 * Parse "label : value1, value2, ..." multiline text into an ordered list.
	 * Returns: [ [ 'label' => '...', 'values' => [ '...', ... ] ], ... ]
	 *
	 * The label separator is the first `:` that sits outside any `{...}` group,
	 * so labels like `{bw:source}` (used in channel rules) parse correctly even
	 * though they contain a literal `:` inside the braces.
	 */
	public static function parse_labeled_list( $text ) {
		$rows  = array();
		$lines = preg_split( '/\r\n|\r|\n/', (string) $text );
		foreach ( $lines as $line ) {
			$line = trim( $line );
			if ( '' === $line || '#' === substr( $line, 0, 1 ) ) {
				continue;
			}
			$split_pos = self::find_label_separator( $line );
			if ( false === $split_pos ) {
				continue;
			}
			$label  = trim( substr( $line, 0, $split_pos ) );
			$rest   = substr( $line, $split_pos + 1 );
			$values = array();
			foreach ( array_map( 'trim', explode( ',', $rest ) ) as $val ) {
				if ( '' !== $val ) {
					$values[] = $val;
				}
			}
			if ( '' === $label || empty( $values ) ) {
				continue;
			}
			$rows[] = array( 'label' => $label, 'values' => $values );
		}
		return $rows;
	}

	/**
	 * Find the position of the first `:` that sits outside any `{...}` group.
	 * Returns false if no such separator exists on the line.
	 */
	private static function find_label_separator( $line ) {
		$depth = 0;
		$len   = strlen( $line );
		for ( $i = 0; $i < $len; $i++ ) {
			$ch = $line[ $i ];
			if ( '{' === $ch ) {
				$depth++;
			} elseif ( '}' === $ch ) {
				if ( $depth > 0 ) {
					$depth--;
				}
			} elseif ( ':' === $ch && 0 === $depth ) {
				return $i;
			}
		}
		return false;
	}

	/**
	 * Parse parameter_aliases into standard dimensions + custom dimensions.
	 *
	 * Returns: [
	 *     'standard' => [ source => [...], medium => [...], ... ],
	 *     'custom'   => [ key    => [...], ... ],
	 * ]
	 *
	 * Rows whose label is one of STANDARD_ALIAS_KEYS populate the standard
	 * bucket. Any other label becomes a custom dimension exposed via
	 * `{bw:<label>}`. Rows with reserved merge-tag labels are dropped.
	 */
	public static function parse_parameter_aliases( $text ) {
		$out  = array( 'standard' => array(), 'custom' => array() );
		$rows = self::parse_labeled_list( $text );
		foreach ( $rows as $row ) {
			$key = sanitize_key( $row['label'] );
			if ( '' === $key || isset( $out['standard'][ $key ] ) || isset( $out['custom'][ $key ] ) ) {
				continue;
			}
			if ( in_array( $key, self::STANDARD_ALIAS_KEYS, true ) ) {
				$out['standard'][ $key ] = $row['values'];
				continue;
			}
			if ( in_array( $key, self::RESERVED_TAG_NAMES, true ) ) {
				continue;
			}
			$out['custom'][ $key ] = $row['values'];
		}
		// Ensure every standard key exists so the frontend config is complete.
		foreach ( self::STANDARD_ALIAS_KEYS as $std ) {
			if ( ! isset( $out['standard'][ $std ] ) ) {
				$out['standard'][ $std ] = array();
			}
		}
		return $out;
	}

	/**
	 * Parse the enabled event types CSV into a list of valid type keys.
	 * Unknown keys are dropped so a stale setting can't enable something the
	 * front end has no handler for.
	 */
	public static function parse_event_types( $text ) {
		$out = array();
		foreach ( array_map( 'trim', explode( ',', (string) $text ) ) as $type ) {
			$type = sanitize_key( $type );
			if ( '' === $type || ! in_array( $type, self::EVENT_TYPES, true ) || in_array( $type, $out, true ) ) {
				continue;
			}
			$out[] = $type;
		}
		return $out;
	}

	/**
	 * Whether a given event type is enabled on this site.
	 */
	public static function event_enabled( $type, $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return in_array( $type, self::parse_event_types( $settings['event_types'] ), true );
	}

	/**
	 * Split a comma-separated setting into a lowercased, de-duplicated list.
	 */
	public static function parse_csv_list( $text ) {
		$out = array();
		foreach ( array_map( 'trim', explode( ',', (string) $text ) ) as $item ) {
			$item = strtolower( $item );
			if ( '' !== $item && ! in_array( $item, $out, true ) ) {
				$out[] = $item;
			}
		}
		return $out;
	}

	/**
	 * Parse a comma-separated list of percentages (video / scroll milestones)
	 * into sorted unique ints in 1–100. Out-of-range values are dropped rather
	 * than clamped, so a typo doesn't silently become a real milestone.
	 */
	public static function parse_percent_list( $text ) {
		$out = array();
		foreach ( array_map( 'trim', explode( ',', (string) $text ) ) as $item ) {
			if ( '' === $item || ! is_numeric( $item ) ) {
				continue;
			}
			$value = (int) $item;
			if ( $value < 1 || $value > 100 || in_array( $value, $out, true ) ) {
				continue;
			}
			$out[] = $value;
		}
		sort( $out );
		return $out;
	}

	/**
	 * Parse the custom event selectors setting into label + selector pairs.
	 * Format per line: `Label : selector1, selector2`.
	 *
	 * Returns: [ [ 'label' => '...', 'selectors' => [ '...', ... ] ], ... ]
	 */
	public static function parse_custom_events( $text ) {
		$out = array();
		foreach ( self::parse_labeled_list( $text ) as $row ) {
			$label = sanitize_text_field( $row['label'] );
			if ( '' === $label ) {
				continue;
			}
			$out[] = array(
				'label'     => $label,
				'selectors' => $row['values'],
			);
		}
		return $out;
	}

	// --- cross-domain handoff -------------------------------------------

	/**
	 * Datapoints the handoff payload may contain. Deliberately a closed list: the
	 * server filters the client's POST against it, so a tampered front end cannot
	 * push a datapoint the site owner has not agreed to send.
	 */
	public static function handoff_available_datapoints() {
		$points = array(
			'channel', 'source', 'medium', 'source_medium', 'campaign', 'term', 'content',
			'adgroup', 'first_channel', 'first_source', 'first_medium', 'first_page',
			'last_page', 'visits', 'pages', 'tagged_visits', 'events', 'events_list',
			'summary', 'summary_detailed',
		);
		$settings = self::get();
		if ( ! empty( $settings['capture_ga_client_id'] ) ) {
			$points[] = 'ga_client_id';
		}
		$aliases = self::parse_parameter_aliases( $settings['parameter_aliases'] );
		foreach ( array_keys( $aliases['custom'] ) as $custom_key ) {
			$points[] = $custom_key;
		}
		return $points;
	}

	/**
	 * Datapoints carrying a visitor's browsing history rather than just how they
	 * arrived. Off by default and warned about in the UI, because sending these to
	 * a third party is a materially bigger decision than sending attribution.
	 */
	public static function handoff_sensitive_datapoints() {
		return array( 'summary', 'summary_detailed', 'events_list' );
	}

	public static function parse_handoff_datapoints( $settings = null ) {
		$settings  = ( null === $settings ) ? self::get() : $settings;
		$available = self::handoff_available_datapoints();
		$out       = array();
		foreach ( array_map( 'trim', explode( ',', (string) $settings['handoff_datapoints'] ) ) as $point ) {
			$point = strtolower( preg_replace( '/[^A-Za-z0-9_.\-]/', '', $point ) );
			if ( '' !== $point && in_array( $point, $available, true ) && ! in_array( $point, $out, true ) ) {
				$out[] = $point;
			}
		}
		return $out;
	}

	public static function parse_handoff_domains( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return self::parse_csv_list( $settings['handoff_domains'] );
	}

	/**
	 * Origins permitted to read the claim/confirm endpoints.
	 *
	 * Defaults to `https://<domain>` for each configured destination when the
	 * explicit list is empty, but an explicit list always wins — CORS is the one
	 * place where being precise matters most.
	 */
	public static function parse_handoff_origins( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$explicit = self::parse_csv_list( $settings['handoff_origins'] );
		if ( ! empty( $explicit ) ) {
			return array_values( array_filter( array_map( array( __CLASS__, 'normalize_origin' ), $explicit ) ) );
		}
		$derived = array();
		foreach ( self::parse_handoff_domains( $settings ) as $domain ) {
			$derived[] = 'https://' . ltrim( $domain, '.' );
		}
		return $derived;
	}

	/**
	 * Reduce a URL to a bare scheme://host[:port] origin, or '' if unusable.
	 */
	public static function normalize_origin( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}
		if ( false === strpos( $value, '://' ) ) {
			$value = 'https://' . $value;
		}
		$parts = wp_parse_url( $value );
		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return '';
		}
		$origin = strtolower( $parts['scheme'] ) . '://' . strtolower( $parts['host'] );
		if ( ! empty( $parts['port'] ) ) {
			$origin .= ':' . (int) $parts['port'];
		}
		return $origin;
	}

	/**
	 * Confirmed-record retention in days, or null for unlimited.
	 */
	public static function handoff_retention_days( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		if ( 'unlimited' === strtolower( (string) $settings['handoff_retention'] ) ) {
			return null;
		}
		return max( 1, absint( $settings['handoff_retention'] ) );
	}

	/**
	 * Whether handoff is live at all. Requires the master switch, at least one
	 * mode, and somewhere to hand off to — anything less and no routes are
	 * registered and no script is enqueued.
	 */
	public static function handoff_enabled( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		if ( empty( $settings['handoff_enabled'] ) ) {
			return false;
		}
		if ( empty( self::parse_handoff_domains( $settings ) ) ) {
			return false;
		}
		return self::handoff_mode_data( $settings ) || self::handoff_mode_link( $settings );
	}

	/**
	 * Mode A — the destination reads the datapoints cross-origin.
	 *
	 * Gated behind an explicit acknowledgment because this is the mode that sends
	 * visitor data to a third-party origin. Un-ticking the acknowledgment disables
	 * the mode, rather than merely hiding a warning.
	 */
	public static function handoff_mode_data( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return ! empty( $settings['handoff_mode_data'] ) && ! empty( $settings['handoff_ack'] );
	}

	/** Mode B — only an opaque token crosses domains; the journey is read in wp-admin. */
	public static function handoff_mode_link( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return ! empty( $settings['handoff_mode_link'] );
	}

	/**
	 * Parse referrer_classification into an ordered list of classes.
	 * Returns: [ [ 'medium' => 'organic', 'sources' => [ 'google', ... ] ], ... ]
	 */
	public static function parse_referrer_classification( $text ) {
		$out  = array();
		$rows = self::parse_labeled_list( $text );
		foreach ( $rows as $row ) {
			$medium = sanitize_key( $row['label'] );
			if ( '' === $medium ) {
				continue;
			}
			$out[] = array( 'medium' => $medium, 'sources' => $row['values'] );
		}
		return $out;
	}

	/**
	 * Parse click_ids text into a list of [param, source, medium] rows.
	 *
	 * Format per line: `source/medium : param1, param2, ...`
	 * One output row per param so the JS lookup stays the same as before.
	 */
	public static function parse_click_ids( $text ) {
		$out  = array();
		$rows = self::parse_labeled_list( $text );
		foreach ( $rows as $row ) {
			$pair = array_map( 'trim', explode( '/', $row['label'], 2 ) );
			if ( count( $pair ) !== 2 || '' === $pair[0] || '' === $pair[1] ) {
				continue;
			}
			$source = $pair[0];
			$medium = $pair[1];
			foreach ( $row['values'] as $param ) {
				$param = sanitize_key( $param );
				if ( '' === $param ) {
					continue;
				}
				$out[] = array(
					'param'  => $param,
					'source' => $source,
					'medium' => $medium,
				);
			}
		}
		return $out;
	}

	/**
	 * Default parameter aliases text (standard dimensions only).
	 */
	public static function default_parameter_aliases_text() {
		return implode( "\n", array(
			'source   : utm_source, source, src, ctm_source',
			'medium   : utm_medium, medium, med, ctm_medium',
			'campaign : utm_campaign, campaign, cmp, ctm_campaign',
			'term     : utm_term, trm, term, key, keyword, ctm_term',
			'content  : utm_content, content, cnt, creative, crv, ctm_content',
			'adgroup  : utm_adgroup, adgroup, adg, ctm_adgroup',
		) );
	}

	/**
	 * Default referrer classification text. Labels are the medium the visit
	 * will be assigned when its referrer host matches one of the values.
	 *
	 * This is only a fallback — explicit UTMs or click-IDs always win.
	 */
	public static function default_referrer_classification_text() {
		return implode( "\n", array(
			'organic : google, yahoo, bing, duckduckgo, ecosia',
			'social  : facebook, instagram, youtube, twitter, x.com, linkedin, pinterest, tiktok, reddit',
		) );
	}

	/**
	 * Default click-ID inference text. Label is the source/medium the visit
	 * will be assigned when any of the listed params is present.
	 */
	public static function default_click_ids_text() {
		return implode( "\n", array(
			'google/cpc         : gclid, gclsrc, gbraid, wbraid',
			'facebook/social    : fbclid',
			'bing/cpc           : msclkid',
			'doubleclick/display: dclid',
			'tiktok/cpc         : ttclid',
			'linkedin/cpc       : li_fat_id',
			'twitter/cpc        : twclid',
			'yandex/cpc         : yclid',
		) );
	}

	/**
	 * Default channel mapping rules. Order matters: first match wins.
	 *
	 * Format per line: `Label : source1/medium1, source2/medium2, ...`
	 * `*` matches any value. `{bw:source}` / `{bw:medium}` in the label are
	 * substituted with the actual visit values at render time.
	 */
	public static function default_channels_text() {
		return implode( "\n", array(
			'Google Ads : google/cpc, google/ppc, google/paid, google/display, google/shopping, google/video',
			'Bing Ads : bing/cpc, microsoft/cpc',
			'Facebook Ads : facebook/cpc, facebook/paid, meta/cpc, meta/paid',
			'Instagram Ads : instagram/cpc, instagram/paid',
			'LinkedIn Ads : linkedin/cpc, linkedin/paid',
			'TikTok Ads : tiktok/cpc, tiktok/paid',
			'YouTube Ads : youtube/cpc, youtube/paid, youtube/video',
			'Twitter Ads : twitter/cpc, x/cpc',
			'Google Organic : google/organic',
			'Bing Organic : bing/organic',
			'DuckDuckGo : duckduckgo/organic',
			'Yahoo : yahoo/organic',
			'Organic Search : */organic',
			'Email : */email, */newsletter, */e-mail',
			'Social : */social',
			'Display : */display, */banner',
			'Affiliate : */affiliate',
			'Direct : (direct)/(none)',
			'{bw:source} : */referral',
			'Unknown : (none)/(none), (unknown)/(unknown), (not set)/(not set)',
		) );
	}

	/**
	 * Parse channels text into an ordered list of rules.
	 *
	 * Returns: [ [ 'label' => '...', 'patterns' => [ [source, medium], ... ] ], ... ]
	 * Each pattern token uses '*' for wildcard. '{bw:source}' and '{bw:medium}' in the
	 * label are preserved — the JS resolver substitutes them at render time.
	 */
	public static function parse_channels( $text ) {
		$rules = array();
		$rows  = self::parse_labeled_list( $text );
		foreach ( $rows as $row ) {
			$patterns = array();
			foreach ( $row['values'] as $pair ) {
				$pieces = array_map( 'trim', explode( '/', $pair, 2 ) );
				if ( count( $pieces ) !== 2 ) {
					continue;
				}
				$patterns[] = array(
					'source' => '' === $pieces[0] ? '*' : $pieces[0],
					'medium' => '' === $pieces[1] ? '*' : $pieces[1],
				);
			}
			if ( empty( $patterns ) ) {
				continue;
			}
			$rules[] = array(
				'label'    => $row['label'],
				'patterns' => $patterns,
			);
		}
		return $rules;
	}
}
