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

/**
 * A guided setup for cross-domain handoff, and the generator for the code the
 * destination site has to run.
 *
 * **Why this exists.** The Handoff tab is a correct set of controls and a poor way
 * to be told what to do. It presents modes, origins, two datapoint lists, a token
 * parameter and a lifetime all at once, and then hands over four bare API URLs
 * labelled "Claim the data" and "Journey link to store" — which do not mean
 * anything to someone who has not read the design. Worst of all it never produced
 * the one artefact the job actually needs: **the script to put on the other site.**
 * Every user was expected to write that themselves from four endpoints.
 *
 * So the wizard asks questions that have answers a marketer knows — where is the
 * form, what should happen, what are the fields called, how long to keep a journey —
 * and derives the rest. It covers the whole tab, not just the cross-domain half:
 * sending somebody to another screen for retention mid-flow is how a setup ends up
 * half-finished. The settings screens stay as they are for adjusting one value
 * later; this is the path for setting it up in the first place.
 *
 * Every run goes through the same five steps, and the strip along the top is the way
 * back to any of them that has already been shown. "The fields" is not skipped in
 * journey-link mode: the journey link itself and the optional contact fields are
 * named there, and only the traffic-detail table belongs to sending data across.
 *
 * **The generated snippet deliberately avoids CORS preflight.** Claim is a plain
 * `fetch` with no custom headers, and confirm is a `sendBeacon` carrying a
 * `application/x-www-form-urlencoded` body. Both are then "simple" requests, so no
 * `OPTIONS` round-trip happens at all. A JSON body would work — the allow-list
 * permits `Content-Type` and `OPTIONS` — but it would make the destination's script
 * depend on preflight surviving whatever CDN, WAF or tag manager sits in front of
 * it, and `x-www-form-urlencoded` is safelisted where `application/json` is not.
 * Confirm fires during form submit, when the page is being torn down, and
 * `sendBeacon` is the only mechanism that reliably survives that.
 *
 * **Nothing about the person goes in the URL.** Confirm used to pass the enquirer's
 * name, email and phone as query parameters — which works, and writes all three
 * into the destination's and the origin's web server access logs in plaintext on
 * every submission. Logs are rotated, backed up and shipped around by tooling that
 * has no idea it is carrying personal data, and the exposure long outlives the
 * decision because nobody remembers the logs contain it. It cannot be fixed on the
 * receiving side either: the log line is written before any of that server's code
 * runs. The URL carries the opaque token; everything else is in the body.
 *
 * The server reads either — `WP_REST_Request::get_param()` looks in the body and
 * the query string both — so a snippet already pasted on a destination keeps
 * working until somebody regenerates it. That back-compatibility is asserted in
 * `tests/confirm-privacy.php`, because breaking it would silently stop recording
 * identity on live sites that did nothing wrong.
 */
class BW_Lead_AI_Handoff_Wizard {

	const NONCE = 'bw_lead_ai_handoff_wizard';

	/**
	 * The parameter a property we own uses to hand the original source back.
	 *
	 * `assets/js/capture.js` holds the matching literal — it is a static file with no
	 * localized config, and this value can never change without changing both ends, so
	 * two literals that must agree is the honest shape rather than a setting that
	 * pretends it is adjustable.
	 */
	const SHARED_REF_PARAM = 'bwlai_ref';

	/** Datapoints offered in the field-mapping step, in the order they are shown. */
	const OFFERED = array( 'channel', 'source', 'medium', 'campaign', 'term', 'content', 'first_channel', 'last_page', 'visits', 'pages', 'summary' );

	/** Ticked when the wizard is run for the first time. */
	const SUGGESTED = array( 'channel', 'source', 'medium', 'campaign', 'term' );

	/** Identity fields the destination can report back on submit. */
	const IDENTITY = array( 'email', 'first', 'last', 'phone' );

	private static $instance = null;

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

