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

/**
 * REST endpoints for journey storage and cross-domain handoff.
 *
 * These are the plugin's only public, unauthenticated routes, and they are
 * **registered only when journey storage is on** (BW_Lead_AI_Settings::
 * journey_storage_enabled()). The cross-origin pair — claim and confirm — is
 * narrower still: those exist only when a cross-domain destination is actually
 * configured. A route that is off does not exist at all — a request 404s
 * because there is no route, not because a permission callback refused it.
 *
 * Routes:
 *   POST /bw-lead-ai/v1/handoff                  create or refresh (same-origin)
 *   POST /bw-lead-ai/v1/journey/beacon           append to a saved journey (same-origin)
 *   GET  /bw-lead-ai/v1/handoff/<token>          claim, once     (cross-origin)
 *   POST /bw-lead-ai/v1/handoff/<token>/confirm  promote         (cross-origin)
 *
 * Security posture, stated plainly: **CORS is not authentication.** The allow-list
 * stops browser JavaScript on other origins; it does nothing against curl. The
 * actual control is token secrecy — 128 bits from a CSPRNG, hashed at rest, short
 * TTL, single-use for the data claim. CORS is defence in depth on top of that.
 *
 * That defence is only real because we remove WordPress core's CORS handler for
 * these routes — see suppress_core_cors(). Without that, core echoes any Origin
 * back and the allow-list here restricts nothing. If you refactor the hooks in
 * this class, re-run `tests/cors-check.sh`: this failure is invisible when you
 * only test from an allow-listed origin.
 */
class BW_Lead_AI_Handoff_REST {

	const NAMESPACE_V1 = 'bw-lead-ai/v1';

	/** Hard ceiling on a stored payload, after server-side filtering. */
	const MAX_PAYLOAD_BYTES = 16384;

	/** Per-datapoint ceilings. The journey summaries are the only large ones. */
	const MAX_FIELD_BYTES = array(
		'journey'          => 12288,
		'summary'          => 4096,
		'summary_detailed' => 8192,
		'events_list'      => 1024,
	);

	/**
	 * Datapoints whose value is JSON. These are dropped rather than truncated
	 * when oversized — half a JSON document is unparseable, so silently cutting
	 * one would leave the journey report unable to render anything at all.
	 */
	const JSON_FIELDS = array( 'journey' );

	const MAX_SCALAR_BYTES = 300;

	/** The server-set continuity cookie. Never wp-* / wordpress_* — those trip cache-bypass rules. */
	const VISITOR_COOKIE = 'bwlai_vk';

	/**
	 * ~400 days: every major browser now caps cookie lifetime there, server-set or
	 * not. This is a ROLLING window — it is re-issued on every append so it slides
	 * forward, rather than a set-once token that quietly dies mid-relationship.
	 */
	const VISITOR_COOKIE_MAX_AGE = 400 * DAY_IN_SECONDS;

	/** Requests per hour, per hashed IP, per bucket. */
	const RATE_LIMITS = array(
		'create'  => 30,
		'claim'   => 120,
		'confirm' => 30,
		'beacon'  => 60,
	);

	/** Global ceiling on live pending records, so a distributed flood can't fill the table. */
	const MAX_HELD = 10000;

	private static $instance = null;

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

	public function register() {
		if ( ! BW_Lead_AI_Settings::journey_storage_enabled() ) {
			return;
		}
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
		// Runs before rest_pre_serve_request, so core's CORS handler is gone by the
		// time headers are emitted. See suppress_core_cors().
		add_filter( 'rest_pre_dispatch', array( $this, 'suppress_core_cors' ), 10, 3 );
		// Priority 20: after anything else on this hook, so our header_remove()
		// backstop can strip permissive headers somebody else already set.
		add_filter( 'rest_pre_serve_request', array( $this, 'send_cors_headers' ), 20, 3 );
	}

	/**
	 * Is this request for one of our handoff routes?
	 */
	private function is_handoff_route( $request ) {
		if ( ! $request instanceof WP_REST_Request ) {
			return false;
		}
		return 0 === strpos( ltrim( $request->get_route(), '/' ), self::NAMESPACE_V1 . '/handoff' );
	}

