<?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).
 *                                 Its `internal` row is the site's own-property list —
 *                                 see owned_hosts() and referrer_classification_text().
 *   - 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' );

	/**
	 * What goes between the two halves of a source/medium pair, everywhere.
	 *
	 * A constant, not a setting. It was configurable once, and the only thing that
	 * bought was a way for two sites to disagree about what `google / cpc` looks
	 * like — including one that saved it as `google/cpc` because an early sanitizer
	 * trimmed the spaces off and nobody could see why their merge tag had changed.
	 * `{bw:source_medium}`, the reports, the channel-label fallback and capture.js
	 * all read this one shape. capture.js carries its own literal copy of it, since
	 * the browser cannot read a PHP constant; keep the two in step.
	 */
	const SOURCE_MEDIUM_SEPARATOR = ' / ';

	/**
	 * The medium whose `referrer_classification` row lists the hostnames this site
	 * owns. One name, used by the parser, the derivation, the channel rule
	 * (`Own Property : *&#47;internal`) and the reports.
	 */
	const OWNED_MEDIUM = 'internal';

	/**
	 * Stands in for this site's own hostname inside a configured hostname value.
	 *
	 * A token rather than the literal so one line means the right thing on staging,
	 * on live and on a client's own domain — the rule travels with the settings
	 * instead of having to be re-typed per environment.
	 */
	const THIS_DOMAIN_TOKEN = '{this-domain}';

	/** 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',
	);

	/**
	 * Valid explicit values for the two journey policy settings. An empty stored
	 * value is also valid and means "decided automatically" — see
	 * journey_save_mode() and journey_update_mode() for what automatic resolves to.
	 */
	const JOURNEY_SAVE_MODES   = array( 'submission', 'confirm' );
	const JOURNEY_UPDATE_MODES = array( 'snapshot', 'visit', 'growing' );

	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(),

			// RETIRED as a source of truth; kept only as a migration source.
			// Which hostnames are OURS is now one visible row in
			// `referrer_classification` — `internal : *.{this-domain}` — instead of
			// a separate list plus an invisible "…and anything under our own
			// hostname" rule nobody could see or edit. Whatever a site stored here
			// is folded into that row at read time by
			// referrer_classification_text(), so nothing is lost, and sanitize()
			// preserves the stored value rather than defaulting it: it is the only
			// copy of what the owner typed. No screen writes it any more.
			'self_referral_hosts'     => '',
			'click_ids'               => self::default_click_ids_text(),
			'channels'                => self::default_channels_text(),

			// Reporting-only: which channels are read together as one bucket. Derived
			// from the channel LABEL at report time and never written to a journey,
			// so changing it needs no reprocessing — see parse_channel_groups().
			'channel_groups'          => self::default_channel_groups_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' => '' ),
			),

			// 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'         => '',
			// Other addresses THIS site answers on — a live hostname while the code
			// is being generated on staging, or the reverse. The generated
			// destination script carries them as an allow-list and calls whichever
			// one the visitor actually arrived from, so one copy of it survives the
			// launch instead of silently calling a host nobody visits any more.
			'handoff_self_origins'    => '',
			'handoff_param'           => 'bwlai',
			'handoff_ttl'             => 60,
			'handoff_retention'       => '730',
			// Two lists, deliberately. `handoff_datapoints` is what gets STORED and
			// therefore what the logged-in journey viewer can show;
			// `handoff_share_datapoints` is the subset the third-party destination
			// may claim. One list for both would mean the only way to see a
			// visitor's history in the viewer is to also hand it to the
			// destination — the opposite of the point.
			'handoff_datapoints'      => 'channel, source, medium, campaign, term, journey',
			'handoff_share_datapoints' => 'channel, source, medium, campaign, term',

			// Which form field on the DESTINATION each datapoint fills, written by
			// the setup wizard as `datapoint:field_name` pairs. Stored so the
			// destination snippet can be regenerated at any time — without it the
			// generated code would only ever exist in the browser tab that made it,
			// and re-running the wizard would start from blank every time.
			'handoff_field_map'       => '',
			'handoff_identity_map'    => '',

			// The field on the DESTINATION form that receives the link back to this
			// site's journey report. Journey-link mode is defined as "let the
			// destination store a link back", and without somewhere to put it the
			// mode does not actually do that — the token is confirmed and the link
			// exists nowhere the destination's own system can see.
			'handoff_link_field'      => '',

			// Identity capture. Both off by default: they are the only settings in
			// the plugin that cause personal data to be retained at all.
			'capture_identity'        => 0,
			'capture_submission'      => 0,

			// Legacy "keep growing" flag, folded into journey_update_mode. Still
			// written by the wizard and read by the update-mode derivation so an
			// install that ticked it before the three-way existed keeps growing.
			'continuity_enabled'      => 0,

			// When a journey is saved, and how it may change afterwards. Stored
			// empty = decided automatically from whether a cross-domain
			// destination is live; see journey_save_mode() / journey_update_mode().
			// The sanitizer keeps these empty until a choice actually differs
			// from the automatic one, so the automatic default can keep tracking
			// the handoff configuration.
			'journey_save_mode'       => '',
			'journey_update_mode'     => '',

			'debug'                   => 0,
		);
	}

	/**
	 * The settings that travel between sites, and nothing else.
	 *
	 * The point of an export is the work: the mapping rules somebody spent an
	 * afternoon getting right, which are true of the internet rather than of one
	 * site, and which `{this-domain}` was built so they could survive the move. That
	 * is what this list is. Everything left out is left out for a stated reason, and
	 * the reasons are worth writing down because "export everything" is the obvious
	 * wrong answer:
	 *
	 * - **Site-specific values are not portable.** `handoff_domains`, `handoff_origins`,
	 *   `handoff_field_map`, `handoff_identity_map` and `handoff_link_field` name one
	 *   site's destination and the field IDs on its form. `handoff_self_origins` names
	 *   the other addresses one site answers on. `field_targets` names the
	 *   fields of one site's forms. `event_custom_selectors` are CSS selectors written
	 *   against one site's markup. Carried across they are not merely useless, they
	 *   are wrong in a way that looks configured.
	 * - **Switches that start collecting or sharing personal data do not travel.**
	 *   `capture_identity` and `capture_submission` are the only settings that cause
	 *   personal data to be retained at all; `handoff_enabled`, `handoff_mode_data`,
	 *   `handoff_mode_link` and `handoff_ack` turn on sending a visitor's history to
	 *   another domain — `handoff_ack` being the site owner's recorded acknowledgement
	 *   of exactly that. An import is a config file somebody pasted in. It must not be
	 *   able to switch those on for them.
	 * - **`debug` does not travel.** It is a per-site troubleshooting state, and an
	 *   import that quietly enabled console logging on a live site would be a
	 *   surprise nobody asked for.
	 * - **Journey storage policy does not travel.** `journey_save_mode` and
	 *   `journey_update_mode` are stored EMPTY when they track the automatic choice,
	 *   which is derived from the site's own handoff configuration — so the honest
	 *   thing on a new site is to let it derive its own, not to freeze the old site's
	 *   answer. `continuity_enabled` is the legacy flag feeding the same derivation.
	 * - **Retired settings do not travel.** See RETIRED_SETTINGS in
	 *   BW_Lead_AI_Admin: `self_referral_hosts` is a migration source read at load
	 *   time and no screen writes it. Exporting it would carry one site's old,
	 *   invisible state into another site's new, visible rule.
	 *
	 * There is no secret in this option to leak — no API key, no token, no password;
	 * the plugin has none. `handoff_param` is the NAME of a query parameter, and the
	 * only cryptographic material the plugin holds (the email pepper) lives in its
	 * own option and is not settings at all.
	 *
	 * Order is reading order: it is the order the export writes and the order the
	 * import preview lists changes in.
	 */
	const PORTABLE_SETTINGS = array(
		// The mapping rules — the reason this feature exists.
		'parameter_aliases',
		'referrer_classification',
		'click_ids',
		'channels',
		'channel_groups',
		// Interaction tracking: which kinds, and the rules for the kinds that have
		// them. NOT event_custom_selectors, which is markup-specific.
		'event_types',
		'event_downloads',
		'event_social_hosts',
		'event_video_milestones',
		'event_scroll_thresholds',
		// One capture option. It reads a cookie another tool already set and stores
		// nothing, so it carries no data consequence the way the identity switches do.
		'capture_ga_client_id',
	);

	/**
	 * What an export file is, so an import can refuse anything else.
	 *
	 * Bumped only when the SHAPE changes in a way an older importer would misread.
	 * Adding a key to PORTABLE_SETTINGS does not need a bump: an older file simply
	 * carries fewer keys, and the importer only ever applies the keys it finds.
	 */
	const EXPORT_SCHEMA = 1;

	/** The marker that says a JSON blob came from this plugin. */
	const EXPORT_MARKER = 'bw-lead-ai-settings';

	/**
	 * This site's portable settings, as the structure the download writes.
	 *
	 * The provenance fields are informational and the importer ignores them — but a
	 * file that cannot say where it came from is a file nobody can audit six months
	 * later, and this one is meant to be kept in a repository or emailed to a
	 * colleague.
	 *
	 * @param array|null $settings Settings to read, or null for the stored ones.
	 * @return array
	 */
	public static function export_payload( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : wp_parse_args( $settings, self::defaults() );

		$defaults = self::defaults();
		$values   = array();
		foreach ( self::PORTABLE_SETTINGS as $key ) {
			$values[ $key ] = isset( $settings[ $key ] ) ? $settings[ $key ] : $defaults[ $key ];
		}

		return array(
			'format'   => self::EXPORT_MARKER,
			'schema'   => self::EXPORT_SCHEMA,
			'plugin'   => 'bw-lead-ai',
			'version'  => defined( 'BW_LEAD_AI_VERSION' ) ? BW_LEAD_AI_VERSION : '',
			'exported' => gmdate( 'c' ),
			'site'     => home_url(),
			'settings' => $values,
		);
	}

	/**
	 * Read an uploaded or pasted export, or say precisely why it is not one.
	 *
	 * NOTHING here trusts the input. It is JSON a browser sent: it may be a photo, a
	 * different plugin's export, a hand-edited file with a key that is not a setting,
	 * or a deliberate attempt to write a value the settings screen would never
	 * accept. So the shape is checked, the marker is checked, and then the ONLY thing
	 * that comes back is a map of keys that are on PORTABLE_SETTINGS — the values are
	 * still raw and still have to go through sanitize() before they reach the option.
	 * This function's job is to reject, not to clean.
	 *
	 * Unknown keys are reported rather than silently dropped: a file with a typo'd
	 * key is a file that will not do what its author thinks, and saying so is the
	 * difference between "imported" and "imported, and quietly ignored half of it".
	 *
	 * @param string $raw The JSON text.
	 * @return array array( 'ok' => bool, 'error' => string, 'settings' => array,
	 *               'ignored' => array, 'meta' => array )
	 */
	public static function parse_import( $raw ) {
		$fail = function ( $message ) {
			return array( 'ok' => false, 'error' => $message, 'settings' => array(), 'ignored' => array(), 'meta' => array() );
		};

		$raw = trim( (string) $raw );
		if ( '' === $raw ) {
			return $fail( __( 'Nothing to import — paste the contents of an export file, or choose one to upload.', 'bw-lead-ai' ) );
		}
		// Bounded before it is parsed. A real export of this option is a few kilobytes;
		// anything past a megabyte is not one, and refusing early costs nothing.
		if ( strlen( $raw ) > 1048576 ) {
			return $fail( __( 'That file is far too large to be a settings export.', 'bw-lead-ai' ) );
		}

		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			return $fail( __( 'That is not valid JSON. Paste the whole export file, including the opening and closing braces.', 'bw-lead-ai' ) );
		}
		if ( ! isset( $data['format'] ) || self::EXPORT_MARKER !== $data['format'] ) {
			return $fail( __( 'That is valid JSON but it is not a BW Lead Attribution Intelligence settings export — it does not carry this plugin\'s marker. Nothing has been changed.', 'bw-lead-ai' ) );
		}
		$schema = isset( $data['schema'] ) ? (int) $data['schema'] : 0;
		if ( $schema < 1 || $schema > self::EXPORT_SCHEMA ) {
			return $fail(
				sprintf(
					/* translators: 1: the file's schema number, 2: the newest this plugin understands. */
					__( 'That export was written in format %1$d and this plugin understands up to format %2$d. Update the plugin, or export again from the other site.', 'bw-lead-ai' ),
					$schema,
					self::EXPORT_SCHEMA
				)
			);
		}
		if ( ! isset( $data['settings'] ) || ! is_array( $data['settings'] ) ) {
			return $fail( __( 'That export carries no settings. Nothing has been changed.', 'bw-lead-ai' ) );
		}

		$values  = array();
		$ignored = array();
		foreach ( $data['settings'] as $key => $value ) {
			$key = is_string( $key ) ? $key : '';
			if ( ! in_array( $key, self::PORTABLE_SETTINGS, true ) ) {
				// Never echoed back raw — an unknown key is a stranger's string.
				$ignored[] = sanitize_key( (string) $key );
				continue;
			}
			// Scalars only. Every portable setting is a string or a flag; an array
			// arriving where a string belongs is either a corrupt file or somebody
			// trying to reach a code path that takes arrays, and neither is an import.
			if ( ! is_scalar( $value ) ) {
				$ignored[] = sanitize_key( $key );
				continue;
			}
			$values[ $key ] = (string) $value;
		}

		if ( empty( $values ) ) {
			return $fail( __( 'That export contains none of the settings this plugin can import. Nothing has been changed.', 'bw-lead-ai' ) );
		}

		return array(
			'ok'       => true,
			'error'    => '',
			'settings' => $values,
			'ignored'  => array_values( array_unique( array_filter( $ignored ) ) ),
			'meta'     => array(
				'version'  => isset( $data['version'] ) ? sanitize_text_field( (string) $data['version'] ) : '',
				'exported' => isset( $data['exported'] ) ? sanitize_text_field( (string) $data['exported'] ) : '',
				'site'     => isset( $data['site'] ) ? esc_url_raw( (string) $data['site'] ) : '',
			),
		);
	}

	/**
	 * What importing these values would change, without changing anything.
	 *
	 * Run through the real sanitizer first, so the preview shows what would actually
	 * be STORED rather than what the file happens to say — a rule the sanitizer would
	 * strip must not appear in the preview as an addition. That also means the
	 * preview is the proof the values survive sanitisation at all.
	 *
	 * @param array $values Key => raw value, from parse_import().
	 * @return array array( 'changes' => [ key => [before, after] ], 'sanitized' => full settings )
	 */
	public static function preview_import( $values ) {
		$current   = self::get();
		$merged    = array_merge( $current, $values );
		$sanitized = self::instance()->sanitize( $merged );

		$changes = array();
		foreach ( self::PORTABLE_SETTINGS as $key ) {
			if ( ! isset( $values[ $key ] ) ) {
				continue;
			}
			$before = isset( $current[ $key ] ) ? (string) $current[ $key ] : '';
			$after  = isset( $sanitized[ $key ] ) ? (string) $sanitized[ $key ] : '';
			if ( $before !== $after ) {
				$changes[ $key ] = array( $before, $after );
			}
		}

		return array( 'changes' => $changes, 'sanitized' => $sanitized );
	}

	/**
	 * Human label for one portable setting, for the import preview.
	 *
	 * The screen names these sections; a preview that listed `referrer_classification`
	 * would be asking the reader to translate their own settings screen back into
	 * option keys.
	 */
	public static function portable_label( $key ) {
		$labels = array(
			'parameter_aliases'       => __( 'Tracking Parameter Definitions', 'bw-lead-ai' ),
			'referrer_classification' => __( 'Medium Mappings', 'bw-lead-ai' ),
			'click_ids'               => __( 'Click ID Mappings', 'bw-lead-ai' ),
			'channels'                => __( 'Channel Mappings', 'bw-lead-ai' ),
			'channel_groups'          => __( 'Channel Groupings', 'bw-lead-ai' ),
			'event_types'             => __( 'Interactions tracked', 'bw-lead-ai' ),
			'event_downloads'         => __( 'Download file types', 'bw-lead-ai' ),
			'event_social_hosts'      => __( 'Social hosts', 'bw-lead-ai' ),
			'event_video_milestones'  => __( 'Video milestones', 'bw-lead-ai' ),
			'event_scroll_thresholds' => __( 'Scroll depth thresholds', 'bw-lead-ai' ),
			'capture_ga_client_id'    => __( 'Capture Google Client ID', 'bw-lead-ai' ),
		);

		return isset( $labels[ $key ] ) ? $labels[ $key ] : $key;
	}

	/**
	 * Put every setting back to the shipped defaults.
	 *
	 * Deletes before it writes, and that is not tidiness. sanitize() deliberately
	 * falls back to the STORED value for a handful of keys that no form renders —
	 * the two journey-policy modes among them — so writing defaults over a live
	 * option would leave those keys exactly as they were and the reset would be a
	 * reset of everything except the settings nobody can see. With the option gone
	 * there is no stored value to fall back to, so factory state is factory state.
	 *
	 * Touches ONLY this option. Stored journeys, the leads table, the UTM builder's
	 * saved links and the data-quality dismissals are all elsewhere and all survive —
	 * see the copy on the Other Settings tab, which says so in as many words.
	 */
	public static function factory_reset() {
		delete_option( BW_LEAD_AI_OPTION );
		/*
		 * Written THROUGH the sanitizer, not as raw defaults.
		 *
		 * register_setting() hangs sanitize() on `sanitize_option_<option>`, which
		 * update_option() applies — in a browser. WP-CLI never fires admin_init, so
		 * there the filter is not registered and update_option() writes what it is
		 * given. Handing it raw defaults would therefore land on two slightly
		 * different values depending on where the reset was run from: the shipped
		 * mapping rules carry alignment padding (`source   : utm_source`) that
		 * sanitize_text_field() collapses to one space, so the browser's copy is
		 * collapsed and the CLI's is not. Sanitizing here makes factory state one
		 * value everywhere, and sanitize() is idempotent, so the browser's second
		 * pass changes nothing. tests/settings-portability.php asserts both halves.
		 */
		update_option( BW_LEAD_AI_OPTION, self::instance()->sanitize( self::defaults() ) );
	}

	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();

		// The `label : value, value` settings are edited as ROWS (see
		// BW_Lead_AI_Admin::render_pair_rows()), but the very same option key still
		// arrives as one blob of text from a hidden pass-through on another tab, or
		// from the section's own "Edit as text" escape hatch. resolve_pair_setting()
		// picks the right reading of the POST; what gets stored is the same canonical
		// text either way, so nothing downstream can tell which editor was used.
		//
		// $current is the value being replaced. Rows are rebuilt against it so a row
		// the user never touched is written back byte-for-byte, spacing and all — a
		// settings screen that rewrites its own file just by being opened and saved
		// would be a data-loss bug wearing a tidy-up costume.
		$current = self::get();

		$out['parameter_aliases'] = $this->resolve_pair_setting( 'parameter_aliases', $input, $current, $defaults );
		if ( ! empty( $input['parameter_aliases_editor'] ) ) {
			// Source and medium are required and always lead. The row editor renders
			// them locked, but the text escape hatch behind it can still delete them,
			// so the rule is enforced here rather than trusted to the UI.
			$out['parameter_aliases'] = $this->enforce_alias_rows( $out['parameter_aliases'], $defaults );
		}

		// Retired: no screen posts this any more, so it falls to the $current branch
		// on every save. PRESERVED rather than defaulted, deliberately — it is the
		// migration source referrer_classification_text() folds into the `internal`
		// row, and resetting it on the next save of any tab would quietly delete a
		// site's other owned properties before its owner had ever seen them on the
		// new screen.
		$out['self_referral_hosts'] = isset( $input['self_referral_hosts'] )
			? $this->sanitize_multiline( $input['self_referral_hosts'] )
			: (string) ( isset( $current['self_referral_hosts'] ) ? $current['self_referral_hosts'] : $defaults['self_referral_hosts'] );

		foreach ( array( 'referrer_classification', 'click_ids', 'channels', 'channel_groups' ) as $pair_key ) {
			$out[ $pair_key ] = $this->resolve_pair_setting( $pair_key, $input, $current, $defaults );
		}

		$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', 'capture_identity', 'capture_submission', 'continuity_enabled' ) 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'];

		// Normalised at the door, not at the point of use. This list is interpolated
		// into the generated destination script as its allow-list, so what is stored
		// is already exactly what is safe to emit — an entry that cannot be reduced to
		// `scheme://host[:port]` is dropped rather than escaped.
		$out['handoff_self_origins'] = isset( $input['handoff_self_origins'] )
			? implode(
				', ',
				self::parse_handoff_self_origins(
					array(
						'handoff_self_origins' => str_replace( array( "\r\n", "\n", "\r" ), ',', (string) $input['handoff_self_origins'] ),
					)
				)
			)
			: $defaults['handoff_self_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'] );

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

		// A datapoint can only be shared if it is also stored — you cannot hand out
		// something that was never captured.
		$stored_list                     = array_map( 'trim', explode( ',', $out['handoff_datapoints'] ) );
		$share_list                      = array_filter(
			array_map( 'trim', explode( ',', $out['handoff_share_datapoints'] ) ),
			function ( $point ) use ( $stored_list ) {
				return '' !== $point && in_array( $point, $stored_list, true );
			}
		);
		$out['handoff_share_datapoints'] = implode( ', ', $share_list );

		// The wizard-owned keys fall back to the STORED value, not the default.
		// Every other key renders an input or a pass-through on some settings tab,
		// so "absent from the POST" means "deliberately not on this form". These
		// three have no input anywhere — the wizard is their only writer — so an
		// absence only ever means the posting form predates them or skipped them,
		// and defaulting would silently erase a working destination setup.
		$stored = get_option( BW_LEAD_AI_OPTION, array() );

		$out['handoff_link_field'] = isset( $input['handoff_link_field'] )
			? self::sanitize_form_field_name( $input['handoff_link_field'] )
			: self::sanitize_form_field_name( isset( $stored['handoff_link_field'] ) ? $stored['handoff_link_field'] : $defaults['handoff_link_field'] );

		foreach ( array( 'handoff_field_map', 'handoff_identity_map' ) as $map_key ) {
			$out[ $map_key ] = isset( $input[ $map_key ] )
				? self::sanitize_field_map( $input[ $map_key ] )
				: self::sanitize_field_map( isset( $stored[ $map_key ] ) ? $stored[ $map_key ] : $defaults[ $map_key ] );
		}

		// The save question is asked as ONE CHECKBOX — "also save when a form on this
		// site is submitted" — because the two values were never alternatives: the
		// cross-domain confirm endpoint saves a journey whenever a destination reports
		// back, whatever this is set to, and this only decides whether an on-site
		// submission saves one TOO. What is stored is unchanged ('submission' /
		// 'confirm'), so no site's data has to move.
		//
		// An unticked box posts nothing at all, which is indistinguishable from a form
		// that never carried the field — and "absent" means "keep what is stored" three
		// lines below, so without a marker the box could be ticked but never unticked.
		// Same presence-marker mechanism as event_types_present and
		// handoff_datapoints_present.
		if ( isset( $input['journey_save_mode_present'] ) ) {
			$input['journey_save_mode'] = ( isset( $input['journey_save_mode'] ) && 'submission' === $input['journey_save_mode'] )
				? 'submission'
				: 'confirm';
		}

		// The two journey policy settings. Whitelisted, and with one deliberate
		// twist: the Journey Storage tab renders the AUTOMATIC choice pre-selected, so
		// an untouched save posts a real value even though nothing was chosen.
		// Storing that value would freeze it — a site that later configures a
		// destination would keep saving at submission because it once pressed
		// Save Changes on an unrelated field. So a posted value that merely
		// matches what the automatic rule already resolves to, on a site that
		// never stored an explicit choice, stays unstored; only a value that
		// DIFFERS from the automatic one is an actual choice, and an explicit
		// choice, once stored, is kept thereafter.
		$stored_full = wp_parse_args( is_array( $stored ) ? $stored : array(), $defaults );
		$mode_lists  = array(
			'journey_save_mode'   => self::JOURNEY_SAVE_MODES,
			'journey_update_mode' => self::JOURNEY_UPDATE_MODES,
		);
		foreach ( $mode_lists as $mode_key => $valid ) {
			$stored_raw = isset( $stored[ $mode_key ] ) && in_array( $stored[ $mode_key ], $valid, true )
				? $stored[ $mode_key ]
				: '';
			$posted = isset( $input[ $mode_key ] ) ? (string) $input[ $mode_key ] : null;
			if ( null === $posted || ! in_array( $posted, $valid, true ) ) {
				// Absent (an old cached form) or junk: keep what is stored.
				$out[ $mode_key ] = $stored_raw;
				continue;
			}
			$automatic = ( 'journey_save_mode' === $mode_key )
				? self::journey_save_mode( $stored_full )
				: self::journey_update_mode( $stored_full );
			if ( '' === $stored_raw && $posted === $automatic ) {
				$out[ $mode_key ] = '';
				continue;
			}
			$out[ $mode_key ] = $posted;
		}

		// 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'];
		}

		// Absent means KEEP WHAT IS STORED, not "reset to the shipped default".
		// Every tab carries these through hidden, so in a browser this branch is
		// not reached — but an empty list is now a deliberate setting ("record
		// that they played it, not how far they got"), and against a default that
		// is non-empty, falling back to the default would switch a feature the
		// owner turned OFF back ON. A tab that one day forgets the pass-through
		// would do it silently, and tab-save-roundtrip cannot see it on any site
		// whose stored value happens to equal the default. $current is defaults-
		// merged, so a fresh install still lands on the default.
		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 ] )
				: ( isset( $current[ $csv_key ] ) ? (string) $current[ $csv_key ] : $defaults[ $csv_key ] );
		}

		/*
		 * The two milestone lists are switched on and off by a checkbox, and an
		 * empty list IS off — recording that a video was played without recording
		 * how much of it was watched is a legitimate, and for most sites the more
		 * useful, setting.
		 *
		 * An unticked box posts nothing, which is indistinguishable from a form
		 * that never carried the field, and "absent" means "fall back" above. So
		 * each carries a presence marker — the same mechanism as
		 * event_types_present. Without it the boxes could be ticked and never
		 * unticked.
		 *
		 * Ticked with an empty list falls back to the default rather than storing
		 * nothing, because otherwise ticking the box would appear to do nothing.
		 */
		foreach ( array( 'video_marks' => 'event_video_milestones', 'scroll_marks' => 'event_scroll_thresholds' ) as $flag => $csv_key ) {
			if ( ! isset( $input[ $flag . '_present' ] ) ) {
				continue;
			}
			if ( empty( $input[ $flag . '_on' ] ) ) {
				$out[ $csv_key ] = '';
				continue;
			}
			if ( '' === $out[ $csv_key ] ) {
				$out[ $csv_key ] = $defaults[ $csv_key ];
			}
		}

		$out['event_custom_selectors'] = $this->resolve_pair_setting( 'event_custom_selectors', $input, $current, $defaults );

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

		$out['field_targets'] = $this->sanitize_field_targets( $input, $current, $defaults, $out );

		return $out;
	}

	/**
	 * Which form field each data point fills, for form plugins with no merge tags.
	 *
	 * The key set is DYNAMIC — it is one row per entry in
	 * BW_Lead_AI_Merge_Tags::available_tags(), which includes this site's own custom
	 * dimensions and its interaction tags — so this cannot iterate a fixed list the
	 * way it used to. What replaces the fixed list as the bound is a whitelist: a key
	 * is accepted only if the register currently has it, or if it is already stored.
	 * A crafted POST can therefore change a mapping this site already has and add one
	 * for a tag this site actually has, and nothing else — the option cannot be grown
	 * with arbitrary keys.
	 *
	 * Three rules make the result stable across saves, which is what the round-trip
	 * test asserts:
	 *
	 * - Everything already stored is carried forward BEFORE the POST is read, so a
	 *   mapping for a tag the current screen cannot show (a custom dimension since
	 *   removed from Tracking Parameter Definitions, say) is not deleted by a save
	 *   from a tab that never saw it.
	 * - An empty row is not data. The mapping screen posts ~25 rows, most of them
	 *   blank; storing those would grow the option on first save and make a save from
	 *   this screen look like a change to every other screen. Blank rows are dropped,
	 *   except for the six keys that have always been in the defaults.
	 * - Clearing a non-default row removes it, so "delete this mapping" actually does.
	 *
	 * @param array $input    The POST.
	 * @param array $current  The settings being replaced.
	 * @param array $defaults Shipped defaults.
	 * @param array $out      The settings so far — the register is derived from THESE,
	 *                        not from the stored ones, so adding a custom dimension and
	 *                        mapping it in one save works.
	 * @return array
	 */
	private function sanitize_field_targets( $input, $current, $defaults, $out ) {
		$targets = $defaults['field_targets'];

		$stored = ( isset( $current['field_targets'] ) && is_array( $current['field_targets'] ) )
			? $current['field_targets']
			: array();
		foreach ( $stored as $raw_key => $row ) {
			$key = BW_Lead_AI_Merge_Tags::sanitize_tag_key( $raw_key );
			if ( '' === $key || ! is_array( $row ) ) {
				continue;
			}
			$targets[ $key ] = self::clean_field_target( $row );
		}

		if ( ! isset( $input['field_targets'] ) || ! is_array( $input['field_targets'] ) ) {
			return $targets;
		}

		$available = BW_Lead_AI_Merge_Tags::available_tags( $out );
		foreach ( $input['field_targets'] as $raw_key => $row ) {
			$key = BW_Lead_AI_Merge_Tags::sanitize_tag_key( $raw_key );
			if ( '' === $key || ! is_array( $row ) ) {
				continue;
			}
			if ( ! isset( $available[ $key ] ) && ! array_key_exists( $key, $targets ) ) {
				continue;
			}
			$clean = self::clean_field_target( $row );
			if ( '' === $clean['attr'] && '' === $clean['val'] && ! array_key_exists( $key, $defaults['field_targets'] ) ) {
				unset( $targets[ $key ] );
				continue;
			}
			$targets[ $key ] = $clean;
		}

		return $targets;
	}

	/** One mapping row: how to find the field, and which field. */
	private static function clean_field_target( $row ) {
		$attr = isset( $row['attr'] ) ? sanitize_key( $row['attr'] ) : '';
		$val  = isset( $row['val'] ) ? sanitize_text_field( $row['val'] ) : '';
		if ( ! in_array( $attr, array( '', 'id', 'class', 'name', 'selector' ), true ) ) {
			$attr = '';
		}
		return array( 'attr' => $attr, 'val' => $val );
	}

	/**
	 * 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__( 'Tracking Parameter Definitions: 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__( 'Tracking Parameter Definitions: "%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;
	}

	/**
	 * Read one `label : value, value` setting out of a POST.
	 *
	 * Three shapes can carry the same setting, and they are told apart by what is
	 * present rather than by trusting a mode flag:
	 *
	 *   1. `<key>_editor` set and no `<key>` — the row editor, in rows mode. Its
	 *      textarea is rendered `disabled`, so a browser sends the rows and nothing
	 *      else. Zero rows is a legitimate answer here (the user deleted them all),
	 *      which is exactly why the marker exists: without it, "no rows posted" and
	 *      "no editor on this form" would look identical and clearing a section
	 *      would silently restore the defaults.
	 *   2. `<key>` present — the section's "Edit as text" escape hatch (rows are
	 *      disabled in that mode), or a hidden pass-through from another tab.
	 *   3. Neither — the setting was not on this form at all; keep the default,
	 *      which is what every other setting in sanitize() does.
	 *
	 * Rows and text produce the same canonical string, so downstream parsing never
	 * learns which one the user typed into.
	 */
	private function resolve_pair_setting( $key, $input, $current, $defaults ) {
		$from_editor = ! empty( $input[ $key . '_editor' ] );

		if ( $from_editor && ! isset( $input[ $key ] ) ) {
			$reference = isset( $current[ $key ] ) ? (string) $current[ $key ] : '';
			return $this->build_pair_text( $key, $input, $reference );
		}
		if ( isset( $input[ $key ] ) ) {
			return $this->sanitize_multiline( $input[ $key ] );
		}
		return $defaults[ $key ];
	}

	/**
	 * Rebuild the stored text from the row editor's two parallel arrays,
	 * `<key>_key[i]` and `<key>_val[i]`.
	 *
	 * Two levels deep on purpose. A three-level name (`<key>[i][label]`) would be
	 * invisible to the field scraper in tests/tab-save-roundtrip.php, whose regex
	 * matches `option[key]` and `option[key][sub]` only — the round-trip test would
	 * go on passing while quietly covering nothing.
	 *
	 * Row order is taken from the numeric index and sorted here explicitly. POST
	 * order happens to agree today, but nothing guarantees it, and order decides
	 * which rule wins for referrer_classification and channels.
	 *
	 * $reference is the text being replaced. A row whose label and values still
	 * match a line in it is written back as that exact line, so alignment padding
	 * and `host: value` spacing survive an untouched save; only rows the user
	 * actually edited are re-emitted in canonical `label : value, value` form.
	 */
	private function build_pair_text( $key, $input, $reference ) {
		$keys = ( isset( $input[ $key . '_key' ] ) && is_array( $input[ $key . '_key' ] ) ) ? $input[ $key . '_key' ] : array();
		$vals = ( isset( $input[ $key . '_val' ] ) && is_array( $input[ $key . '_val' ] ) ) ? $input[ $key . '_val' ] : array();

		// PHP has already turned "0", "1", … into integer keys; anything left that is
		// not an integer did not come from the editor, and guessing an order for it
		// would be worse than dropping it.
		$order = array_filter( array_keys( $keys + $vals ), 'is_int' );
		sort( $order, SORT_NUMERIC );

		$verbatim = self::pair_verbatim_map( $reference );
		$lines    = array();
		foreach ( $order as $i ) {
			$label = ( isset( $keys[ $i ] ) && is_scalar( $keys[ $i ] ) ) ? sanitize_text_field( (string) $keys[ $i ] ) : '';
			$value = ( isset( $vals[ $i ] ) && is_scalar( $vals[ $i ] ) ) ? $this->sanitize_csv( $vals[ $i ] ) : '';
			if ( '' === $label && '' === $value ) {
				// A row with nothing in it is a row the user added and abandoned, or
				// one they emptied to delete. Either way there is nothing to store.
				continue;
			}
			$line    = self::pair_canonical_line( $label, $value );
			$lines[] = isset( $verbatim[ $line ] ) ? $verbatim[ $line ] : $line;
		}
		return implode( "\n", $lines );
	}

	/**
	 * Split a `label : value, value` setting into one editable row per line.
	 *
	 * Returns: [ [ 'key' => '...', 'val' => 'a, b', 'raw' => '<line as stored>' ], ... ]
	 *
	 * Every non-blank line becomes a row, including lines the rule parser cannot
	 * understand — a comment, or something half-typed. Those come back with the
	 * whole line in `key` and an empty `val`, which is what puts them in front of
	 * the user in the editor instead of quietly deleting a config someone wrote.
	 */
	public static function split_pair_lines( $text ) {
		$rows = array();
		foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $line ) {
			$line = trim( $line );
			if ( '' === $line ) {
				continue;
			}
			// `#` lines are comments to parse_labeled_list, so they are not pairs
			// here either, even when they happen to contain a colon.
			$split_pos = ( '#' === substr( $line, 0, 1 ) ) ? false : self::find_label_separator( $line );
			if ( false === $split_pos ) {
				$rows[] = array( 'key' => $line, 'val' => '', 'raw' => $line );
				continue;
			}
			$values = array();
			foreach ( array_map( 'trim', explode( ',', substr( $line, $split_pos + 1 ) ) ) as $value ) {
				if ( '' !== $value ) {
					$values[] = $value;
				}
			}
			$rows[] = array(
				'key' => trim( substr( $line, 0, $split_pos ) ),
				'val' => implode( ', ', $values ),
				'raw' => $line,
			);
		}
		return $rows;
	}

	/**
	 * The canonical single line for one row. A row with no values keeps its label
	 * alone rather than gaining a dangling colon, so an unparseable line the editor
	 * showed as label-only is written back exactly as it arrived.
	 */
	private static function pair_canonical_line( $label, $value ) {
		if ( '' === $value ) {
			return $label;
		}
		return trim( $label . ' : ' . $value );
	}

	/** Canonical line => the line as it is actually stored. See build_pair_text(). */
	private static function pair_verbatim_map( $text ) {
		$map = array();
		foreach ( self::split_pair_lines( $text ) as $row ) {
			$canonical = self::pair_canonical_line( $row['key'], $row['val'] );
			if ( ! isset( $map[ $canonical ] ) ) {
				$map[ $canonical ] = $row['raw'];
			}
		}
		return $map;
	}

	/**
	 * Source and medium lead the parameter aliases and cannot be removed — without
	 * them nothing can be attributed at all. Clearing one falls back to the default
	 * parameter list rather than to nothing, and a duplicate of either is dropped.
	 */
	private function enforce_alias_rows( $text, $defaults ) {
		$source = null;
		$medium = null;
		$rest   = array();

		foreach ( self::split_pair_lines( $text ) as $row ) {
			$label = sanitize_key( $row['key'] );
			if ( 'source' === $label || 'medium' === $label ) {
				if ( '' === $row['val'] ) {
					continue;
				}
				if ( 'source' === $label && null === $source ) {
					$source = $row['raw'];
				} elseif ( 'medium' === $label && null === $medium ) {
					$medium = $row['raw'];
				}
				continue;
			}
			$rest[] = $row['raw'];
		}

		if ( null === $source || null === $medium ) {
			$fallback = self::parse_parameter_aliases( $defaults['parameter_aliases'] );
			if ( null === $source ) {
				$source = 'source : ' . implode( ', ', $fallback['standard']['source'] );
			}
			if ( null === $medium ) {
				$medium = 'medium : ' . implode( ', ', $fallback['standard']['medium'] );
			}
		}

		return implode( "\n", array_merge( array( $source, $medium ), $rest ) );
	}

	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',
			'journey', '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( 'journey', 'summary', 'summary_detailed', 'events_list' );
	}

	/**
	 * Human labels for datapoints, used in the settings UI and the journey report.
	 * The report should never show a raw merge-tag name to a marketer.
	 */
	public static function handoff_datapoint_label( $point ) {
		$labels = array(
			'channel'          => __( 'Channel', 'bw-lead-ai' ),
			'source'           => __( 'Source', 'bw-lead-ai' ),
			'medium'           => __( 'Medium', 'bw-lead-ai' ),
			'source_medium'    => __( 'Source / Medium', 'bw-lead-ai' ),
			'campaign'         => __( 'Campaign', 'bw-lead-ai' ),
			'term'             => __( 'Search term', 'bw-lead-ai' ),
			'content'          => __( 'Ad content', 'bw-lead-ai' ),
			'adgroup'          => __( 'Ad group', 'bw-lead-ai' ),
			'first_channel'    => __( 'First channel', 'bw-lead-ai' ),
			'first_source'     => __( 'First source', 'bw-lead-ai' ),
			'first_medium'     => __( 'First medium', 'bw-lead-ai' ),
			'first_page'       => __( 'First page visited', 'bw-lead-ai' ),
			'last_page'        => __( 'Landing page', 'bw-lead-ai' ),
			'visits'           => __( 'Visits', 'bw-lead-ai' ),
			'pages'            => __( 'Pages viewed', 'bw-lead-ai' ),
			'tagged_visits'    => __( 'Campaign visits', 'bw-lead-ai' ),
			'events'           => __( 'Interactions', 'bw-lead-ai' ),
			'events_list'      => __( 'Interactions breakdown', 'bw-lead-ai' ),
			'journey'          => __( 'Full browsing history', 'bw-lead-ai' ),
			'summary'          => __( 'Written summary', 'bw-lead-ai' ),
			'summary_detailed' => __( 'Written summary (detailed)', 'bw-lead-ai' ),
			'ga_client_id'     => __( 'Google Analytics client ID', 'bw-lead-ai' ),
		);
		return isset( $labels[ $point ] ) ? $labels[ $point ] : $point;
	}

	/**
	 * Datapoints that are STORED — what the logged-in journey report can show.
	 */
	public static function parse_handoff_datapoints( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return self::filter_datapoint_list( $settings['handoff_datapoints'] );
	}

	/**
	 * Datapoints the third-party destination may CLAIM — always a subset of the
	 * stored list. Kept separate so the full history can live in the viewer
	 * without ever being handed across origins.
	 */
	public static function parse_handoff_share_datapoints( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$stored   = self::parse_handoff_datapoints( $settings );
		$share    = self::filter_datapoint_list( $settings['handoff_share_datapoints'] );
		return array_values( array_intersect( $share, $stored ) );
	}

	/**
	 * `datapoint:field_name, other:another_field` -> array( datapoint => field ).
	 *
	 * The field name is restricted to what can sit inside a `[name="..."]`
	 * selector in generated JavaScript. It is validated on the way IN rather than
	 * escaped on the way out, because the only consumer echoes it into a <script>
	 * block: rejecting an exotic field name costs a rename, and getting the
	 * escaping subtly wrong costs script injection on someone's form page.
	 */
	public static function parse_field_map( $raw ) {
		$out = array();
		foreach ( array_filter( array_map( 'trim', explode( ',', (string) $raw ) ) ) as $pair ) {
			$bits = explode( ':', $pair, 2 );
			if ( count( $bits ) !== 2 ) {
				continue;
			}
			$key  = sanitize_key( trim( $bits[0] ) );
			$name = trim( $bits[1] );
			if ( '' === $key || '' === $name || strlen( $name ) > 100 ) {
				continue;
			}
			if ( preg_match( '/^[A-Za-z0-9_\-\[\]\. ]+$/', $name ) ) {
				$out[ $key ] = $name;
			}
		}
		return $out;
	}

	/**
	 * One form control's `name`, restricted to what may sit inside a generated
	 * `[name="..."]` selector. Validated on the way in rather than escaped on the
	 * way out, because the only consumer echoes it into a <script> block.
	 */
	public static function sanitize_form_field_name( $raw ) {
		$raw = trim( (string) $raw );
		if ( '' === $raw || strlen( $raw ) > 100 ) {
			return '';
		}
		return preg_match( '/^[A-Za-z0-9_\-\[\]\. ]+$/', $raw ) ? $raw : '';
	}

	public static function serialize_field_map( $map ) {
		$parts = array();
		foreach ( (array) $map as $key => $name ) {
			$parts[] = sanitize_key( $key ) . ':' . $name;
		}
		return implode( ', ', $parts );
	}

	private static function sanitize_field_map( $raw ) {
		return self::serialize_field_map( self::parse_field_map( $raw ) );
	}

	private static function filter_datapoint_list( $raw ) {
		$available = self::handoff_available_datapoints();
		$out       = array();
		foreach ( array_map( 'trim', explode( ',', (string) $raw ) ) 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;
	}

	/**
	 * The other addresses this same site answers on.
	 *
	 * Not destinations — these are aliases for the site the plugin is installed on.
	 * The generated destination script carries this list, its own address first, and
	 * calls whichever one the visitor arrived from. That is what lets one copy of the
	 * code work while a site is still on a staging hostname and again after it
	 * launches, instead of needing to be regenerated and re-pasted on a day nobody
	 * has a reason to remember.
	 *
	 * Anything that cannot be reduced to a plain `scheme://host[:port]` is dropped.
	 * The list IS the security boundary — the script never calls an origin that is
	 * not on it, whatever the referrer claims — so a value that cannot be validated
	 * has no business being emitted at all.
	 */
	public static function parse_handoff_self_origins( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$raw      = isset( $settings['handoff_self_origins'] ) ? $settings['handoff_self_origins'] : '';
		// Newlines count as separators here as well as commas. The field is a
		// textarea and the sanitizer folds it to a comma list on the way in, but
		// this is also what the SANITIZER calls to do that folding — so reading it
		// cannot be allowed to depend on having already been through it.
		$raw      = str_replace( array( "\r\n", "\n", "\r" ), ',', (string) $raw );
		$out      = array();
		foreach ( self::parse_csv_list( $raw ) as $item ) {
			$origin = self::normalize_self_origin( $item );
			if ( '' !== $origin && ! in_array( $origin, $out, true ) ) {
				$out[] = $origin;
			}
		}
		return $out;
	}

	/**
	 * One entry of the self-origin list, reduced to a bare origin or rejected.
	 *
	 * Stricter than normalize_origin(): only http/https and a plain hostname with an
	 * optional port survive, because the result is interpolated into a script.
	 */
	public static function normalize_self_origin( $value ) {
		$origin = self::normalize_origin( $value );
		return preg_match( '#^https?://[a-z0-9.\-]+(:[0-9]{1,5})?$#', $origin ) ? $origin : '';
	}

	/**
	 * 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'] );
	}

	/**
	 * Whether to record who a visitor turned out to be.
	 *
	 * This is the only switch in the plugin that causes personal data to be stored,
	 * so it is off by default and gated on handoff being on — there is nowhere to
	 * attach an identity otherwise.
	 */
	public static function capture_identity( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		// Not gated on handoff_enabled(): whether a journey carries a name is a
		// property of the journey, not of cross-domain delivery. Gating it here
		// meant a site whose forms live on its own domain could hold identities
		// and never display them — the browse list hid its "Who" column on the
		// strength of a cross-domain setting that site will never turn on.
		// BW_Lead_AI_Identity::register() keeps its own handoff gate, so nothing
		// new is hooked or captured by this.
		return ! empty( $settings['capture_identity'] );
	}

	/**
	 * When a journey is saved — promoted from a provisional held record to one
	 * that is kept.
	 *
	 * 'submission' — a Gravity Forms submission on THIS site saves it. The site
	 *                is its own destination; no cross-domain machinery involved.
	 * 'confirm'    — the cross-domain destination's confirmation ping saves it,
	 *                which is the only behaviour that existed before this
	 *                setting. Requires a destination to be configured or nothing
	 *                ever fires the save.
	 *
	 * Nothing stored means decided automatically: a site with a live cross-domain
	 * destination converts over there, so its confirm is the save signal; a site
	 * without one converts on its own forms. An explicit stored choice always
	 * wins — configuring a destination later flips the automatic default but
	 * never a choice someone actually made (see sanitize(), which only stores a
	 * value that differed from the automatic one at the time it was saved).
	 *
	 * The two are not exclusive: with handoff live, the confirm endpoint saves in
	 * either mode. This setting decides whether an on-site submission ALSO saves.
	 */
	public static function journey_save_mode( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$explicit = isset( $settings['journey_save_mode'] ) ? (string) $settings['journey_save_mode'] : '';
		if ( in_array( $explicit, self::JOURNEY_SAVE_MODES, true ) ) {
			return $explicit;
		}
		return self::handoff_enabled( $settings ) ? 'confirm' : 'submission';
	}

	/**
	 * How a saved journey may change after it is saved.
	 *
	 * 'snapshot' — never touched again: the record is how they converted.
	 * 'visit'    — keeps updating until the visit the save belongs to ends (30
	 *              minutes of inactivity, the same boundary capture.js uses),
	 *              then freezes. This is what catches the pages a form redirects
	 *              to — a thank-you page, and the thank-you after the thank-you.
	 * 'growing'  — keeps accreting every time the same person returns. This is
	 *              the old "Keep growing" (continuity) behaviour.
	 *
	 * Nothing stored means mapped from what the site actually did before the
	 * three-way existed: "keep growing" ticked WAS growing; a site that was
	 * storing journeys (handoff live) with it off never updated one after saving
	 * — a snapshot, preserved as such; a site storing nothing server-side had no
	 * behaviour to preserve, so it gets the new default, 'visit'.
	 */
	public static function journey_update_mode( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$explicit = isset( $settings['journey_update_mode'] ) ? (string) $settings['journey_update_mode'] : '';
		if ( in_array( $explicit, self::JOURNEY_UPDATE_MODES, true ) ) {
			return $explicit;
		}
		if ( ! empty( $settings['continuity_enabled'] ) ) {
			return 'growing';
		}
		return self::handoff_enabled( $settings ) ? 'snapshot' : 'visit';
	}

	/**
	 * Whether this site stores journeys server-side at all.
	 *
	 * The gate for the journeys table, the token endpoint, the form stamping and
	 * the submission hook. True for every site except one explicitly set to
	 * confirm-only with no live destination — a state in which nothing could
	 * ever fire the save, so the machinery would only ever hold and expire.
	 *
	 * This used to be handoff_enabled(), which meant a site whose forms live on
	 * its own domain — no destination to hand anything to — stored nothing: every
	 * submission stamped its entry and left no journey behind it.
	 */
	public static function journey_storage_enabled( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return self::handoff_enabled( $settings ) || 'submission' === self::journey_save_mode( $settings );
	}

	/**
	 * Whether anything may append to a saved journey — the gate for the beacon
	 * route, the visitor-key cookie and the key binding on new records. Which
	 * appends are then actually accepted is the update mode's decision, enforced
	 * where the append lands (the beacon endpoint).
	 */
	public static function journey_updates_enabled( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		return self::journey_storage_enabled( $settings ) && 'snapshot' !== self::journey_update_mode( $settings );
	}

	/**
	 * Legacy alias: "continuity" now means the growing end of the three-way
	 * journey_update_mode. Kept because the name is part of the plugin's public
	 * vocabulary; new code should ask journey_update_mode() directly.
	 */
	public static function continuity_enabled( $settings = null ) {
		return 'growing' === self::journey_update_mode( $settings );
	}

	/** Whether to keep the whole submitted form alongside the journey. */
	public static function capture_submission( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		// Gated on storage, not on handoff: a submission needs a stored journey
		// to be attached to, and since storage ungated from cross-domain config
		// an on-site form qualifies exactly as a destination's confirm does.
		return ! empty( $settings['capture_submission'] ) && self::journey_storage_enabled( $settings );
	}

	/** 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', ... ] ], ... ]
	 */
	/**
	 * This site's own hostname, lowercased. '' when it cannot be determined.
	 *
	 * **THE FULL HOSTNAME, NEVER THE REGISTRABLE DOMAIN.** `clientsite.example-host.info`,
	 * not `example-host.info`. This is exactly the line someone later "improves" by
	 * reducing to the parent so that `*.{this-domain}` also covers a www sibling. It
	 * must not be done, and the reason is not stylistic.
	 *
	 * Sites are routinely siblings under a shared parent: a hosting platform's demo
	 * domain, an agency's staging domain, a company's product domains, a country-code
	 * second level like `.co.uk`. Reduce to the parent and `*.{this-domain}` silently
	 * declares every unrelated neighbour a property this site owns — their referrals
	 * stop counting as acquisitions and are filed as "Own Property" instead, so a real
	 * source of leads disappears into a bucket labelled "us". Nothing errors and the
	 * totals stay plausible, which is why it has to be refused here rather than
	 * noticed later. The identical hazard was rejected once already in this plugin's
	 * cookie scoping.
	 *
	 * tests/owned-hosts.php fails if this ever starts returning a parent domain.
	 */
	public static function own_hostname() {
		return strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
	}

	/**
	 * Expand `{this-domain}` inside one configured hostname value.
	 *
	 * Case is otherwise left alone: these values are echoed back as the stored
	 * `source` when a rule matches, and quietly lowercasing them would rewrite that
	 * string on every journey the next reprocess touched.
	 */
	public static function expand_host_value( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value || false === stripos( $value, self::THIS_DOMAIN_TOKEN ) ) {
			return $value;
		}
		$own = self::own_hostname();
		// No hostname to put there: drop the value rather than leave a rule that
		// matches on a literal brace.
		return ( '' === $own ) ? '' : str_ireplace( self::THIS_DOMAIN_TOKEN, $own, $value );
	}

	/**
	 * Does a hostname belong to a configured value? The canonical matcher.
	 *
	 * Three shapes, all of which people write:
	 *   - `openai.com` — a value containing a dot matches the host exactly or as a
	 *     suffix, so it also covers `chat.openai.com`.
	 *   - `google` — a bare value matches any whole dot-separated label, so it covers
	 *     `www.google.com` and `google.co.uk` but never `notgoogle.com`. Matching on a
	 *     plain "ends with" is what broke this for every site before 1.2.1.
	 *   - `*.example.com` — subdomains ONLY: `get.example.com` matches, the bare
	 *     `example.com` does not. It is the only one of the three that can EXCLUDE
	 *     something, which is what lets `internal : *.{this-domain}` say "everything
	 *     under me" without also being a statement about me.
	 *
	 * `assets/js/capture.js` carries the same three cases in its own hostMatches() —
	 * the browser cannot call this. Change one, change both; tests/acceptance.js
	 * covers the JS side.
	 */
	public static function host_matches( $host, $value ) {
		$h = strtolower( trim( (string) $host ) );
		$s = strtolower( trim( (string) $value ) );
		if ( '' === $h || '' === $s ) {
			return false;
		}

		if ( 0 === strpos( $s, '*.' ) ) {
			$base = trim( substr( $s, 2 ), " \t\n\r\0\x0B." );
			if ( '' === $base ) {
				return false;
			}
			return strlen( $h ) > strlen( $base ) && substr( $h, -( strlen( $base ) + 1 ) ) === '.' . $base;
		}

		$s = trim( $s, " \t\n\r\0\x0B." );
		if ( '' === $s ) {
			return false;
		}
		if ( $h === $s ) {
			return true;
		}
		if ( false !== strpos( $s, '.' ) ) {
			return strlen( $h ) > strlen( $s ) && substr( $h, -( strlen( $s ) + 1 ) ) === '.' . $s;
		}
		return in_array( $s, explode( '.', $h ), true );
	}

	/**
	 * One `internal` value, normalised to a pattern safe to treat as ours — or ''.
	 *
	 * Anything not plausibly a hostname is refused, and that is a security decision
	 * rather than tidiness. This list is not only a classification input: arriving
	 * from a host on it is the credential the `bwlai_ref` spoof gate checks (see
	 * ownedPropertyRef() in assets/js/capture.js), so a value broad enough to match a
	 * stranger's host would let that stranger claim any source they liked.
	 */
	private static function owned_pattern( $value ) {
		$value = strtolower( self::expand_host_value( $value ) );
		$wild  = ( 0 === strpos( $value, '*.' ) );
		$base  = trim( $wild ? substr( $value, 2 ) : $value, " \t\n\r\0\x0B." );
		if ( '' === $base || ! preg_match( '/^[a-z0-9.\-]+\.[a-z]{2,}$/', $base ) ) {
			return '';
		}
		return $wild ? '*.' . $base : $base;
	}

	/**
	 * `referrer_classification` as the plugin reads it, with the `internal` row
	 * guaranteed to exist and to carry anything the retired `self_referral_hosts`
	 * setting was holding.
	 *
	 * **Why derived at read time rather than migrated into the database.** Rewriting
	 * a site's saved settings on upgrade is a change nobody asked for, made while
	 * nobody is looking, to the one field a site owner is most likely to have hand
	 * tuned. Deriving it instead means the Settings screen shows the merged row, the
	 * owner can see exactly what they are getting, and their next save is what makes
	 * it permanent — by their hand, with the result on screen in front of them.
	 *
	 * **What it does, precisely:**
	 *  - No `internal` row in the stored text at all — every site configured before
	 *    this existed — gets one, `internal : *.{this-domain}`, first. That is not a
	 *    new rule: it is the rule that was already running invisibly ("…and anything
	 *    under our own hostname"), written down. Nothing a site matched yesterday
	 *    stops matching today.
	 *  - Hostnames in `self_referral_hosts` that the row does not ALREADY cover are
	 *    appended to it, exactly as typed. "Already cover" is decided by the same
	 *    matcher everything else uses, so `get.example.com` is skipped when the row
	 *    says `*.example.com`, rather than added a second time in another shape.
	 *  - An `internal` row that exists and covers everything is returned untouched,
	 *    which makes a second call a no-op and a saved result stable.
	 *
	 * An `internal` row deliberately left EMPTY is honoured: it counts as present, so
	 * nothing is prepended, and it yields no patterns. That is the escape hatch for a
	 * site that really does want its own subdomains treated as separate sources.
	 */
	public static function referrer_classification_text( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$text     = isset( $settings['referrer_classification'] ) ? (string) $settings['referrer_classification'] : '';

		// Scanned from raw lines rather than parse_labeled_list(), which drops a row
		// with no values — and "the row is there but empty" is a meaningful state here.
		$lines    = preg_split( '/\r\n|\r|\n/', $text );
		$row_index = -1;
		$existing  = array();
		foreach ( $lines as $i => $line ) {
			$pos = self::find_label_separator( $line );
			if ( false === $pos || self::OWNED_MEDIUM !== sanitize_key( trim( substr( $line, 0, $pos ) ) ) ) {
				continue;
			}
			if ( $row_index < 0 ) {
				$row_index = $i;
			}
			foreach ( array_map( 'trim', explode( ',', substr( $line, $pos + 1 ) ) ) as $value ) {
				$pattern = self::owned_pattern( $value );
				if ( '' !== $pattern ) {
					$existing[] = $pattern;
				}
			}
		}

		// A site with no row was relying on the invisible rule, and the invisible rule
		// said exactly this. The expanded form goes into $existing too, so the
		// coverage test below is asked about what the new row will really match
		// rather than about a token.
		$seed = array();
		if ( $row_index < 0 ) {
			$seed[]   = '*.' . self::THIS_DOMAIN_TOKEN;
			$expanded = self::owned_pattern( $seed[0] );
			if ( '' !== $expanded ) {
				$existing[] = $expanded;
			}
		}

		$missing = array();
		foreach ( preg_split( '/[\r\n,]+/', isset( $settings['self_referral_hosts'] ) ? (string) $settings['self_referral_hosts'] : '' ) as $legacy ) {
			$host = self::owned_pattern( $legacy );
			if ( '' === $host || in_array( $host, $missing, true ) ) {
				continue;
			}
			if ( ! self::is_owned_host( $host, $existing ) ) {
				$missing[] = $host;
			}
		}

		if ( $row_index < 0 ) {
			array_unshift( $lines, self::OWNED_MEDIUM . ' : ' . implode( ', ', array_merge( $seed, $missing ) ) );
			return trim( implode( "\n", $lines ) );
		}

		if ( empty( $missing ) ) {
			return $text;
		}

		$line = rtrim( $lines[ $row_index ] );
		$pos  = self::find_label_separator( $line );
		$glue = ( '' === trim( substr( $line, $pos + 1 ) ) ) ? ' ' : ', ';
		$lines[ $row_index ] = $line . $glue . implode( ', ', $missing );

		return implode( "\n", $lines );
	}

	/**
	 * Every hostname pattern this site owns, from the `internal` row.
	 *
	 * The site's own hostname is NOT in here, and that is deliberate rather than an
	 * omission — is_owned_host() matches it exactly, on its own, because "this site is
	 * this site" is not configuration. Everything a rule could reasonably disagree
	 * about — the subdomains, the landing-page platform, the booking tool — is in the
	 * row, where it can be read and edited.
	 *
	 * Three consumers, and all of them read this and nothing else: the capture
	 * script's owned-host test, the shared-property snippet's OWNED array, and
	 * BW_Lead_AI_Reprocess when it re-derives stored journeys.
	 */
	public static function owned_hosts( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$out      = array();

		foreach ( self::parse_referrer_classification( self::referrer_classification_text( $settings ) ) as $rule ) {
			if ( self::OWNED_MEDIUM !== $rule['medium'] ) {
				continue;
			}
			foreach ( $rule['sources'] as $value ) {
				$pattern = self::owned_pattern( $value );
				if ( '' !== $pattern && ! in_array( $pattern, $out, true ) ) {
					$out[] = $pattern;
				}
			}
		}

		return $out;
	}

	/**
	 * Is this hostname one of ours rather than a source that found us?
	 *
	 * @param string     $host  The referring hostname.
	 * @param array|null $owned From owned_hosts(); resolved when null.
	 */
	public static function is_owned_host( $host, $owned = null ) {
		$host = strtolower( trim( (string) $host ) );
		if ( '' === $host ) {
			return false;
		}
		// Matched EXACTLY, not as a suffix. What is under this hostname is owned
		// because the `internal` row says so, which is the whole point of the row:
		// a line you can read and change, not a rule hidden in the code.
		$own = self::own_hostname();
		if ( '' !== $own && $host === $own ) {
			return true;
		}
		$owned = ( null === $owned ) ? self::owned_hosts() : (array) $owned;
		foreach ( $owned as $pattern ) {
			if ( self::host_matches( $host, $pattern ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * The rules a referring host is CLASSIFIED against — every row except `internal`.
	 *
	 * The `internal` row lists hostnames the site owns, and ownership is settled
	 * before classification in both engines: capture.js returns early for an owned
	 * host, and BW_Lead_AI_Reprocess::derive() folds one to the `internal` medium in
	 * step 1. So an `internal` rule could never fire as a classifier anyway — but
	 * leaving it in this list would still change something, because Reprocess reads
	 * the set of mediums a rule CAN produce to tell an arrival that carried tracking
	 * parameters from one that was merely classified. Adding `internal` to that set
	 * would silently re-bound visits on exactly the journeys this row is about.
	 * One concept, one path: ownership via owned_hosts(), classification via this.
	 */
	public static function classification_rules( $settings = null ) {
		$settings = ( null === $settings ) ? self::get() : $settings;
		$out      = array();
		foreach ( self::parse_referrer_classification( self::referrer_classification_text( $settings ) ) as $rule ) {
			if ( self::OWNED_MEDIUM !== $rule['medium'] ) {
				$out[] = $rule;
			}
		}
		return $out;
	}

	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;
			}
			$sources = array();
			foreach ( $row['values'] as $value ) {
				$value = self::expand_host_value( $value );
				if ( '' !== $value ) {
					$sources[] = $value;
				}
			}
			if ( empty( $sources ) ) {
				continue;
			}
			$out[] = array( 'medium' => $medium, 'sources' => $sources );
		}
		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.
	 */
	/**
	 * Default referrer classification.
	 *
	 * **`ai` is deliberately first.** Rules are walked in order and the first match
	 * wins, and a bare value like `google` matches any whole label — so it also
	 * matches `gemini.google.com`. With `organic` first, every AI-assistant
	 * referral from a Google property would be filed as organic search. Ordering
	 * resolves that without narrowing the `google` rule, which would be the wrong
	 * trade: restricting it to `www.` subdomains would stop matching `google.com`
	 * and every country domain like `google.co.uk`.
	 *
	 * `email` is above `organic` for the same reason, and it is not hypothetical:
	 * a click from Gmail arrives as `mail.google.com`, and from the Gmail Android
	 * app as `com.google.android.gm` — both contain the label `google`, so both
	 * were being counted as organic search. Someone opening your newsletter is not
	 * someone finding you on Google, and the difference matters to whoever is
	 * deciding where to spend.
	 *
	 * AI assistants now send real referral traffic and it is worth seeing as its
	 * own medium rather than buried in `referral` or miscounted as search.
	 *
	 * **`internal` is first, and it is not an ordinary rule.** Its values are the
	 * hostnames this site owns — read by owned_hosts(), not by the classifier — and it
	 * leads because "this arrival is ours" has to be settled before anything tries to
	 * name a source for it. It is written here rather than derived in code so that the
	 * answer to "which hosts count as mine?" is one line a site owner can read and
	 * edit, instead of a separate list plus a rule about subdomains they never saw.
	 * `{this-domain}` resolves to this site's own hostname, so the line survives being
	 * copied between staging, live and a client's own domain.
	 */
	public static function default_referrer_classification_text() {
		return implode( "\n", array(
			'internal: *.' . self::THIS_DOMAIN_TOKEN,
			'ai      : chatgpt.com, openai.com, gemini.google.com, claude.ai, perplexity.ai, copilot.microsoft.com, copilot.com',
			'email   : mail.google.com, com.google.android.gm, mail.yahoo.com, outlook.live.com, outlook.office.com, outlook.office365.com, mail.proton.me, mail.aol.com, webmail.bell.net',
			'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',
			// Named individually before the catch-all so the report distinguishes
			// which assistant sent the traffic; `AI Assistant : */ai` then covers
			// whatever appears next without needing a settings change.
			'ChatGPT : chatgpt.com/ai, openai.com/ai',
			'Google Gemini : gemini.google.com/ai',
			'Claude : claude.ai/ai',
			'Perplexity : perplexity.ai/ai',
			'Microsoft Copilot : copilot.microsoft.com/ai, copilot.com/ai',
			'AI Assistant : */ai',
			'Email : */email, */newsletter, */e-mail',
			'Social : */social',
			'Display : */display, */banner',
			'Affiliate : */affiliate',
			// "Untracked", not "Direct". GA4's "Direct" implies somebody typed the
			// address, but the bucket is really every arrival that carried no
			// source information — untagged email clicks, apps that strip the
			// referrer, bookmarks, AND the typed-in case, with no way to tell them
			// apart. Naming it for the mechanism (nothing was trackable) stops a
			// reader inventing a story the data does not contain.
			'Untracked : (direct)/(none)',
			// A visitor whose journey began on one of the site's own properties —
			// a landing page, a booking tool — where the true source could not be
			// seen. Split out of Untracked because the owner can DO something
			// about this one: install the shared-property snippet and the real
			// source starts travelling across.
			'Own Property : */internal',
			'{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;
				}
				// `{this-domain}` works here exactly as it does in the medium rules —
				// a channel rule naming one of your own hosts (`get.{this-domain}/internal`
				// to tell one owned property from the rest) must survive the site being
				// cloned to another hostname, same as `internal : *.{this-domain}` does.
				//
				// Expanded AFTER the empty-means-any default, never before: if there is
				// no hostname to substitute, expand_host_value() returns '' and the rule
				// then matches nothing. Expanding first would turn that same failure into
				// '*' — a rule that silently claims EVERY source. Fail closed, not open.
				$patterns[] = array(
					'source' => self::expand_host_value( '' === $pieces[0] ? '*' : $pieces[0] ),
					'medium' => self::expand_host_value( '' === $pieces[1] ? '*' : $pieces[1] ),
				);
			}
			if ( empty( $patterns ) ) {
				continue;
			}
			$rules[] = array(
				'label'    => $row['label'],
				'patterns' => $patterns,
			);
		}
		return $rules;
	}

	/**
	 * Default channel groupings.
	 *
	 * Every member name below is a label that default_channels_text() actually
	 * produces — a group listing a channel that does not exist silently groups
	 * nothing, and the two lists drifting apart is the one failure mode here that
	 * looks like working software. Keep them in step.
	 *
	 * The last row is load-bearing, not decoration: `{bw:channel} : *` makes every
	 * channel nobody grouped into a group of its own, so the grouped table still
	 * adds up to the same total as the ungrouped one. Delete it and the ungrouped
	 * channels vanish from the grouped view instead of standing on their own.
	 */
	public static function default_channel_groups_text() {
		return implode( "\n", array(
			'Paid : Google Ads, Bing Ads, Facebook Ads, Instagram Ads, LinkedIn Ads, TikTok Ads, YouTube Ads, Twitter Ads, Display',
			'Organic Search : Google Organic, Bing Organic, DuckDuckGo, Yahoo, Organic Search',
			'AI : ChatGPT, Google Gemini, Claude, Perplexity, Microsoft Copilot, AI Assistant',
			'{bw:channel} : *',
		) );
	}

	/**
	 * Parse channel groupings into an ordered list of rules.
	 *
	 * Returns: [ [ 'label' => '...', 'members' => [ '...', ... ] ], ... ]
	 *
	 * Same shape and same idioms as parse_channels(): one rule per line, walked in
	 * order, `*` matching anything. The difference is what it matches on — a
	 * finished channel LABEL rather than a source/medium pair — which is why this
	 * is a display concern and parse_channels() is a derivation one. Nothing here
	 * is ever written to a journey.
	 */
	public static function parse_channel_groups( $text ) {
		$rules = array();
		foreach ( self::parse_labeled_list( $text ) as $row ) {
			$rules[] = array(
				'label'   => $row['label'],
				'members' => $row['values'],
			);
		}
		return $rules;
	}

	/**
	 * The group one channel label belongs to, under an ordered rule list.
	 *
	 * First match wins and matching is case-insensitive, exactly as channel rules
	 * are matched. `*` as a member matches any channel.
	 *
	 * `{bw:channel}` in a group name is replaced with the channel's own label — the
	 * same convention `{bw:source}` uses in the channel rules, and what makes a
	 * catch-all row leave ungrouped channels standing on their own. A bare
	 * `{channel}` is accepted as an alias: it is the form people reach for first,
	 * and rejecting it would produce a table where every ungrouped channel is
	 * literally named "{channel}".
	 *
	 * A channel no rule claims is its own group. That is the same fallback shape as
	 * channel_label()'s raw `source / medium`: the honest answer when configuration
	 * has nothing to say is the thing itself, never nothing.
	 *
	 * @param string $channel The channel label as stored on the journey.
	 * @param array  $groups  From parse_channel_groups().
	 * @return string '' only when $channel is ''.
	 */
	public static function channel_group_label( $channel, $groups ) {
		$channel = (string) $channel;
		if ( '' === $channel ) {
			return '';
		}
		foreach ( (array) $groups as $rule ) {
			if ( empty( $rule['members'] ) ) {
				continue;
			}
			foreach ( $rule['members'] as $member ) {
				$member = trim( (string) $member );
				if ( '*' !== $member && strtolower( $member ) !== strtolower( $channel ) ) {
					continue;
				}
				return str_replace(
					array( '{bw:channel}', '{channel}' ),
					array( $channel, $channel ),
					(string) $rule['label']
				);
			}
		}
		return $channel;
	}
}