	/** Is the wizard being asked for? Returns the step number, or 0. */
	public static function requested_step() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- reading a navigation parameter; every step that WRITES verifies its own nonce.
		$raw = isset( $_GET['wizard'] ) ? sanitize_key( wp_unslash( $_GET['wizard'] ) ) : '';
		if ( '' === $raw ) {
			return 0;
		}
		$step = max( 1, min( 5, (int) $raw ) );
		return $step;
	}

	public static function url( $step = 1 ) {
		return add_query_arg(
			array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => 'handoff', 'wizard' => (int) $step ),
			admin_url( 'admin.php' )
		);
	}

	public static function exit_url() {
		return add_query_arg(
			array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => 'handoff' ),
			admin_url( 'admin.php' )
		);
	}

	// --- state ------------------------------------------------------------

	/**
	 * What the wizard knows so far.
	 *
	 * Carried in hidden inputs rather than a transient or the session: a wizard that
	 * stashes half-finished state server-side leaves it behind when someone
	 * navigates away, and two admins configuring at once would overwrite each
	 * other. Hidden inputs make each step's POST self-contained.
	 */
	private function collect( $posted ) {
		$state = array(
			'form_url' => '',
			'mode'     => 'link',
			'fields'     => array(),
			'identity'   => array(),
			'link_field' => '',
			'keep'       => '730',
			'growing'  => 1,
			'named'    => 1,
			'self_origins' => '',
			// The furthest step this run has actually shown. Carried like every other
			// answer, because it is one: it is what makes the step strip a way back
			// without also making it a way to skip a question that has not been asked.
			'reached'  => 1,
		);

		if ( isset( $posted['reached'] ) ) {
			$state['reached'] = min( 5, max( 1, (int) $posted['reached'] ) );
		}
		if ( isset( $posted['form_url'] ) ) {
			$state['form_url'] = esc_url_raw( trim( (string) wp_unslash( $posted['form_url'] ) ) );
		}
		if ( isset( $posted['mode'] ) && 'data' === $posted['mode'] ) {
			$state['mode'] = 'data';
		}
		if ( isset( $posted['fields'] ) && is_array( $posted['fields'] ) ) {
			foreach ( wp_unslash( $posted['fields'] ) as $key => $name ) {
				$key  = sanitize_key( $key );
				$name = $this->sanitize_field_name( $name );
				if ( '' !== $name && in_array( $key, self::OFFERED, true ) ) {
					$state['fields'][ $key ] = $name;
				}
			}
		}
		if ( isset( $posted['link_field'] ) ) {
			$state['link_field'] = $this->sanitize_field_name( wp_unslash( $posted['link_field'] ) );
		}
		if ( isset( $posted['self_origins'] ) ) {
			// Kept as typed here and reduced to origins by the settings sanitizer on
			// the way into the option, so a typo comes back on screen as it was
			// written rather than vanishing without explanation.
			$state['self_origins'] = sanitize_textarea_field( (string) wp_unslash( $posted['self_origins'] ) );
		}
		if ( isset( $posted['keep'] ) ) {
			$keep = sanitize_text_field( (string) wp_unslash( $posted['keep'] ) );
			$state['keep'] = ( 'unlimited' === $keep || ctype_digit( $keep ) ) ? $keep : '730';
		}
		if ( isset( $posted['settings_step'] ) ) {
			// Checkboxes are absent from a POST when unticked, so their value can
			// only be read on the step that actually rendered them.
			$state['growing'] = empty( $posted['growing'] ) ? 0 : 1;
			$state['named']   = empty( $posted['named'] ) ? 0 : 1;
		}
		if ( isset( $posted['identity'] ) && is_array( $posted['identity'] ) ) {
			foreach ( wp_unslash( $posted['identity'] ) as $key => $name ) {
				$key  = sanitize_key( $key );
				$name = $this->sanitize_field_name( $name );
				if ( '' !== $name && in_array( $key, self::IDENTITY, true ) ) {
					$state['identity'][ $key ] = $name;
				}
			}
		}
		return $state;
	}

	/**
	 * A form control's `name` attribute.
	 *
	 * Kept to what can appear in one unquoted, un-escaped position inside the
	 * generated `[name="..."]` selector and inside a JSON string literal. Anything
	 * with a quote, backslash or angle bracket is rejected outright rather than
	 * escaped, because the value is echoed into a <script> block: refusing an
	 * unusual field name costs the user a rename, and getting the escaping subtly
	 * wrong costs them script injection on their own form page.
	 */
	private function sanitize_field_name( $raw ) {
		$raw = trim( (string) $raw );
		if ( '' === $raw || strlen( $raw ) > 100 ) {
			return '';
		}
		return preg_match( '/^[A-Za-z0-9_\-\[\]\. ]+$/', $raw ) ? $raw : '';
	}

	/**
	 * @param array $state The answers to carry.
	 * @param array $skip  Keys this step renders a REAL control for. A hidden input
	 *                     for the same name would be a second value under one name,
	 *                     and which one wins is a browser detail nobody should have
	 *                     to know.
	 */
	private function state_inputs( $state, $skip = array() ) {
		$state = array_merge(
			array( 'form_url' => '', 'mode' => 'link', 'fields' => array(), 'identity' => array(), 'link_field' => '', 'self_origins' => '', 'reached' => 1 ),
			(array) $state
		);
		$out = '<input type="hidden" name="reached" value="' . esc_attr( (int) $state['reached'] ) . '">';
		if ( ! in_array( 'self_origins', $skip, true ) && '' !== trim( (string) $state['self_origins'] ) ) {
			$out .= '<input type="hidden" name="self_origins" value="' . esc_attr( $state['self_origins'] ) . '">';
		}
		if ( ! in_array( 'form_url', $skip, true ) ) {
			$out .= '<input type="hidden" name="form_url" value="' . esc_attr( $state['form_url'] ) . '">';
		}
		if ( ! in_array( 'mode', $skip, true ) ) {
			$out .= '<input type="hidden" name="mode" value="' . esc_attr( $state['mode'] ) . '">';
		}
		if ( ! in_array( 'link_field', $skip, true ) && ! empty( $state['link_field'] ) ) {
			$out .= '<input type="hidden" name="link_field" value="' . esc_attr( $state['link_field'] ) . '">';
		}
		if ( ! in_array( 'fields', $skip, true ) ) {
			foreach ( $state['fields'] as $key => $name ) {
				$out .= '<input type="hidden" name="fields[' . esc_attr( $key ) . ']" value="' . esc_attr( $name ) . '">';
			}
		}
		if ( ! in_array( 'identity', $skip, true ) ) {
			foreach ( $state['identity'] as $key => $name ) {
				$out .= '<input type="hidden" name="identity[' . esc_attr( $key ) . ']" value="' . esc_attr( $name ) . '">';
			}
		}
		return $out;
	}

	// --- rendering --------------------------------------------------------

	public function render( $step ) {
		// Configuring cross-domain handoff decides what leaves this site and where
		// it goes. Squarely the manage capability, never the read one.
		if ( ! BW_Lead_AI_Caps::can_manage() ) {
			wp_die( esc_html__( 'You do not have permission to configure this.', 'bw-lead-ai' ) );
		}

		$posted = array();
		if ( 'POST' === strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '' ) ) {
			check_admin_referer( self::NONCE );
			$posted = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified on the line above; each value is sanitized in collect().
		}
		$state = $this->collect( $posted );
		$error = '';

		// A bare GET — a bookmark, a reload, a browser restoring the tab, or the step
		// strip on the final screen, which has no form to post from. Nothing was
		// answered on this request, so there is nothing to save, and applying the
		// collected (empty) state would write constructor defaults over the real
		// configuration — this path once emptied a live field map. Render from what
		// is already saved instead; a site with no saved destination falls through
		// the form_url guard to step 1.
		$bare       = empty( $posted );
		$bare_final = ( 5 === $step && ( $bare || ! isset( $posted['settings_step'] ) ) );
		if ( $bare_final || ( $bare && $step > 1 ) ) {
			$state = wp_parse_args( self::state_from_settings(), $state );
			if ( '' !== $state['form_url'] ) {
				// Saved settings ARE the whole set of answers, so every step has been
				// through at least once.
				$state['reached'] = 5;
			}
		}

		// Steps past the first need the answer the first one collects.
		if ( $step > 1 && '' === $state['form_url'] ) {
			$step = 1;
		}


		if ( 1 === $step && ! empty( $posted ) && '' === $state['form_url'] ) {
			$error = __( 'That does not look like a web address. Paste the full URL of the page your form is on.', 'bw-lead-ai' );
		}
		if ( $step > 1 ) {
			$host = $this->host_of( $state['form_url'] );
			if ( '' === $host ) {
				$step  = 1;
				$error = __( 'That does not look like a web address. Paste the full URL of the page your form is on.', 'bw-lead-ai' );
			} elseif ( $this->is_this_site( $host ) ) {
				$step  = 1;
				$error = __( 'That form is on this same site, so there is nothing to hand off — the journey is already here. Handoff is only for forms on a different domain.', 'bw-lead-ai' );
			}
		}

		$saved = false;
		if ( 5 === $step && ! $bare_final ) {
			$saved = $this->apply( $state );
		}

		$state['reached'] = max( (int) $state['reached'], $step );

		echo '<div class="bw-wizard">';
		$this->render_steps_nav( $step, $state );
		if ( '' !== $error ) {
			echo '<div class="notice notice-error inline"><p>' . esc_html( $error ) . '</p></div>';
		}

		switch ( $step ) {
			case 2:
				$this->render_step_2( $state );
				break;
			case 3:
				$this->render_step_3( $state );
				break;
			case 4:
				$this->render_step_settings( $state );
				break;
			case 5:
				$this->render_step_4( $state, $saved );
				break;
			default:
				$this->render_step_1( $state );
		}
		echo '</div>';
	}

	/** The id every step's form carries, so the step strip above it can submit it. */
	const FORM_ID = 'bw-lead-ai-wizard-form';

	/** The steps, in order. */
	private function step_list() {
		return array(
			1 => __( 'The form', 'bw-lead-ai' ),
			2 => __( 'What happens', 'bw-lead-ai' ),
			3 => __( 'The fields', 'bw-lead-ai' ),
			4 => __( 'Settings', 'bw-lead-ai' ),
			5 => __( 'Your code', 'bw-lead-ai' ),
		);
	}

	/**
	 * Which steps the strip offers as a way back, and which are inert text.
	 *
	 * A step is offered once it has been shown — that is what `reached` records —
	 * and never before, so the strip cannot be used to skip a question whose answer
	 * a later step depends on. Two exceptions, both because the alternative is a
	 * link that does not do what it says:
	 *
	 * - Nothing past the first step is offered without a destination, because
	 *   render() sends every one of them back to step 1 until there is one.
	 * - "Your code" is only offered from the step before it, which is the one that
	 *   posts the answers. Reached from anywhere else it can only show the code for
	 *   what is already saved, which would silently ignore an edit made on the way.
	 */
	private function step_is_navigable( $n, $current, $state ) {
		if ( $n === $current ) {
			return false;
		}
		if ( $n > (int) $state['reached'] ) {
			return false;
		}
		if ( $n > 1 && '' === $state['form_url'] ) {
			return false;
		}
		return ( $n < 5 ) || ( 4 === $current );
	}

	/**
	 * The step strip, with the steps already seen as controls back to them.
	 *
	 * Steps 1-4 each render a form, so their strip entries are submit buttons
	 * targeting it: going back that way carries everything typed so far instead of
	 * throwing it away, which a plain link would do. The final step has no form —
	 * by then the answers are saved settings — so there the entries are links.
	 *
	 * The empty submit button before the strip exists to keep the Enter key
	 * working. A form's default button is the first submit button in DOCUMENT
	 * order, and the strip sits above the form, so without this one pressing Enter
	 * in a text field would quietly go BACK a step instead of forward.
	 */
	private function render_steps_nav( $current, $state ) {
		$has_form = ( $current < 5 );

		if ( $has_form ) {
			printf(
				'<button type="submit" form="%1$s" formaction="%2$s" class="bw-wizard-default-submit" tabindex="-1" aria-hidden="true"></button>',
				esc_attr( self::FORM_ID ),
				esc_url( self::url( min( 5, $current + 1 ) ) )
			);
		}

		echo '<ol class="bw-wizard-steps">';
		foreach ( $this->step_list() as $n => $label ) {
			$class = 'bw-wizard-step';
			if ( $n === $current ) {
				$class .= ' is-current';
			} elseif ( $n < $current ) {
				$class .= ' is-done';
			}
			$inner = '<span class="bw-wizard-num">' . (int) $n . '</span> ' . esc_html( $label );

			if ( ! $this->step_is_navigable( $n, $current, $state ) ) {
				printf(
					'<li class="%1$s"%2$s>%3$s</li>',
					esc_attr( $class ),
					( $n === $current ) ? ' aria-current="step"' : '',
					$inner // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
				);
				continue;
			}

			/* translators: %s: the name of a wizard step, e.g. "The fields". */
			$title = sprintf( __( 'Go back to: %s', 'bw-lead-ai' ), $label );

			if ( $has_form ) {
				printf(
					'<li class="%1$s is-link"><button type="submit" form="%2$s" formaction="%3$s" class="bw-wizard-step-link" title="%4$s">%5$s</button></li>',
					esc_attr( $class ),
					esc_attr( self::FORM_ID ),
					esc_url( self::url( $n ) ),
					esc_attr( $title ),
					$inner // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
				);
			} else {
				printf(
					'<li class="%1$s is-link"><a class="bw-wizard-step-link" href="%2$s" title="%3$s">%4$s</a></li>',
					esc_attr( $class ),
					esc_url( self::url( $n ) ),
					esc_attr( $title ),
					$inner // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above.
				);
			}
		}
		echo '</ol>';
	}

	private function form_open() {
		echo '<form method="post" action="' . esc_url( self::url( 1 ) ) . '" id="' . esc_attr( self::FORM_ID ) . '" class="bw-wizard-form">';
		wp_nonce_field( self::NONCE );
	}

	// --- step 1 -----------------------------------------------------------

	private function render_step_1( $state ) {
		$existing = BW_Lead_AI_Settings::parse_handoff_domains();
		$prefill  = $state['form_url'];
		if ( '' === $prefill && ! empty( $existing ) ) {
			$prefill = 'https://' . $existing[0] . '/';
		}
		?>
		<h2><?php esc_html_e( 'Where does your form live?', 'bw-lead-ai' ); ?></h2>
		<p class="bw-wizard-lede">
			<?php esc_html_e( 'Paste the address of the page your form is on.', 'bw-lead-ai' ); ?>
		</p>
		<?php $this->form_open(); ?>
			<?php echo $this->state_inputs( $state, array( 'form_url' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from esc_attr'd values. ?>
			<input type="url" name="form_url" class="large-text bw-wizard-input" required
				placeholder="https://portal.example.com/apply"
				value="<?php echo esc_attr( $prefill ); ?>">
			<p class="description">
				<?php esc_html_e( 'Any page on that domain will do. Subdomains are covered automatically.', 'bw-lead-ai' ); ?>
			</p>
			<p class="bw-wizard-actions">
				<button type="submit" formaction="<?php echo esc_url( self::url( 2 ) ); ?>" class="button button-primary button-hero">
					<?php esc_html_e( 'Next', 'bw-lead-ai' ); ?>
				</button>
				<a class="button-link" href="<?php echo esc_url( self::exit_url() ); ?>"><?php esc_html_e( 'Cancel', 'bw-lead-ai' ); ?></a>
			</p>
		</form>
		<?php
	}

	// --- step 2 -----------------------------------------------------------

	private function render_step_2( $state ) {
		$host = $this->host_of( $state['form_url'] );
		?>
		<h2><?php esc_html_e( 'What should happen when they get there?', 'bw-lead-ai' ); ?></h2>
		<p class="bw-wizard-lede">
			<?php
			printf(
				/* translators: %s: the destination hostname. */
				esc_html__( 'A visitor clicks through to %s, but their journey stays on this site. Two ways to join them up:', 'bw-lead-ai' ),
				'<code>' . esc_html( $host ) . '</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
			);
			?>
		</p>
		<?php $this->form_open(); ?>
			<?php echo $this->state_inputs( $state, array( 'mode' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from esc_attr'd values. ?>

			<label class="bw-wizard-choice">
				<input type="radio" name="mode" value="link" <?php checked( 'data' !== $state['mode'] ); ?>>
				<span class="bw-wizard-choice-body">
					<strong><?php esc_html_e( 'Just let me see the journey', 'bw-lead-ai' ); ?></strong>
					<span class="bw-pill"><?php esc_html_e( 'Recommended', 'bw-lead-ai' ); ?></span>
					<span class="bw-wizard-choice-sub">
						<?php esc_html_e( 'The other site stores a link back here. You follow it to read the whole journey — no visitor data leaves this server.', 'bw-lead-ai' ); ?>
					</span>
				</span>
			</label>

			<label class="bw-wizard-choice">
				<input type="radio" name="mode" value="data" <?php checked( 'data' === $state['mode'] ); ?>>
				<span class="bw-wizard-choice-body">
					<strong><?php esc_html_e( 'Also fill in hidden fields on that form', 'bw-lead-ai' ); ?></strong>
					<span class="bw-wizard-choice-sub">
						<?php esc_html_e( 'The other site also fills its own form fields with the details you choose, so the campaign shows up in whatever system that form feeds.', 'bw-lead-ai' ); ?>
					</span>
					<span class="bw-wizard-choice-note">
						<?php esc_html_e( 'This sends visitor data to a third party. Keep it to the minimum, and check whether it belongs in your privacy policy.', 'bw-lead-ai' ); ?>
					</span>
				</span>
			</label>

			<p class="bw-wizard-actions">
				<button type="submit" formaction="<?php echo esc_url( self::url( 3 ) ); ?>" class="button button-primary button-hero">
					<?php esc_html_e( 'Next', 'bw-lead-ai' ); ?>
				</button>
				<button type="submit" formaction="<?php echo esc_url( self::url( 1 ) ); ?>" class="button"><?php esc_html_e( 'Back', 'bw-lead-ai' ); ?></button>
			</p>
		</form>
		<?php
	}

	// --- step 3 -----------------------------------------------------------

	private function render_step_3( $state ) {
		$settings   = BW_Lead_AI_Settings::get();
		$fields     = $state['fields'];
		$identity   = $state['identity'];
		$link_field = $state['link_field'];
		$wants_data = 'data' === $state['mode'];
		if ( empty( $fields ) ) {
			$fields = BW_Lead_AI_Settings::parse_field_map( $settings['handoff_field_map'] );
		}
		if ( empty( $identity ) ) {
			$identity = BW_Lead_AI_Settings::parse_field_map( $settings['handoff_identity_map'] );
		}
		if ( '' === $link_field ) {
			$link_field = (string) $settings['handoff_link_field'];
		}
		$first = empty( $fields );
		?>
		<h2><?php esc_html_e( 'What are the fields on that form called?', 'bw-lead-ai' ); ?></h2>
		<p class="bw-wizard-lede">
			<?php esc_html_e( 'The code needs somewhere to put what it carries — usually hidden fields on the other form.', 'bw-lead-ai' ); ?>
		</p>
		<div class="bw-wizard-hint">
			<?php esc_html_e( 'The field name is the value of the input\'s name attribute — the thing that looks like', 'bw-lead-ai' ); ?>
			<code>&lt;input type="hidden" name="<strong>lead_source</strong>"&gt;</code>.
			<?php esc_html_e( 'If you are not sure, ask whoever manages that form.', 'bw-lead-ai' ); ?>
		</div>

		<?php $this->form_open(); ?>
			<?php echo $this->state_inputs( $state, array( 'fields', 'identity', 'link_field' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from esc_attr'd values. ?>

			<h3><?php esc_html_e( 'The link back to the journey', 'bw-lead-ai' ); ?></h3>
			<div class="description bw-copy">
				<?php esc_html_e( 'This is the one that matters. The code writes a journey link here, and whoever picks up the lead follows it straight to the full journey.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					BW_Lead_AI_Admin::help(
						__( 'The link only opens for someone signed in here, so it is safe to keep in a CRM. Leave it empty if the form has no spare field — the journey still appears under Reports → Journeys.', 'bw-lead-ai' )
					)
				);
				?>
			</div>
			<table class="widefat striped bw-wizard-map">
				<tbody>
					<tr>
						<td><label for="bw-wiz-link"><?php esc_html_e( 'Journey link', 'bw-lead-ai' ); ?></label></td>
						<td>
							<input type="text" id="bw-wiz-link" name="link_field"
								value="<?php echo esc_attr( $link_field ); ?>"
								class="regular-text code" placeholder="<?php esc_attr_e( 'e.g. lead_journey_link', 'bw-lead-ai' ); ?>">
						</td>
					</tr>
				</tbody>
			</table>

			<?php if ( ! $wants_data ) : ?>
				<p class="description bw-copy">
					<?php esc_html_e( 'You chose not to send the traffic details across, so that is the only field the other form needs.', 'bw-lead-ai' ); ?>
				</p>
			<?php endif; ?>

			<?php if ( $wants_data ) : ?>
			<h3><?php esc_html_e( 'The traffic details', 'bw-lead-ai' ); ?></h3>
			<p class="description bw-copy">
				<?php esc_html_e( 'Give the field name each one should go into, or leave it empty to skip.', 'bw-lead-ai' ); ?>
			</p>
			<table class="widefat striped bw-wizard-map">
				<thead><tr>
					<th><?php esc_html_e( 'Detail from the journey', 'bw-lead-ai' ); ?></th>
					<th><?php esc_html_e( 'Goes into this field on their form', 'bw-lead-ai' ); ?></th>
				</tr></thead>
				<tbody>
				<?php foreach ( self::OFFERED as $key ) : ?>
					<?php
					$value = isset( $fields[ $key ] ) ? $fields[ $key ] : '';
					if ( '' === $value && $first && in_array( $key, self::SUGGESTED, true ) ) {
						$value = 'lead_' . $key;
					}
					?>
					<tr>
						<td><label for="bw-wiz-f-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( BW_Lead_AI_Settings::handoff_datapoint_label( $key ) ); ?></label></td>
						<td>
							<input type="text" id="bw-wiz-f-<?php echo esc_attr( $key ); ?>"
								name="fields[<?php echo esc_attr( $key ); ?>]"
								value="<?php echo esc_attr( $value ); ?>"
								class="regular-text code" placeholder="<?php esc_attr_e( 'leave empty to skip', 'bw-lead-ai' ); ?>">
						</td>
					</tr>
				<?php endforeach; ?>
				</tbody>
			</table>
			<?php endif; ?>

			<h3><?php esc_html_e( 'Who filled it in', 'bw-lead-ai' ); ?> <span class="bw-wizard-optional"><?php esc_html_e( 'optional', 'bw-lead-ai' ); ?></span></h3>
			<p class="description bw-copy">
				<?php esc_html_e( 'Name the form\'s contact fields and the code reads them on submit, so the journey has a name. Read from the form, never written to it.', 'bw-lead-ai' ); ?>
			</p>
			<table class="widefat striped bw-wizard-map">
				<tbody>
				<?php
				$id_labels = array(
					'email' => __( 'Email address', 'bw-lead-ai' ),
					'first' => __( 'First name', 'bw-lead-ai' ),
					'last'  => __( 'Last name', 'bw-lead-ai' ),
					'phone' => __( 'Phone', 'bw-lead-ai' ),
				);
				foreach ( self::IDENTITY as $key ) :
					?>
					<tr>
						<td><label for="bw-wiz-i-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $id_labels[ $key ] ); ?></label></td>
						<td>
							<input type="text" id="bw-wiz-i-<?php echo esc_attr( $key ); ?>"
								name="identity[<?php echo esc_attr( $key ); ?>]"
								value="<?php echo esc_attr( isset( $identity[ $key ] ) ? $identity[ $key ] : '' ); ?>"
								class="regular-text code" placeholder="<?php esc_attr_e( 'leave empty to skip', 'bw-lead-ai' ); ?>">
						</td>
					</tr>
				<?php endforeach; ?>
				</tbody>
			</table>

			<p class="bw-wizard-actions">
				<button type="submit" formaction="<?php echo esc_url( self::url( 4 ) ); ?>" class="button button-primary button-hero">
					<?php esc_html_e( 'Next', 'bw-lead-ai' ); ?>
				</button>
				<button type="submit" formaction="<?php echo esc_url( self::url( 2 ) ); ?>" class="button"><?php esc_html_e( 'Back', 'bw-lead-ai' ); ?></button>
			</p>
		</form>
		<?php
	}

	// --- step 4: the rest of the settings, in plain English -----------------

	/**
	 * The questions the settings screen asks in its own vocabulary, asked here in
	 * the user's instead. Retention, continuity and identity capture are not
	 * cross-domain concepts — they apply to every stored journey — but somebody
	 * setting handoff up for the first time still has to answer them, and sending
	 * them off to another tab mid-flow is how a setup gets half-finished.
	 */
	private function render_step_settings( $state ) {
		// On the way IN the answers have not been given yet, so show what the site
		// is currently set to rather than the constructor defaults. "Growing" is
		// the resolved update mode, not the raw legacy flag — a site explicitly
		// set to visit or snapshot must not render as if growing were on.
		if ( ! isset( $_POST['settings_step'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- presence check only; the POST was nonce-verified in render().
			$current          = BW_Lead_AI_Settings::get();
			$state['keep']    = (string) $current['handoff_retention'];
			$state['growing'] = ( 'growing' === BW_Lead_AI_Settings::journey_update_mode( $current ) ) ? 1 : 0;
			$state['named']   = (int) $current['capture_identity'];
			// Except when this run already carries an answer in its hidden inputs —
			// stepping back and forward again must not undo what was typed here. An
			// empty one is not carried, so empty means "not answered on this run".
			if ( '' === trim( (string) $state['self_origins'] ) ) {
				$state['self_origins'] = (string) $current['handoff_self_origins'];
			}
		}
		$keeps = array(
			'90'        => __( '90 days', 'bw-lead-ai' ),
			'365'       => __( '1 year', 'bw-lead-ai' ),
			'730'       => __( '2 years', 'bw-lead-ai' ),
			'1825'      => __( '5 years', 'bw-lead-ai' ),
			'unlimited' => __( 'For as long as I keep them', 'bw-lead-ai' ),
		);
		?>
		<h2><?php esc_html_e( 'A few last settings', 'bw-lead-ai' ); ?></h2>
		<p class="bw-wizard-lede">
			<?php esc_html_e( 'Change any of these later on the settings tabs.', 'bw-lead-ai' ); ?>
		</p>

		<?php $this->form_open(); ?>
			<?php echo $this->state_inputs( $state, array( 'self_origins' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from esc_attr'd values. ?>
			<input type="hidden" name="settings_step" value="1">

			<h3><?php esc_html_e( 'Does this site answer on another address?', 'bw-lead-ai' ); ?></h3>
			<p class="description bw-copy">
				<?php esc_html_e( 'One per line. Your code calls whichever of them the visitor came from, so it keeps working after this site moves to its live address.', 'bw-lead-ai' ); ?>
			</p>
			<textarea name="self_origins" rows="2" class="large-text code"
				placeholder="https://www.example.com"><?php echo esc_textarea( (string) $state['self_origins'] ); ?></textarea>

			<p class="description bw-copy" style="margin-top:24px;">
				<?php esc_html_e( 'The rest apply to every journey this site stores, not just handed-off ones.', 'bw-lead-ai' ); ?>
			</p>

			<h3><?php esc_html_e( 'How long should a journey be kept?', 'bw-lead-ai' ); ?></h3>
			<p class="description bw-copy">
				<?php esc_html_e( 'Counted from the last time you heard from someone, not from when they first enquired — so an active lead is never deleted mid-conversation.', 'bw-lead-ai' ); ?>
			</p>
			<select name="keep" class="bw-wizard-select">
				<?php foreach ( $keeps as $value => $label ) : ?>
					<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $state['keep'], $value ); ?>>
						<?php echo esc_html( $label ); ?>
					</option>
				<?php endforeach; ?>
			</select>

			<h3><?php esc_html_e( 'When someone comes back, what should happen?', 'bw-lead-ai' ); ?></h3>
			<label class="bw-wizard-choice">
				<input type="checkbox" name="growing" value="1" <?php checked( 1, (int) $state['growing'] ); ?>>
				<span class="bw-wizard-choice-body">
					<strong><?php esc_html_e( 'Keep adding to their journey', 'bw-lead-ai' ); ?></strong>
					<span class="bw-wizard-choice-sub">
						<?php esc_html_e( 'On, the journey keeps growing as the same person returns — you see what they did after enquiring, not just before. Off, later visits are not added.', 'bw-lead-ai' ); ?>
					</span>
				</span>
			</label>

			<h3><?php esc_html_e( 'Should journeys have a name on them?', 'bw-lead-ai' ); ?></h3>
			<label class="bw-wizard-choice">
				<input type="checkbox" name="named" value="1" <?php checked( 1, (int) $state['named'] ); ?>>
				<span class="bw-wizard-choice-body">
					<strong><?php esc_html_e( 'Record who enquired', 'bw-lead-ai' ); ?></strong>
					<span class="bw-wizard-choice-sub">
						<?php esc_html_e( 'The report then opens with a person rather than a channel. Name and email are read on your server, never stored in anyone\'s browser.', 'bw-lead-ai' ); ?>
					</span>
					<span class="bw-wizard-choice-note">
						<?php esc_html_e( 'This is the only setting here that stores personal data — names and emails alongside someone\'s browsing. This may belong in your privacy policy.', 'bw-lead-ai' ); ?>
					</span>
				</span>
			</label>

			<p class="bw-wizard-actions">
				<button type="submit" formaction="<?php echo esc_url( self::url( 5 ) ); ?>" class="button button-primary button-hero">
					<?php esc_html_e( 'Generate my code', 'bw-lead-ai' ); ?>
				</button>
				<button type="submit" formaction="<?php echo esc_url( self::url( 3 ) ); ?>" class="button">
					<?php esc_html_e( 'Back', 'bw-lead-ai' ); ?>
				</button>
			</p>
		</form>
		<?php
	}

	// --- final step: the code ---------------------------------------------

	private function render_step_4( $state, $saved ) {
		$host     = $this->host_of( $state['form_url'] );
		$settings = BW_Lead_AI_Settings::get();
		$param    = (string) $settings['handoff_param'];
		$snippet  = self::build_snippet( $state );
		?>
		<h2><?php esc_html_e( 'Done — here is your code', 'bw-lead-ai' ); ?></h2>

		<?php if ( $saved ) : ?>
			<div class="notice notice-success inline"><p>
				<?php
				printf(
					/* translators: %s: the destination hostname. */
					esc_html__( 'Handoff is switched on and configured for %s. Links from this site to that domain now carry a token.', 'bw-lead-ai' ),
					'<code>' . esc_html( $host ) . '</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
				);
				?>
			</p></div>
		<?php endif; ?>

		<p class="bw-wizard-lede">
			<?php
			printf(
				/* translators: %s: the destination hostname. */
				esc_html__( 'Give this to whoever manages %s. It goes just before the closing body tag, or as a tag-manager Custom HTML tag. If that site already has an older Lead AI tag, remove it.', 'bw-lead-ai' ),
				'<code>' . esc_html( $host ) . '</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
			);
			?>
		</p>

		<div class="bw-wizard-code">
			<div class="bw-wizard-code-bar">
				<span><?php esc_html_e( 'Paste this on the destination site', 'bw-lead-ai' ); ?></span>
				<button type="button" class="button button-small bw-lead-ai-copy" data-target="data-wizard-snippet">
					<?php esc_html_e( 'Copy', 'bw-lead-ai' ); ?>
				</button>
			</div>
			<textarea readonly rows="18" class="bw-lead-ai-pre" data-wizard-snippet><?php echo esc_textarea( $snippet ); ?></textarea>
		</div>

		<h3><?php esc_html_e( 'What it does', 'bw-lead-ai' ); ?></h3>
		<ul class="bw-wizard-explain">
			<li><?php esc_html_e( 'Picks up the token this site adds to the link, and remembers it if the form is a few pages further in.', 'bw-lead-ai' ); ?></li>
			<?php if ( 'data' === $state['mode'] && ! empty( $state['fields'] ) ) : ?>
				<li><?php esc_html_e( 'Fetches the details you chose and fills them into the named fields, without overwriting anything already there.', 'bw-lead-ai' ); ?></li>
			<?php endif; ?>
			<li><?php esc_html_e( 'When the form is submitted, tells this site the journey became a real enquiry — which is what saves it.', 'bw-lead-ai' ); ?></li>
		</ul>
		<p class="description bw-copy">
			<?php esc_html_e( 'Ordinary forms report their own submission.', 'bw-lead-ai' ); ?>
			<?php
			echo wp_kses_post(
				BW_Lead_AI_Admin::help(
					__( 'It listens for the browser&#8217;s own submit event. A form that submits purely in JavaScript never reports back — if journeys stay Held after a real enquiry, call <code>navigator.sendBeacon(url)</code> with the confirm URL (Cross-domain tab) from the form&#8217;s success callback.', 'bw-lead-ai' )
				)
			);
			?>
		</p>

		<h3><?php esc_html_e( 'Through Google Tag Manager', 'bw-lead-ai' ); ?></h3>
		<ul class="bw-wizard-explain">
			<li>
				<?php
				echo wp_kses(
					__( 'A <strong>Custom HTML</strong> tag on the <strong>All Pages</strong> trigger.', 'bw-lead-ai' ),
					array( 'strong' => array() )
				);
				?>
			</li>
			<li><?php esc_html_e( 'Do not narrow it to the form page. The token arrives on whichever page the visitor lands on, so it has to fire there too — every page a link from here can reach, and the form page.', 'bw-lead-ai' ); ?></li>
			<li><?php esc_html_e( 'Publish the container. Saving it is not enough.', 'bw-lead-ai' ); ?></li>
		</ul>
		<p class="description bw-copy">
			<?php esc_html_e( 'Tag Manager runs Custom HTML after the page is ready. That is fine — the code handles being loaded either before or after.', 'bw-lead-ai' ); ?>
		</p>

		<h3><?php esc_html_e( 'Check it worked', 'bw-lead-ai' ); ?></h3>
		<ol class="bw-wizard-explain">
			<li>
				<?php
				printf(
					/* translators: %s: the destination hostname. */
					esc_html__( 'Visit a page on this site, then click through to %s.', 'bw-lead-ai' ),
					'<code>' . esc_html( $host ) . '</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
				);
				?>
			</li>
			<li>
				<?php
				printf(
					/* translators: %s: the token parameter name. */
					esc_html__( 'The address bar should have %s on the end.', 'bw-lead-ai' ),
					'<code>?' . esc_html( $param ) . '=…</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
				);
				?>
			</li>
			<li><?php esc_html_e( 'Submit the form, then come back and look at Reports → Journeys — it should be there, saved.', 'bw-lead-ai' ); ?></li>
			<li>
				<?php
				printf(
					/* translators: %s: the debug query parameter. */
					esc_html__( 'If something looks wrong, add %s to the form page&#8217;s address: the hidden fields show with a red outline, and the code logs to the browser console.', 'bw-lead-ai' ),
					'<code>?bwlaidebug=true</code>' // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped inline.
				);
				?>
			</li>
		</ol>

		<p class="bw-wizard-actions">
			<a class="button button-primary button-hero" href="<?php echo esc_url( self::exit_url() ); ?>">
				<?php esc_html_e( 'Finish', 'bw-lead-ai' ); ?>
			</a>
			<a class="button" href="<?php echo esc_url( admin_url( 'admin.php?page=' . BW_Lead_AI_Handoff_Admin::LIST_SLUG ) ); ?>">
				<?php esc_html_e( 'Go to Journeys', 'bw-lead-ai' ); ?>
			</a>
		</p>
		<?php
	}

	// --- applying ---------------------------------------------------------

	/**
	 * Write the answers into the real settings.
	 *
	 * Goes through the normal option so the existing sanitizer still owns every
	 * value — the wizard decides *what* to set, never how a value is validated.
	 */
	private function apply( $state ) {
		$host = $this->host_of( $state['form_url'] );
		if ( '' === $host ) {
			return false;
		}

		$settings = BW_Lead_AI_Settings::get();
		$scheme   = wp_parse_url( $state['form_url'], PHP_URL_SCHEME );
		$scheme   = ( 'http' === $scheme ) ? 'http' : 'https';

		$settings['handoff_enabled']   = 1;
		$settings['handoff_mode_link'] = 1;
		$settings['handoff_domains']   = $this->add_to_list( $settings['handoff_domains'], $host );
		$settings['handoff_origins']   = $this->add_to_list( $settings['handoff_origins'], $scheme . '://' . $host );

		// Replaced rather than added to: this is a list the user just saw in full and
		// edited, so a removed line has to mean removed.
		if ( isset( $state['self_origins'] ) ) {
			$settings['handoff_self_origins'] = (string) $state['self_origins'];
		}

		// The choice itself is recorded either way, so returning to the wizard shows
		// what was chosen. Sending nothing is what an empty field list means, and
		// that is handled below rather than by forgetting the answer.
		$settings['handoff_mode_data'] = ( 'data' === $state['mode'] ) ? 1 : 0;

		if ( 'data' === $state['mode'] && ! empty( $state['fields'] ) ) {
			// The acknowledgement is the point of the warning on the settings
			// screen, and step 2 states the same thing in the option the user
			// actively chose. Ticking it here is recording that choice, not
			// bypassing it — the wizard cannot reach this line otherwise.
			$settings['handoff_ack'] = 1;

			$chosen = array_keys( $state['fields'] );
			$settings['handoff_share_datapoints'] = implode( ', ', $chosen );
			// Anything sent must also be stored, or the claim endpoint filters it
			// straight back out and the wizard would silently produce dead fields.
			$settings['handoff_datapoints'] = implode(
				', ',
				array_unique( array_merge( BW_Lead_AI_Settings::parse_handoff_datapoints(), $chosen ) )
			);
		}

		$settings['handoff_retention'] = $state['keep'];

		// "Keep adding to their journey" is the growing end of the three-way
		// journey_update_mode; the legacy flag is written in step so an older
		// reading of it stays truthful. What the box CANNOT do is silently move
		// a choice the user made on the Journey Storage tab, or let this run's enabling
		// of handoff shift the automatic default underneath them — so the mode
		// is pinned to what was actually in effect when the box was answered:
		// checked pins growing; unchecked pins the current non-growing mode, or
		// snapshot when it turns an explicit growing off.
		$was_mode = BW_Lead_AI_Settings::journey_update_mode( BW_Lead_AI_Settings::get() );
		if ( $state['growing'] ) {
			$settings['journey_update_mode'] = 'growing';
			$settings['continuity_enabled']  = 1;
		} else {
			$settings['journey_update_mode'] = ( 'growing' === $was_mode ) ? 'snapshot' : $was_mode;
			$settings['continuity_enabled']  = 0;
		}

		$settings['capture_identity'] = (int) $state['named'];

		// A destination can only report a name back if identity capture is on, so a
		// mapping without it would silently do nothing.
		if ( ! empty( $state['identity'] ) && $state['named'] ) {
			$settings['capture_identity'] = 1;
		}

		// Persist the mapping so the snippet can be shown again on the settings
		// screen, and so re-running the wizard starts from what is already set.
		$settings['handoff_link_field']   = $state['link_field'];
		$settings['handoff_field_map']    = BW_Lead_AI_Settings::serialize_field_map( $state['fields'] );
		$settings['handoff_identity_map'] = BW_Lead_AI_Settings::serialize_field_map( $state['identity'] );

		update_option( BW_LEAD_AI_OPTION, $settings );
		return true;
	}

	private function add_to_list( $existing, $value ) {
		$parts = array_filter( array_map( 'trim', preg_split( '/[,\r\n]+/', (string) $existing ) ) );
		if ( ! in_array( $value, $parts, true ) ) {
			$parts[] = $value;
		}
		return implode( ', ', $parts );
	}

	// --- the generated snippet -------------------------------------------

	/**
	 * Every address this site answers on, the one it is being generated from first.
	 *
	 * The generated script carries this as its allow-list and calls exactly one
	 * member of it — so this is also the list the Cross-domain tab shows, rather
	 * than a second reading of the same setting that could drift from what is
	 * actually emitted.
	 */
	public static function snippet_origins( $settings = null ) {
		$settings = ( null === $settings ) ? BW_Lead_AI_Settings::get() : $settings;
		$api      = esc_url_raw( rest_url( BW_Lead_AI_Handoff_Rest::NAMESPACE_V1 . '/handoff/' ) );

		// The strict reduction first, because that is what the setting stores and the
		// two have to be comparable. A host it will not accept — an internationalised
		// domain, say — still has to produce a working default, so fall back to the
		// plain reduction of this site's own URL, which is not user input.
		$own = BW_Lead_AI_Settings::normalize_self_origin( $api );
		if ( '' === $own ) {
			$own = BW_Lead_AI_Settings::normalize_origin( $api );
		}

		$origins = ( '' === $own ) ? array() : array( $own );
		foreach ( BW_Lead_AI_Settings::parse_handoff_self_origins( $settings ) as $extra ) {
			if ( ! in_array( $extra, $origins, true ) ) {
				$origins[] = $extra;
			}
		}
		return $origins;
	}

	/**
	 * The part of one of this site's own URLs that follows its origin.
	 *
	 * Path AND query, because both of the URLs this is used on can carry one: a REST
	 * route on a site with plain permalinks is `/?rest_route=…`, and the journey
	 * viewer is an admin URL with several arguments already on it.
	 */
	private static function url_suffix( $url ) {
		$parts  = wp_parse_url( (string) $url );
		$suffix = ( isset( $parts['path'] ) && '' !== $parts['path'] ) ? $parts['path'] : '/';
		if ( ! empty( $parts['query'] ) ) {
			$suffix .= '?' . $parts['query'];
		}
		return $suffix;
	}

	/**
	 * Build the destination-side script.
	 *
	 * Every value interpolated here is either a constant, a URL built by this site,
	 * an origin already reduced to `scheme://host[:port]`, or a field name that
	 * sanitize_field_name() has already restricted to `[A-Za-z0-9_-[]. ]`.
	 * wp_json_encode() then handles the quoting, so nothing user-supplied reaches
	 * the script unquoted.
	 *
	 * **The API and the viewer are emitted as paths, not as one absolute URL.** The
	 * script joins whichever allow-listed origin the visitor arrived from onto them
	 * at run time. A single baked-in address meant the code had to be regenerated
	 * and re-pasted on the other site the day this one moved to its live hostname —
	 * a step with nothing to make anyone notice it was missed, because the failure
	 * is silent: the fields simply stay blank. The allow-list is the boundary; a
	 * referrer that is not on it is ignored rather than trusted.
	 */
	public static function build_snippet( $state ) {
		$settings = BW_Lead_AI_Settings::get();
		$param    = (string) $settings['handoff_param'];
		$fields     = ( 'data' === $state['mode'] ) ? $state['fields'] : array();
		$identity   = $state['identity'];
		$link_field = isset( $state['link_field'] ) ? (string) $state['link_field'] : '';
		$host     = wp_parse_url( home_url(), PHP_URL_HOST );

		$j_origins = wp_json_encode( self::snippet_origins( $settings ), JSON_UNESCAPED_SLASHES );
		$j_api    = wp_json_encode(
			self::url_suffix( esc_url_raw( rest_url( BW_Lead_AI_Handoff_Rest::NAMESPACE_V1 . '/handoff/' ) ) ),
			JSON_UNESCAPED_SLASHES
		);
		$j_param  = wp_json_encode( $param, JSON_UNESCAPED_SLASHES );
		$j_fields = wp_json_encode( (object) $fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		$j_ident  = wp_json_encode( (object) $identity, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		$j_link   = wp_json_encode( $link_field, JSON_UNESCAPED_SLASHES );
		// Built here, not fetched: the report URL is this site's own address plus the
		// token the browser already holds, so journey-link mode needs no API call at
		// all — just this one assignment and the confirm on submit.
		$j_viewer = wp_json_encode(
			self::url_suffix( BW_Lead_AI_Handoff_Admin::viewer_url() . '&token=' ),
			JSON_UNESCAPED_SLASHES
		);
		$j_ctx    = wp_json_encode(
			sprintf( '%s form', $state['form_url'] ? wp_parse_url( $state['form_url'], PHP_URL_HOST ) : $host ),
			JSON_UNESCAPED_SLASHES
		);

		$claim_block = empty( $fields ) ? '' : "
    // The claim works once, so a value we already have beats asking again.
    // The already-have case is filled in start(), which waits for the form.
    if (!state.data) { claim(0); }
";

		$claim_fn = empty( $fields ) ? '' : "
  // Retrying on a 404 covers the race between the click and this page loading.
  function claim(attempt) {
    log('claim attempt', attempt + 1);
    fetch(API + state.token, { credentials: 'omit' })
      .then(function (r) {
        if (r.status === 404 && attempt < 2) {
          setTimeout(function () { claim(attempt + 1); }, 400 * (attempt + 1));
          return null;
        }
        return r.ok ? r.json() : null;
      })
      .then(function (j) {
        if (!j || !j.data) { return; }
        state.data = j.data;
        save(state);
        whenFormReady(function () { fill(j.data); });
      })
      .catch(function () { log('claim failed - the journey link still works'); });
  }
";

		$fill_fn = empty( $fields ) ? '  function fill() {}' : "  function fill(data) {
    Object.keys(FIELDS).forEach(function (key) {
      setField(FIELDS[key], data[key], key);
    });
  }";

		return <<<JS
<!-- BW Lead Attribution Intelligence — handoff for {$host} -->
<!-- Replace any earlier Lead AI tag with this one — an old tag claims the details first and leaves these fields blank. -->
<script>
(function () {
  // The addresses that site answers on, its usual one first. This code talks to
  // whichever one the visitor came from, and never to one that is not listed.
  var ORIGINS     = {$j_origins};
  var API_PATH    = {$j_api};
  var VIEWER_PATH = {$j_viewer};

  var PARAM   = {$j_param};
  var CONTEXT = {$j_ctx};
  var STORE   = 'bwlai_handoff';

  // Journey detail -> the name of the field it fills on this form.
  var FIELDS   = {$j_fields};

  // This form's own contact fields, read on submit so the enquiry has a name.
  var IDENTITY = {$j_ident};

  // Where the link back to the full journey report is written.
  var LINK_FIELD = {$j_link};

  // Add ?bwlaidebug=true to this page's address to see what is happening: the
  // hidden fields stay visible with a red outline, and progress logs to the
  // console. Nothing else changes, so it is safe on a live form.
  var DEBUG = false;
  try { DEBUG = new URLSearchParams(window.location.search).get('bwlaidebug') === 'true'; } catch (e) { DEBUG = false; }

  function log() {
    if (!DEBUG || !window.console) { return; }
    console.log.apply(console, ['%c[bwlai]', 'color:#c8272c;font-weight:bold']
      .concat([].slice.call(arguments)));
  }

  function load() {
    try { return JSON.parse(sessionStorage.getItem(STORE)) || {}; } catch (e) { return {}; }
  }
  function save(s) {
    try { sessionStorage.setItem(STORE, JSON.stringify(s)); return true; } catch (e) { return false; }
  }

  function allowed(origin) {
    for (var i = 0; i < ORIGINS.length; i++) { if (ORIGINS[i] === origin) { return true; } }
    return false;
  }
  function cameFrom() {
    try { return new URL(document.referrer).origin; } catch (e) { return ''; }
  }

  var state = load();

  // The token arrives on the landing page, but the form may be several clicks
  // further in, so it is remembered for the rest of the browsing session — with
  // the address it came from, which the form page has no other way to know.
  var token = null;
  try { token = new URLSearchParams(window.location.search).get(PARAM); } catch (e) { token = null; }
  if (token && !/^[a-f0-9]{32}\$/.test(token)) { token = null; }
  if (token && token !== state.token) {
    var from = cameFrom();
    state = { token: token, origin: allowed(from) ? from : ORIGINS[0] };
  }
  // Hiding the plumbing rows happens whatever else does or does not. These fields
  // are ours, never questions for the visitor, so a page opened directly — no token,
  // no journey — must still never show them. Everything below this needs a token;
  // this does not.
  function concealAll() {
    conceal(LINK_FIELD);
    Object.keys(FIELDS).forEach(function (key) { conceal(FIELDS[key]); });
  }

  // Embedded and multi-step form builders render the form after the page is ready,
  // so a single pass at load can run before the fields exist. Watch until they do.
  // Mirrors why the submit listener sits on the document in capture phase.
  function whenFormReady(run) {
    run();
    if (!window.MutationObserver || !document.body) { return; }
    var seen = 0;
    var obs  = new MutationObserver(function () {
      run();
      if (++seen > 60) { obs.disconnect(); }
    });
    obs.observe(document.body, { childList: true, subtree: true });
    setTimeout(function () { obs.disconnect(); }, 30000);
  }

  function bootConceal() { whenFormReady(concealAll); }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', bootConceal);
  } else {
    bootConceal();
  }

  if (!state.token) { log('no journey token - fields hidden, nothing else to do'); return; }

  // Checked again here, not only when it was stored: whatever ends up in storage,
  // an address that is not on the list above is never called.
  var ORIGIN = allowed(state.origin) ? state.origin : ORIGINS[0];
  var API    = ORIGIN + API_PATH;
  var VIEWER = ORIGIN + VIEWER_PATH;
  log('talking to', ORIGIN);

  // Once the token is safely stored, take it back out of the address bar.
  if (token && save(state)) {
    try {
      var qs = new URLSearchParams(window.location.search);
      qs.delete(PARAM);
      history.replaceState(null, '', window.location.pathname + (qs.toString() ? '?' + qs.toString() : '') + window.location.hash);
    } catch (e) { log('could not tidy the address bar'); }
  }

  function setField(name, value, label) {
    if (!name || value === undefined || value === null || value === '') { return; }
    var el = document.querySelector('[name="' + name + '"]');
    if (!el) { log('no field named', name); return; }
    // Never clobber something the visitor typed, or a value another tool set.
    if (el.value) { log(label || name, 'already has a value - left alone'); return; }
    el.value = value;
    // Some form builders only notice a value when these fire.
    try {
      el.dispatchEvent(new Event('input',  { bubbles: true }));
      el.dispatchEvent(new Event('change', { bubbles: true }));
    } catch (e) { log('could not fire input/change on', name); }
    log('wrote', (label || name) + ':', String(value).slice(0, 120));
  }

  // Hide the rows these values land in - they are plumbing, not questions.
  function rowFor(el) {
    var lab = el.closest('label');
    if (!lab && el.id) {
      // Compared as a property, never built into a selector string: an id with a
      // quote or a bracket in it would otherwise throw or match the wrong node.
      var labels = document.getElementsByTagName('label');
      for (var i = 0; i < labels.length; i++) {
        if (labels[i].htmlFor === el.id) { lab = labels[i]; break; }
      }
    }
    var box = el.parentElement;
    if (lab) {
      while (box && box !== document.body && box.tagName !== 'FORM' && !box.contains(lab)) {
        box = box.parentElement;
      }
    }
    if (!box || box === document.body || box.tagName === 'FORM') { return el.parentElement; }
    // Collapse a wrapper that exists only to hold this row, so no empty gap is left.
    while (box.parentElement && box.parentElement !== document.body
      && box.parentElement.tagName !== 'FORM'
      && box.parentElement.children.length === 1) {
      box = box.parentElement;
    }
    return box;
  }

  function conceal(name) {
    if (!name) { return; }
    var el = document.querySelector('[name="' + name + '"]');
    if (!el) { return; }
    // The row to hide is the one holding BOTH the input and its label. Matching a
    // container by class name alone is not enough: builders nest an inner wrapper
    // around just the input whose class also contains "form-field", so hiding the
    // nearest match leaves the label sitting on its own. Find the label, then climb
    // until an ancestor contains it too, and collapse any wrapper that then holds
    // nothing else.
    var box = rowFor(el);
    if (!box) { return; }
    if (DEBUG) {
      box.style.outline = '2px dashed #c8272c';
      box.style.outlineOffset = '2px';
      log('left visible for debugging:', name);
      return;
    }
    box.style.display = 'none';
  }

{$fill_fn}
{$claim_fn}
  function valueOf(name) {
    var el = document.querySelector('[name="' + name + '"]');
    return el && el.value ? el.value : '';
  }

  function confirm() {
    // The URL carries the token and nothing else. Everything about the PERSON
    // goes in the body, because query strings are written to web server access
    // logs in full — an enquirer's name, email and phone would be recorded in
    // plaintext on every submission, in logs that are rotated, backed up and
    // shipped around by tooling with no idea what it is carrying. Nothing on the
    // receiving server can prevent that: the log line is written before any of
    // its code runs, so this is the only place it can be stopped.
    var url  = API + encodeURIComponent(state.token) + '/confirm';
    var body = 'context=' + encodeURIComponent(CONTEXT);
    Object.keys(IDENTITY).forEach(function (key) {
      var v = valueOf(IDENTITY[key]);
      if (v) { body += '&identity[' + key + ']=' + encodeURIComponent(v); }
    });
    log('reporting the submission');

    // sendBeacon is the only thing that reliably survives the page being torn
    // down by the submit. x-www-form-urlencoded is a CORS-safelisted content
    // type, so this still needs no preflight.
    try {
      if (navigator.sendBeacon) {
        var blob = new Blob([body], { type: 'application/x-www-form-urlencoded' });
        if (navigator.sendBeacon(url, blob)) { return; }
      }
    } catch (e) { /* fall through to fetch */ }

    // Reached when sendBeacon is missing, refuses the payload, or throws on the
    // content type. The body goes with it — a fallback that dropped it would
    // lose the enquirer's details silently, on exactly the browsers nobody tests.
    fetch(url, {
      method: 'POST',
      credentials: 'omit',
      keepalive: true,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body
    }).catch(function () { log('could not report the submission'); });
  }

  function start() {
    // Rows are already hidden by bootConceal above. Writing the values is retried
    // the same way, so a form that appears late is filled when it arrives —
    // setField leaves any field that already has a value alone, so re-running is safe.
    whenFormReady(function () {
      setField(LINK_FIELD, VIEWER + encodeURIComponent(state.token), 'journey link');
      if (state.data) { fill(state.data); }
    });
{$claim_block}
    // Capture phase, on the document: this still fires for forms added to the
    // page later, which covers embedded and multi-step form builders.
    document.addEventListener('submit', function (e) {
      if (e.target && e.target.tagName === 'FORM') { confirm(); }
    }, true);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();
</script>
JS;
	}

	// --- the shared-property snippet --------------------------------------

	/**
	 * Build the script for a property this site's owner also owns.
	 *
	 * **The problem it solves.** A visitor finds a landing page of yours — on a
	 * subdomain, or on a landing-page platform, or on a second domain entirely — through
	 * an ad or a search, and then clicks through to the main site. Listing that property
	 * as one of your own hostnames correctly stops it being credited as a channel, but
	 * the fallback is Direct, and Direct here is a tidier way of saying the answer was
	 * thrown away. Somebody found that landing page from somewhere; that somewhere is
	 * knowable right up until the moment we discard it.
	 *
	 * This snippet's whole job is not to discard it. On the owned property it remembers
	 * what originally brought the visitor there and writes it onto every link pointing
	 * home, so the main site's capture script can classify the arrival as the ad or the
	 * search that really found them.
	 *
	 * **Why links and not a cookie.** A shared cookie is the tidier-sounding answer and
	 * it only works in one case: same registrable domain, and only when the site is not
	 * itself a subdomain of a shared parent (see the visitor cookie's own note on what
	 * widening it too far leaks). The continuity cookie is also HttpOnly and the REST
	 * layer never permits credentials cross-origin, so a page on another property could
	 * not read or send it even if the scope allowed it — that is deliberate, not an
	 * oversight to route around. Link decoration has none of those constraints: it works
	 * across genuinely different domains, on any platform that allows a script tag, with
	 * no CORS, no cookies and no dependency on continuity being switched on at all.
	 *
	 * **What is deliberately NOT carried.** Only the original source — tags, or one
	 * hostname. No visitor identity, no journey, no stored data. A link written by this
	 * cannot identify anybody, which is what makes it safe to put on a property whose
	 * pages you may not fully control.
	 *
	 * Everything interpolated here is a hostname or a parameter name that has already
	 * been validated against a strict pattern below, and wp_json_encode() then does the
	 * quoting — nothing user-supplied reaches the script unquoted.
	 */
	public static function build_shared_property_snippet( $settings = null ) {
		$settings = ( null === $settings ) ? BW_Lead_AI_Settings::get() : $settings;
		$host     = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		$hostname = preg_match( '/^[a-z0-9.\-]+\.[a-z]{2,}$/', $host ) ? $host : '';

		// The main site, and its www/bare sibling. A property's links are as likely to
		// be written one way as the other, and a canonical redirect between them keeps
		// the query string — so covering both costs one array entry and saves a silent
		// no-op that would look exactly like the feature not working.
		$mains = array();
		if ( '' !== $hostname ) {
			$mains[] = $hostname;
			$sibling = ( 0 === strpos( $hostname, 'www.' ) ) ? substr( $hostname, 4 ) : 'www.' . $hostname;
			if ( preg_match( '/^[a-z0-9.\-]+\.[a-z]{2,}$/', $sibling ) ) {
				$mains[] = $sibling;
			}
		}

		// Reuses the `internal` row of Medium Mappings, so a hostname the owner has
		// already declared theirs is not mistaken for the source that found the
		// visitor. This site's own hostname (and its www/bare sibling) goes in
		// explicitly: the snippet runs on the OTHER property, where
		// window.location.hostname is that property, so nothing else would recognise
		// an arrival from here as internal.
		$owned = BW_Lead_AI_Settings::owned_hosts( $settings );
		foreach ( $mains as $main ) {
			if ( ! in_array( $main, $owned, true ) ) {
				$owned[] = $main;
			}
		}

		// The parameter names THIS site resolves, so a site using non-UTM tags carries
		// its own. The snippet also carries anything beginning `utm_` on top of these.
		$aliases = BW_Lead_AI_Settings::parse_parameter_aliases( $settings['parameter_aliases'] );
		$names   = array();
		foreach ( array( 'standard', 'custom' ) as $bucket ) {
			foreach ( (array) $aliases[ $bucket ] as $list ) {
				$names = array_merge( $names, (array) $list );
			}
		}
		foreach ( BW_Lead_AI_Settings::parse_click_ids( $settings['click_ids'] ) as $row ) {
			$names[] = $row['param'];
		}
		$carry = array();
		foreach ( $names as $name ) {
			$name = trim( (string) $name );
			if ( '' !== $name && preg_match( '/^[A-Za-z0-9_.\-]+$/', $name ) && ! in_array( $name, $carry, true ) ) {
				$carry[] = $name;
			}
		}
		sort( $carry );

		$j_main  = wp_json_encode( array_values( array_unique( $mains ) ), JSON_UNESCAPED_SLASHES );
		$j_owned = wp_json_encode( array_values( $owned ), JSON_UNESCAPED_SLASHES );
		$j_carry = wp_json_encode( $carry, JSON_UNESCAPED_SLASHES );
		$j_ref   = wp_json_encode( self::SHARED_REF_PARAM, JSON_UNESCAPED_SLASHES );
		$label   = '' === $hostname ? 'the main site' : $hostname;

		return <<<JS
<!-- BW Lead Attribution Intelligence — shared property, hands the original source to {$label} -->
<!-- Put this on the property you own, not on {$label}. It sends no visitor data
     anywhere: it appends what originally found the visitor to your own links home. -->
<script>
(function () {
  // Links to these hosts get the original source appended. Every other link on the
  // page is left exactly as it is.
  var MAIN  = {$j_main};

  // Hostnames belonging to the same owner. Arriving from one of these is not an
  // acquisition, so it is never recorded as what found the visitor.
  var OWNED = {$j_owned};

  // Parameter names the main site treats as source information. Anything starting
  // with utm_ counts too, so a tag nobody has configured still travels.
  var CARRY = {$j_carry};

  var REF   = {$j_ref};
  var STORE = 'bwlai_origin';

  // Add ?bwlaidebug=true to this page's address to watch it work in the console.
  // Nothing else changes, so it is safe on a live page.
  var DEBUG = false;
  try { DEBUG = window.location.search.indexOf('bwlaidebug=true') !== -1; } catch (e) { /* very old browser - debug stays off */ }

  function log() {
    if (!DEBUG || !window.console) { return; }
    console.log.apply(console, ['%c[bwlai]', 'color:#c8272c;font-weight:bold']
      .concat([].slice.call(arguments)));
  }

  function pairs(search) {
    var qs = String(search || '').replace(/^\?/, '');
    return qs ? qs.split('&') : [];
  }

  function keyOf(pair) {
    var eq  = pair.indexOf('=');
    var raw = (eq === -1) ? pair : pair.slice(0, eq);
    try { return decodeURIComponent(raw); } catch (e) { return raw; /* a stray % in the URL - compare the raw form */ }
  }

  function isSourceParam(name) {
    if (!name) { return false; }
    if (name.indexOf('utm_') === 0) { return true; }
    for (var i = 0; i < CARRY.length; i++) {
      if (CARRY[i] === name) { return true; }
    }
    return false;
  }

  // The source parameters on THIS page, kept byte for byte as they were written.
  // Passing them through unchanged means whatever the ad platform put there is what
  // the main site reads - nothing re-encoded, nothing normalised, nothing to get
  // subtly wrong between two systems that have to agree.
  function ownParams() {
    var found = [];
    var all   = pairs(window.location.search);
    for (var i = 0; i < all.length; i++) {
      if (all[i] && isSourceParam(keyOf(all[i]))) { found.push(all[i]); }
    }
    return found.join('&');
  }

  function isOwned(host) {
    if (!host) { return true; }
    if (host === String(window.location.hostname).toLowerCase()) { return true; }
    for (var i = 0; i < OWNED.length; i++) {
      var o = OWNED[i];
      // A leading '*.' means subdomains only - 'get.example.com' is theirs,
      // the bare 'example.com' is not.
      if (o.indexOf('*.') === 0) {
        var b = o.slice(2);
        if (b && host.length > b.length && host.slice(-(b.length + 1)) === '.' + b) { return true; }
        continue;
      }
      if (host === o) { return true; }
      if (host.length > o.length && host.slice(-(o.length + 1)) === '.' + o) { return true; }
    }
    return false;
  }

  function referrerHost() {
    try { return new URL(document.referrer).hostname.toLowerCase(); }
    catch (e) { return ''; /* no referrer at all, or one this browser will not parse */ }
  }

  function read() {
    try { return JSON.parse(sessionStorage.getItem(STORE)) || null; }
    catch (e) { return null; /* storage off or a corrupt value - behave as if nothing was captured */ }
  }

  function write(s) {
    try { sessionStorage.setItem(STORE, JSON.stringify(s)); }
    catch (e) { /* private mode - the in-memory copy still decorates this page's links */ }
  }

  // What found this visitor, captured once and then carried for the rest of the
  // browsing session, because the form they fill in may be several clicks further on.
  // Tags on THIS page always win and always overwrite: a fresh ad click mid-session is
  // a genuine new acquisition. Otherwise the referring host is recorded, and only when
  // nothing has been captured yet, so the ORIGINAL source is the one that survives.
  var state = read();
  var own   = ownParams();
  if (own) {
    state = { q: own };
    write(state);
    log('captured the tags on this page:', own);
  } else if (!state) {
    var from = referrerHost();
    if (isOwned(from)) {
      log('came from one of ours (or nowhere) - nothing to carry');
    } else {
      state = { r: from };
      write(state);
      log('captured the referring host:', from);
    }
  }

  // What gets appended to a link home: the original tags unchanged, or failing that
  // the one hostname that found them.
  function payload() {
    if (!state) { return ''; }
    if (state.q) { return state.q; }
    if (state.r) { return REF + '=' + encodeURIComponent(state.r); }
    return '';
  }

  function alreadyTagged(search) {
    var all = pairs(search);
    for (var i = 0; i < all.length; i++) {
      var k = keyOf(all[i]);
      if (k === REF || isSourceParam(k)) { return true; }
    }
    return false;
  }

  function isMain(host) {
    for (var i = 0; i < MAIN.length; i++) {
      if (MAIN[i] === host) { return true; }
    }
    return false;
  }

  function decorate(a) {
    var extra = payload();
    if (!extra || !a || !a.href) { return; }
    if (a.protocol !== 'http:' && a.protocol !== 'https:') { return; }
    if (!isMain(String(a.hostname || '').toLowerCase())) { return; }
    // Somebody tagged this link deliberately. Theirs wins, and this stays idempotent
    // so decorating the same link twice cannot double it up.
    if (alreadyTagged(a.search)) { return; }
    var qs = String(a.search || '').replace(/^\?/, '');
    a.search = qs ? qs + '&' + extra : extra;
    log('decorated', a.href);
  }

  function decorateAll() {
    var links = document.getElementsByTagName('a');
    for (var i = 0; i < links.length; i++) { decorate(links[i]); }
  }

  // Links that appear later - a menu that builds itself, a page that swaps its content
  // in - are caught when they are used instead. mousedown covers the middle click and
  // the open-in-new-tab click, neither of which reliably reaches a click handler.
  function onPointer(e) {
    var el = (e && e.target && e.target.closest) ? e.target.closest('a[href]') : null;
    if (el) { decorate(el); }
  }

  function start() {
    decorateAll();
    document.addEventListener('mousedown', onPointer, true);
    document.addEventListener('click', onPointer, true);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();
</script>
JS;
	}

	/**
	 * The wizard's answers, reconstructed from saved settings, so the settings
	 * screen can show the same code the wizard produced.
	 */
	public static function state_from_settings( $settings = null ) {
		$settings = ( null === $settings ) ? BW_Lead_AI_Settings::get() : $settings;
		$domains  = BW_Lead_AI_Settings::parse_handoff_domains( $settings );
		$fields   = BW_Lead_AI_Settings::parse_field_map( $settings['handoff_field_map'] );

		// Only offer to fill what is actually claimable — a field mapped to a
		// datapoint that is no longer shared would generate code that silently
		// fills nothing.
		$shared = BW_Lead_AI_Settings::parse_handoff_share_datapoints( $settings );
		foreach ( array_keys( $fields ) as $key ) {
			if ( ! in_array( $key, $shared, true ) ) {
				unset( $fields[ $key ] );
			}
		}

		return array(
			'link_field' => BW_Lead_AI_Settings::handoff_mode_link( $settings ) ? (string) $settings['handoff_link_field'] : '',
			'form_url' => empty( $domains ) ? '' : 'https://' . $domains[0] . '/',
			// The remembered answer to "what should happen when they get there", not a
			// guess re-derived from the data. Coupling it to a non-empty field list
			// meant an un-shared datapoint silently reset the choice, and the wizard
			// asked again as though it had never been answered.
			'mode'     => BW_Lead_AI_Settings::handoff_mode_data( $settings ) ? 'data' : 'link',
			'fields'   => $fields,
			'identity' => BW_Lead_AI_Settings::capture_identity( $settings )
				? BW_Lead_AI_Settings::parse_field_map( $settings['handoff_identity_map'] )
				: array(),
		);
	}

	// --- helpers ----------------------------------------------------------

	private function host_of( $url ) {
		$url = trim( (string) $url );
		if ( '' === $url ) {
			return '';
		}
		if ( false === strpos( $url, '//' ) ) {
			$url = 'https://' . $url;
		}
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host ) {
			return '';
		}
		$host = strtolower( $host );
		return preg_match( '/^[a-z0-9.\-]+\.[a-z]{2,}$/', $host ) ? $host : '';
	}

	private function is_this_site( $host ) {
		$own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		return $host === $own;
	}
}