	/**
	 * Remove WordPress core's CORS handler for our routes only.
	 *
	 * Core's `rest_send_cors_headers()` echoes **any** Origin back, together with
	 * `Access-Control-Allow-Credentials: true`. It is registered on the same
	 * `rest_pre_serve_request` hook we use, which means our allow-list only ever
	 * *added* headers for permitted origins — it never removed core's permissive
	 * ones for an origin we rejected. The allow-list was therefore decorative: a
	 * non-listed origin still received `Access-Control-Allow-Origin` from core,
	 * and the class docblock's claim of a restricted allow-list was not true of
	 * the shipped behaviour.
	 *
	 * Dropping core's handler here — scoped to our namespace, at dispatch time,
	 * before any header is emitted — makes send_cors_headers() the only source of
	 * CORS headers on these routes. Every other REST route keeps core's behaviour
	 * exactly as it was.
	 */
	public function suppress_core_cors( $result, $server, $request ) {
		if ( $this->is_handoff_route( $request ) ) {
			remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
		}
		return $result;
	}

	public function register_routes() {
		register_rest_route(
			self::NAMESPACE_V1,
			'/handoff',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'create' ),
				'permission_callback' => '__return_true',
			)
		);

		// The cross-origin claim. Registration used to lean on register()'s own
		// handoff gate; now that register() runs for any storing site the
		// destination requirement has to be stated here, or a site with the mode
		// flags ticked but no destination would grow a cross-origin read route.
		if ( BW_Lead_AI_Settings::handoff_enabled() && BW_Lead_AI_Settings::handoff_mode_data() ) {
			register_rest_route(
				self::NAMESPACE_V1,
				'/handoff/(?P<token>[a-f0-9]{32})',
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'claim' ),
					'permission_callback' => '__return_true',
				)
			);
		}

		// The journey-update beacon. Without this, browsing never reaches the
		// server: cached pages may not even run PHP, so nothing appends and the
		// visitor cookie never re-stamps. It serves both update modes that allow
		// appends — 'visit' (the post-submission redirect chain) and 'growing'
		// (return visits; see docs/CONTINUITY-PLAN.md §4). Which appends are
		// accepted is decided per record in beacon(), not here.
		if ( BW_Lead_AI_Settings::journey_updates_enabled() ) {
			register_rest_route(
				self::NAMESPACE_V1,
				'/journey/beacon',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'beacon' ),
					'permission_callback' => '__return_true',
				)
			);
		}

		if ( BW_Lead_AI_Settings::handoff_enabled() && BW_Lead_AI_Settings::handoff_mode_link() ) {
			register_rest_route(
				self::NAMESPACE_V1,
				'/handoff/(?P<token>[a-f0-9]{32})/confirm',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'confirm' ),
					'permission_callback' => '__return_true',
				)
			);
		}
	}

	// --- CORS -----------------------------------------------------------

	/**
	 * Echo CORS headers for allow-listed origins only, on our routes only.
	 *
	 * Never `*`, never with credentials, and `Vary: Origin` so a shared cache
	 * cannot serve one origin's allowance to another. Runs for preflight OPTIONS
	 * as well as the real request, because WordPress dispatches both through here.
	 */
	public function send_cors_headers( $served, $result, $request ) {
		if ( ! $this->is_handoff_route( $request ) ) {
			return $served;
		}

		header( 'Vary: Origin', false );
		header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );

		$origin  = BW_Lead_AI_Settings::normalize_origin( get_http_origin() );
		$allowed = ( '' !== $origin )
			&& in_array( $origin, BW_Lead_AI_Settings::parse_handoff_origins(), true );

		if ( ! $allowed ) {
			// A rejected origin must leave with NO CORS headers at all. These
			// removals are a backstop: suppress_core_cors() should already have
			// stopped core emitting them, but if any other plugin sets them we
			// still refuse rather than silently inherit somebody else's policy.
			header_remove( 'Access-Control-Allow-Origin' );
			header_remove( 'Access-Control-Allow-Methods' );
			header_remove( 'Access-Control-Allow-Credentials' );
			header_remove( 'Access-Control-Max-Age' );
			return $served;
		}

		header( 'Access-Control-Allow-Origin: ' . $origin );
		header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
		header( 'Access-Control-Allow-Headers: Content-Type' );
		header( 'Access-Control-Max-Age: 600' );

		// Never advertise credentials. Nothing in this design uses them — the
		// destination script fetches with credentials omitted — and allowing them
		// would widen what a page on an allow-listed origin could do with these
		// routes. Core sets this unconditionally, so remove it explicitly.
		header_remove( 'Access-Control-Allow-Credentials' );

		return $served;
	}

	// --- rate limiting ---------------------------------------------------

	/**
	 * The requesting IP, never stored raw.
	 *
	 * Defaults to REMOTE_ADDR rather than trusting a forwarded header, since those
	 * are trivially spoofed and would make the rate limit decorative. Sites behind
	 * a proxy that rewrites REMOTE_ADDR work as-is; anything more exotic can use
	 * the filter.
	 */
	private function client_ip() {
		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';
		return (string) apply_filters( 'bw_lead_ai_client_ip', $ip );
	}

	/**
	 * A rate-limit key that identifies a client without recording who they are.
	 *
	 * The plugin does not log IP addresses — a deliberate, privacy-motivated choice
	 * inherited from its predecessor — so the counter is keyed by a salted daily
	 * hash. It is non-reversible, rotates itself every day, and nothing survives
	 * beyond the hourly counter transient.
	 */
	private function rate_key( $bucket ) {
		$hash = hash( 'sha256', wp_salt( 'auth' ) . '|' . gmdate( 'Y-m-d' ) . '|' . $this->client_ip() );
		return 'bw_lai_rl_' . $bucket . '_' . substr( $hash, 0, 32 );
	}

	private function rate_limited( $bucket ) {
		$limits = apply_filters( 'bw_lead_ai_handoff_rate_limits', self::RATE_LIMITS );
		$limit  = isset( $limits[ $bucket ] ) ? (int) $limits[ $bucket ] : 0;
		if ( $limit <= 0 ) {
			return false;
		}
		$key   = $this->rate_key( $bucket );
		$count = (int) get_transient( $key );
		if ( $count >= $limit ) {
			return true;
		}
		set_transient( $key, $count + 1, HOUR_IN_SECONDS );
		return false;
	}

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

	/**
	 * Uniform "no such record" response.
	 *
	 * Missing, expired and already-claimed all return the same 404 so the endpoint
	 * cannot be used as an oracle to probe which tokens ever existed.
	 */
	private function not_found() {
		return new WP_REST_Response( array( 'error' => 'not_found' ), 404 );
	}

	/**
	 * Reduce a client-supplied payload to exactly the datapoints the site owner
	 * enabled, enforcing size caps.
	 *
	 * Filtering happens here, on the server, precisely because the front end is not
	 * trustworthy — a tampered script cannot widen what gets sent.
	 *
	 * @return array|WP_REST_Response Filtered payload, or an error response.
	 */
	private function filter_payload( $raw ) {
		if ( ! is_array( $raw ) ) {
			return new WP_REST_Response( array( 'error' => 'invalid_payload' ), 400 );
		}

		$allowed = BW_Lead_AI_Settings::parse_handoff_datapoints();
		$out     = array();

		foreach ( $allowed as $key ) {
			if ( ! isset( $raw[ $key ] ) || is_array( $raw[ $key ] ) ) {
				continue;
			}
			$value = (string) $raw[ $key ];
			if ( '' === $value ) {
				continue;
			}
			$cap = isset( self::MAX_FIELD_BYTES[ $key ] ) ? self::MAX_FIELD_BYTES[ $key ] : self::MAX_SCALAR_BYTES;

			if ( in_array( $key, self::JSON_FIELDS, true ) ) {
				// Drop, never truncate — see JSON_FIELDS. Also reject anything that
				// doesn't parse, so the report never has to defend against garbage.
				if ( strlen( $value ) > $cap || ! is_array( json_decode( $value, true ) ) ) {
					continue;
				}
				$out[ $key ] = $value;
				continue;
			}

			if ( strlen( $value ) > $cap ) {
				$value = substr( $value, 0, $cap );
			}
			// Strip control characters but keep newlines and tabs, which the
			// summaries legitimately contain.
			$out[ $key ] = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value );
		}

		if ( empty( $out ) ) {
			return new WP_REST_Response( array( 'error' => 'empty_payload' ), 400 );
		}
		if ( strlen( (string) wp_json_encode( $out ) ) > self::MAX_PAYLOAD_BYTES ) {
			return new WP_REST_Response( array( 'error' => 'payload_too_large' ), 413 );
		}
		return $out;
	}

	// --- visitor key ------------------------------------------------------

	/**
	 * Read the continuity cookie, if the browser sent one.
	 *
	 * HttpOnly, so no script on the page can read it — not ours, and not any
	 * third-party tag. Only the server ever sees it.
	 */
	private function visitor_key_from_request() {
		if ( empty( $_COOKIE[ self::VISITOR_COOKIE ] ) ) {
			return '';
		}
		$key = sanitize_text_field( wp_unslash( $_COOKIE[ self::VISITOR_COOKIE ] ) );
		return BW_Lead_AI_Handoff_Store::is_token_shaped( $key ) ? $key : '';
	}

	/**
	 * Issue (or re-issue) the continuity cookie.
	 *
	 * Set ONLY on POST responses, never on anything cacheable, and always alongside
	 * `no-store`. If a Set-Cookie for this ever ended up in a shared cache, every
	 * visitor would receive the SAME key and their named histories would merge into
	 * one record — silent, and unrecoverable. That is the single worst failure
	 * available here, which is why the guard is unconditional rather than a setting.
	 */
	private function issue_visitor_cookie( $key ) {
		if ( headers_sent() || ! BW_Lead_AI_Handoff_Store::is_token_shaped( $key ) ) {
			return;
		}
		header( 'Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0' );
		setcookie(
			self::VISITOR_COOKIE,
			$key,
			array(
				'expires'  => time() + self::VISITOR_COOKIE_MAX_AGE,
				'path'     => COOKIEPATH ? COOKIEPATH : '/',
				'domain'   => self::visitor_cookie_domain(),
				'secure'   => is_ssl(),
				'httponly' => true,
				'samesite' => 'Lax',
			)
		);
	}

	/**
	 * What the continuity cookie is scoped to.
	 *
	 * Default: this site's own host with a leading `www.` removed, dot-prefixed. A site
	 * at `www.example.com` gets `.example.com`, so the visitor key it issues is also sent
	 * on `get.example.com` — a landing-page subdomain running the capture script joins
	 * the same journey instead of starting a second one. A site at `example.com` gets the
	 * same `.example.com`. Previously this was WordPress's `COOKIE_DOMAIN`, which is
	 * normally empty and produces a host-only cookie no sibling subdomain ever sees.
	 *
	 * **The rejected alternative, and it is rejected on a real failure, not on taste.**
	 * The obvious version is to derive the registrable domain (eTLD+1) and scope to that,
	 * which is what "share across our subdomains" sounds like it means. On a site that
	 * owns its whole apex the two agree. On a site that itself LIVES on a subdomain of a
	 * shared parent — a staging host, an agency preview domain, any multi-tenant platform
	 * where each customer is `<customer>.<platform>` — they do not: eTLD+1 of
	 * `client.demoing.info` is `demoing.info`, so every unrelated site on that parent
	 * would be handed the same visitor keys, and two businesses' visitors would merge
	 * into one continuity record. That is silent and unrecoverable, and the only signal
	 * would be journeys that look mysteriously well-travelled. It is also not a rare
	 * configuration; it is how most sites start life. So the conservative form wins:
	 * scope to the site's OWN host and no higher, and let a site that genuinely owns its
	 * apex widen it deliberately through the filter below.
	 *
	 * **This cookie is deliberately NOT how a shared property recovers a source.** It is
	 * HttpOnly, so no script can read it, and the REST layer never allows credentials on
	 * a cross-origin request — a page on another property cannot cause it to be sent, by
	 * design. Carrying the ORIGINAL SOURCE across an owned property is the job of the
	 * "shared property" snippet's link passthrough (`bwlai_ref`), which needs no cookie,
	 * no shared domain and no CORS. The two are independent: this widens IDENTITY across
	 * subdomains of one site, that carries ATTRIBUTION from anywhere.
	 *
	 * A hostname with no dot in it cannot take a domain-scoped cookie at all, so that
	 * case returns '' and the browser keeps it host-only.
	 */
	private static function visitor_cookie_domain() {
		$host   = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
		$host   = preg_replace( '/^www\./', '', $host );
		$domain = ( '' === $host || false === strpos( $host, '.' ) ) ? '' : '.' . $host;

		/**
		 * Filter the domain the visitor-continuity cookie is scoped to.
		 *
		 * Return a wider scope (`.example.com` from `shop.example.com`) only when the
		 * site owns every host under it — see the note above on what widening it to a
		 * shared parent domain would leak. Return '' for a host-only cookie.
		 *
		 * @param string $domain Dot-prefixed cookie domain, or '' for host-only.
		 * @param string $host   This site's own hostname, `www.` already stripped.
		 */
		$domain = apply_filters( 'bw_lead_ai_cookie_domain', $domain, $host );

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

	// --- update policy ---------------------------------------------------

	/**
	 * The inactivity gap that ends a visit, mirroring capture.js's
	 * SESSION_GAP_MS. The two must agree or the server would freeze a journey
	 * the browser still considers mid-visit (or the reverse).
	 */
	const VISIT_GAP_SECONDS = 30 * MINUTE_IN_SECONDS;

	/**
	 * May this saved record still be appended to?
	 *
	 * The server decides, from its own clock and its own columns — the client is
	 * never trusted to say "this is still the same visit", because anything the
	 * client asserts, a tampered client asserts better.
	 *
	 *   growing  — always. The record accretes for as long as it is retained.
	 *   visit    — only while the visit the save belongs to is still running:
	 *              the save (confirmed_at) or the last accepted append
	 *              (last_seen_at), whichever is newer, was under thirty minutes
	 *              ago. A refused append does not advance either column, so the
	 *              first thirty-minute gap freezes the record permanently —
	 *              later browsing cannot thaw it.
	 *   snapshot — never. The beacon route is not even registered in this mode;
	 *              this re-check is for the window where the mode changed after
	 *              a page (and its route table) was already served.
	 */
	private function may_append( $anchor ) {
		$mode = BW_Lead_AI_Settings::journey_update_mode();
		if ( 'growing' === $mode ) {
			return true;
		}
		if ( 'visit' !== $mode ) {
			return false;
		}
		$last = 0;
		foreach ( array( 'confirmed_at', 'last_seen_at' ) as $column ) {
			if ( empty( $anchor[ $column ] ) ) {
				continue;
			}
			// Stored via current_time( 'mysql', true ), i.e. GMT — say so, or
			// strtotime() applies the server's local timezone to a GMT string.
			$ts = strtotime( $anchor[ $column ] . ' +00:00' );
			if ( $ts && $ts > $last ) {
				$last = $ts;
			}
		}
		if ( ! $last ) {
			return false;
		}
		return ( time() - $last ) < self::VISIT_GAP_SECONDS;
	}

	// --- endpoints -------------------------------------------------------

	/**
	 * The journey-update beacon: append this visitor's newer journey to the
	 * record we already hold for them, and slide the cookie forward. Serves the
	 * post-submission pages of the converting visit ('visit' mode) and return
	 * visits ('growing' mode) through the same door, so the update policy has
	 * exactly one place to be enforced.
	 *
	 * Returns a bare 204 with NO body. That is deliberate and load-bearing: the
	 * visitor key authorises appending to your own journey and nothing else. The
	 * moment this returned journey or identity data it would become a read token,
	 * and an HttpOnly cookie that any request carries automatically is exactly the
	 * wrong thing to hang read access on.
	 */
	public function beacon( WP_REST_Request $request ) {
		$empty = new WP_REST_Response( null, 204 );

		if ( $this->rate_limited( 'beacon' ) ) {
			return new WP_REST_Response( null, 429 );
		}

		$key = $this->visitor_key_from_request();
		if ( '' === $key ) {
			// No key yet — nothing to append to. A record is created by a real
			// signal (a handoff click or a submission), never by browsing alone.
			return $empty;
		}

		$anchor = BW_Lead_AI_Handoff_Store::anchor_for_key( $key );
		if ( ! $anchor ) {
			return $empty;
		}

		// Only keep growing a journey we actually decided to keep — and only when
		// the update mode still allows this record to change. The cookie is
		// re-stamped either way: a frozen journey does not end the browser's
		// identity, and the next record this visitor produces reuses the key.
		if ( BW_Lead_AI_Handoff_Store::STATUS_SAVED !== $anchor['status'] || ! $this->may_append( $anchor ) ) {
			$this->issue_visitor_cookie( $key );
			return $empty;
		}

		$payload = $this->filter_payload( $request->get_param( 'data' ) );
		if ( ! ( $payload instanceof WP_REST_Response ) ) {
			BW_Lead_AI_Handoff_Store::append_to_anchor( $anchor['id'], $payload );
			BW_Lead_AI_Handoff_Store::extend_retention( $anchor['id'], BW_Lead_AI_Settings::handoff_retention_days() );
		}

		// Re-stamp so the ~400-day window slides forward from this visit.
		$this->issue_visitor_cookie( $key );
		BW_Lead_AI_Handoff_Store::prune_inline();

		return $empty;
	}

	/**
	 * Create a record, or refresh an existing pending one.
	 *
	 * Deliberately no REST nonce. This runs on public, cacheable pages; a page
	 * cache or CDN would serve a stale nonce and the feature would fail silently
	 * for a subset of visitors — exactly the failure mode this design is trying to
	 * avoid. It is treated as a public endpoint and defended with rate limits,
	 * payload caps, server-side filtering and a global pending ceiling instead.
	 */
	public function create( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'create' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}

		$payload = $this->filter_payload( $request->get_param( 'data' ) );
		if ( $payload instanceof WP_REST_Response ) {
			return $payload;
		}

		$settings = BW_Lead_AI_Settings::get();
		$existing = (string) $request->get_param( 'token' );

		// Refresh path: requires possession of a live pending token. Never extends
		// expiry, so a refresh loop cannot keep a record alive indefinitely.
		if ( '' !== $existing && BW_Lead_AI_Handoff_Store::is_token_shaped( $existing ) ) {
			if ( BW_Lead_AI_Handoff_Store::refresh( $existing, $payload ) ) {
				return new WP_REST_Response( array( 'token' => $existing, 'refreshed' => true ), 200 );
			}
			// Fall through and mint a new one — the old token had expired.
		}

		if ( BW_Lead_AI_Handoff_Store::held_count() >= self::MAX_HELD ) {
			return new WP_REST_Response( array( 'error' => 'capacity' ), 503 );
		}

		$ttl   = (int) $settings['handoff_ttl'];
		$token = BW_Lead_AI_Handoff_Store::mint( $payload, $ttl );
		if ( is_wp_error( $token ) ) {
			return new WP_REST_Response( array( 'error' => 'store_unavailable' ), 503 );
		}

		// Bind this record to a durable visitor key so later pageviews — and later
		// enquiries — find it again, reusing the browser's existing key when it
		// already has one.
		//
		// **In every mode, including snapshot.** The key answers two separate
		// questions, and only one of them is the update mode's business: may this
		// journey's BROWSING keep being rewritten (mode-gated, in may_append()),
		// and is this the person who already enquired (never gated — a second
		// enquiry joins the journey it belongs to rather than becoming a second
		// lead, whatever the site does about pageviews). Snapshot mode used to set
		// no key at all, which made a merge impossible there; a snapshot journey
		// now still refuses to grow, and still learns the person came back.
		//
		// The route only exists when journey storage is on, so there is no
		// condition left worth writing — a site storing nothing reaches no code
		// here and sets no cookie.
		$key = $this->visitor_key_from_request();
		if ( '' === $key ) {
			$key = BW_Lead_AI_Handoff_Store::generate_token();
		}
		BW_Lead_AI_Handoff_Store::attach_visitor_key( $token, $key );
		$this->issue_visitor_cookie( $key );
		BW_Lead_AI_Handoff_Store::prune_inline();

		return new WP_REST_Response(
			array(
				'token'      => $token,
				'expires_in' => $ttl * MINUTE_IN_SECONDS,
			),
			201
		);
	}

	/**
	 * Claim the payload. Single-use — see BW_Lead_AI_Handoff_Store::claim().
	 */
	public function claim( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'claim' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}
		$payload = BW_Lead_AI_Handoff_Store::claim( (string) $request->get_param( 'token' ) );
		if ( null === $payload ) {
			return $this->not_found();
		}

		// Narrow the stored record to the datapoints the site owner agreed to send
		// across origins. The stored payload is deliberately larger — it also feeds
		// the logged-in journey report — so filtering HERE is what keeps a
		// visitor's browsing history from leaving the site while still being
		// readable in wp-admin.
		$shareable = BW_Lead_AI_Settings::parse_handoff_share_datapoints();
		$out       = array();
		foreach ( $shareable as $key ) {
			if ( isset( $payload[ $key ] ) ) {
				$out[ $key ] = $payload[ $key ];
			}
		}

		return new WP_REST_Response( array( 'data' => $out ), 200 );
	}

	/**
	 * Promote a record to confirmed. Fired by the destination on its form-submit
	 * trigger, which is what turns a throwaway pending record into a kept one.
	 */
	public function confirm( WP_REST_Request $request ) {
		if ( $this->rate_limited( 'confirm' ) ) {
			return new WP_REST_Response( array( 'error' => 'rate_limited' ), 429 );
		}

		$context = sanitize_text_field( (string) $request->get_param( 'context' ) );
		if ( strlen( $context ) > 200 ) {
			$context = substr( $context, 0, 200 );
		}

		$token = (string) $request->get_param( 'token' );

		// The destination's report is the only witness to this submission — nothing
		// on this site sees a form that lives on another domain. save() records the
		// moment and freezes the attribution as it stood; without that, a journey
		// that keeps growing afterwards has no conversion to point at and hands its
		// credit to whatever brought the visitor back next.
		$ok = BW_Lead_AI_Handoff_Store::save(
			$token,
			$context,
			BW_Lead_AI_Settings::handoff_retention_days(),
			BW_Lead_AI_Handoff_Store::VIA_CONFIRM
		);
		if ( ! $ok ) {
			return $this->not_found();
		}

		// The destination may tell us who the lead turned out to be. This is the
		// only route for identity when the form lives on THEIR domain — our
		// server-side capture can only see forms submitted to this site.
		//
		// Identity is stored in its own column and is never returned by the claim
		// endpoint, so accepting it here does not make it shareable onward. It is
		// only honoured when the site owner has switched identity capture on.
		if ( BW_Lead_AI_Settings::capture_identity() ) {
			$identity = $this->sanitize_identity( $request->get_param( 'identity' ) );
			if ( ! empty( $identity ) ) {
				BW_Lead_AI_Handoff_Store::attach_identity( $token, $identity, null );
				BW_Lead_AI_Identity::resolve_and_link( $token, $identity );
			}
		}

		return new WP_REST_Response( array( 'confirmed' => true ), 200 );
	}

	/**
	 * Clean an identity supplied by the destination. Nothing here is trusted: the
	 * keys are a fixed allow-list, the email must actually be one, and everything
	 * is length-capped before it can reach the database or the report.
	 */
	private function sanitize_identity( $raw ) {
		if ( ! is_array( $raw ) ) {
			return array();
		}
		$out = array();
		foreach ( array( 'first', 'last', 'phone' ) as $key ) {
			if ( ! empty( $raw[ $key ] ) && ! is_array( $raw[ $key ] ) ) {
				$value = sanitize_text_field( (string) $raw[ $key ] );
				if ( '' !== $value ) {
					$out[ $key ] = substr( $value, 0, 200 );
				}
			}
		}
		if ( ! empty( $raw['email'] ) && ! is_array( $raw['email'] ) && is_email( (string) $raw['email'] ) ) {
			$out['email'] = substr( sanitize_email( (string) $raw['email'] ), 0, 200 );
		}
		return $out;
	}
}
