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

/**
 * Admin surfaces for cross-domain handoff: a browsable list of captured journeys,
 * the single-journey report, and the purge action.
 *
 * The report is the point of Mode B — the destination stores only an opaque token,
 * and someone follows it back here to read what the visitor actually did. It is
 * written for a marketer, not a developer: no merge-tag names, no field keys, the
 * browsing history first and the plumbing folded away.
 *
 * Everything rendered here originates in URL parameters a stranger controlled —
 * UTM values, referrer hostnames, page paths, page titles. It is all escaped on
 * output. That is the single most important detail in this file.
 */
class BW_Lead_AI_Handoff_Admin {

	/**
	 * The list's own page slug, now a redirect target only.
	 *
	 * It is not registered anywhere: the list is the Journeys view of the Reports
	 * page. The constant survives because links to it are in browsers, in notes and
	 * in other people's CRM records — BW_Lead_AI_Admin::redirect_moved_screens()
	 * matches on it and forwards, token and filters intact.
	 */
	const LIST_SLUG        = 'bw-lead-ai-journeys';
	const PAGE_SLUG        = 'bw-lead-ai-journey';
	const PURGE_ACTION     = 'bw_lead_ai_handoff_purge';
	const REPROCESS_ACTION = 'bw_lead_ai_reprocess';
	const OUTCOME_ACTION   = 'bw_lead_ai_set_outcome';

	/**
	 * The scopes the Reprocess form offers, in days (0 = everything).
	 *
	 * A closed list because it is also the validation: the handler rejects anything
	 * not in here rather than trusting a posted day count, so a hand-crafted POST
	 * cannot aim the rewrite at an arbitrary window.
	 */
	const REPROCESS_SCOPES = array( 0, 30, 90, 365 );

	private static $instance = null;

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

	public function register() {
		add_action( 'admin_menu', array( $this, 'menu' ), 20 );
		add_action( 'admin_post_' . self::PURGE_ACTION, array( $this, 'handle_purge' ) );
		add_action( 'admin_post_' . self::REPROCESS_ACTION, array( $this, 'handle_reprocess' ) );
		add_action( 'admin_post_' . self::OUTCOME_ACTION, array( $this, 'handle_outcome' ) );
	}

	/**
	 * Capability required to read a stored journey.
	 *
	 * Defaults to the plugin's own read capability now that there is one — this
	 * filter predates BW_Lead_AI_Caps and was the first place the idea appeared,
	 * so it stays as the narrower override a site may already be using. Filtering
	 * the general read capability moves this with it; filtering this one moves
	 * journeys alone.
	 */
	public static function view_capability() {
		return apply_filters( 'bw_lead_ai_view_journey_capability', BW_Lead_AI_Caps::view() );
	}

	/**
	 * Does this site have a journey store at all? True once anything has created
	 * the table — enabling handoff, or importing history.
	 */
	public static function has_stored_journeys() {
		return (int) get_option( BW_Lead_AI_Handoff_Store::SCHEMA_OPTION ) > 0;
	}

	/**
	 * Does this site get a Reports entry, and with it the Journeys tab?
	 *
	 * The union of two conditions that used to gate two separate menu entries, now
	 * that one page carries both. has_stored_journeys() answers "is there anything to
	 * read", and handoff_enabled() covers the gap before the first journey arrives on
	 * a site that has just been configured to store them — without it, a site that
	 * turned handoff on this morning would have no way to reach the list it is about
	 * to fill.
	 */
	public static function journeys_ui_enabled() {
		return BW_Lead_AI_Settings::handoff_enabled() || self::has_stored_journeys();
	}

	/**
	 * The query args that address the journeys list: the Reports page, Journeys view.
	 *
	 * One place, because every link into the list — the viewer's back link, the
	 * status filters, the search form's hidden inputs, the per-row View journey
	 * button and the redirect from the old slug — has to agree, and a list whose own
	 * links drop the view parameter walks the reader back to the aggregate report on
	 * their first click.
	 */
	public static function list_args() {
		return array(
			'page' => BW_Lead_AI_Admin::REPORTS_SLUG,
			'view' => 'journeys',
		);
	}

	public static function viewer_url( $token = '' ) {
		$url = self::list_url();
		return $token ? add_query_arg( 'token', rawurlencode( $token ), $url ) : $url;
	}

	public static function list_url() {
		return add_query_arg( self::list_args(), admin_url( 'admin.php' ) );
	}

	/**
	 * The pre-1.7.0 viewer path, kept alive purely as a redirect.
	 *
	 * Journey links are written into records on a third-party system the moment a
	 * visitor converts, and they are meant to still work years later. Moving the
	 * admin menu changed the path, so every link already stored elsewhere would
	 * dead-end without this. Registering the old slug as a hidden Settings submenu
	 * is what keeps WordPress routing it at all.
	 */
	public static function legacy_viewer_url( $token = '' ) {
		$url = admin_url( 'options-general.php?page=' . self::PAGE_SLUG );
		return $token ? add_query_arg( 'token', rawurlencode( $token ), $url ) : $url;
	}

	/**
	 * Forward any older address for a journey to the Journeys view of Reports.
	 *
	 * Serves both of them: the pre-1.7.0 Settings path, where it is the registered
	 * page's callback, and the list's own former slug, where
	 * BW_Lead_AI_Admin::redirect_moved_screens() calls it on admin_init. One handler
	 * because the answer is the same either way, and because a second copy of this
	 * would be a second place for the carried parameters to fall out of step.
	 *
	 * Everything the list can be addressed by rides along — the token and the record
	 * hash so a CRM link opens the exact journey, the search, status and page so a
	 * bookmark of a filtered list opens filtered.
	 *
	 * On a site with no journeys the Reports page is not registered, so the fallback
	 * is Settings: a screen that always exists. That one is a 302, not a 301 — it is
	 * true only while the site has no journeys, and a browser that cached it
	 * permanently would keep sending journey links to Settings forever afterwards.
	 */
	public function redirect_legacy_journey_url() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only passthrough of an opaque token.
		$args = array();
		foreach ( array( 'token', 'record', 's', 'status', 'paged' ) as $key ) {
			if ( isset( $_GET[ $key ] ) ) {
				$args[ $key ] = sanitize_text_field( wp_unslash( $_GET[ $key ] ) );
			}
		}
		// phpcs:enable

		if ( ! self::journeys_ui_enabled() ) {
			wp_safe_redirect( add_query_arg( array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG ), admin_url( 'admin.php' ) ), 302 );
			exit;
		}

		$target = add_query_arg(
			array_merge( self::list_args(), $args ),
			admin_url( 'admin.php' )
		);
		wp_safe_redirect( $target, 301 );
		exit;
	}

	/**
	 * The journeys list has no menu entry of its own: it is the Journeys view of the
	 * Reports page, registered by BW_Lead_AI_Admin, and everything on that page's
	 * hook — this stylesheet included — is enqueued there. Only the legacy viewer
	 * redirect is registered here now, and a redirect renders nothing to style.
	 */
	public function menu() {
		// Keep links stored elsewhere working — see legacy_viewer_url().
		add_submenu_page(
			'options-general.php',
			__( 'Visitor journey', 'bw-lead-ai' ),
			__( 'Visitor journey', 'bw-lead-ai' ),
			self::view_capability(),
			self::PAGE_SLUG,
			array( $this, 'redirect_legacy_journey_url' )
		);
		remove_submenu_page( 'options-general.php', self::PAGE_SLUG );
	}

	public function handle_purge() {
		// Purging and reprocessing rewrite the archive. Reading it is the view
		// capability; changing it is not.
		if ( ! BW_Lead_AI_Caps::can_manage() ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'bw-lead-ai' ) );
		}
		check_admin_referer( self::PURGE_ACTION );
		$deleted = BW_Lead_AI_Handoff_Store::purge_all();
		wp_safe_redirect(
			add_query_arg(
				array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => 'journeys', 'bw_purged' => (int) $deleted ),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

	/**
	 * Re-derive attribution on stored journeys using the site's current settings.
	 *
	 * Sits alongside the purge handler on purpose: both are destructive-ish actions
	 * on the journey archive that live outside the settings form, and keeping them in
	 * one place means one pattern to get right — capability, nonce, do the work,
	 * redirect with a result. Never hooked to anything automatic; a visitor's stored
	 * history is only ever rewritten because someone chose to.
	 */
	public function handle_reprocess() {
		// Purging and reprocessing rewrite the archive. Reading it is the view
		// capability; changing it is not.
		if ( ! BW_Lead_AI_Caps::can_manage() ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'bw-lead-ai' ) );
		}
		check_admin_referer( self::REPROCESS_ACTION );

		// Through the plugin's single POST reader — see BW_Lead_AI_Admin::posted().
		$days = absint( BW_Lead_AI_Admin::posted( 'bw_reprocess_days' ) );
		if ( ! in_array( $days, self::REPROCESS_SCOPES, true ) ) {
			$days = 0;
		}

		$result = BW_Lead_AI_Reprocess::run( $days, true );
		BW_Lead_AI_Reprocess::record_run( (int) $result['changed'] );

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'           => BW_Lead_AI_Admin::PAGE_SLUG,
					'tab'            => 'journeys',
					'bw_reprocessed' => (int) $result['changed'],
					'bw_examined'    => (int) $result['examined'],
				),
				admin_url( 'admin.php' )
			)
		);
		exit;
	}

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

	/** Stored timestamps are GMT; show them in the site's timezone and format. */
	private function local_dt( $gmt, $with_time = true ) {
		if ( empty( $gmt ) ) {
			return '';
		}
		$format = get_option( 'date_format' ) . ( $with_time ? ' ' . get_option( 'time_format' ) : '' );
		return mysql2date( $format, get_date_from_gmt( $gmt ) );
	}

	/**
	 * A journey timestamp (epoch ms, from the browser) in the site's timezone and
	 * date format. Falls back to whatever string the client sent when there is no
	 * usable timestamp, so older records still render.
	 */
	private function journey_dt( $ms, $fallback = '', $with_time = true ) {
		$ms = (int) $ms;
		if ( $ms <= 0 ) {
			return (string) $fallback;
		}
		$format = get_option( 'date_format' ) . ( $with_time ? ' ' . get_option( 'time_format' ) : '' );
		return wp_date( $format, (int) floor( $ms / 1000 ) );
	}

	/**
	 * The outcome control on a single journey — the same one the list carries.
	 *
	 * Directly under the hero rather than at the foot of the page: somebody
	 * reading one enquiry to decide what it is should not have to scroll past the
	 * whole browsing history to say so.
	 */
	private function render_outcome_form( $row ) {
		if ( ! BW_Lead_AI_Caps::can_view() ) {
			return;
		}
		$outcomes = BW_Lead_AI_Handoff_Store::outcomes();
		$current  = isset( $row['outcome'] ) ? (string) $row['outcome'] : '';

		echo '<form method="post" class="bw-outcome-single" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::OUTCOME_ACTION ) . '">';
		echo '<input type="hidden" name="ret[record]" value="' . esc_attr( (string) $row['token_hash'] ) . '">';
		wp_nonce_field( self::OUTCOME_ACTION );

		echo '<label for="bw-outcome-single"><strong>' . esc_html__( 'Outcome', 'bw-lead-ai' ) . '</strong></label> ';
		echo '<select id="bw-outcome-single" name="outcome[' . esc_attr( (string) $row['token_hash'] ) . ']">';
		echo '<option value=""' . selected( $current, '', false ) . '>' . esc_html__( 'Not set', 'bw-lead-ai' ) . '</option>';
		foreach ( $outcomes as $key => $label ) {
			echo '<option value="' . esc_attr( $key ) . '"' . selected( $current, $key, false ) . '>' . esc_html( $label ) . '</option>';
		}
		echo '</select> ';
		submit_button( __( 'Save', 'bw-lead-ai' ), 'secondary', 'submit', false );

		if ( ! empty( $row['outcome_at'] ) ) {
			$who = ! empty( $row['outcome_by'] ) ? get_userdata( (int) $row['outcome_by'] ) : null;
			echo ' <span class="description">' . esc_html(
				$who
					? sprintf(
						/* translators: 1: a person's name, 2: a date. */
						__( 'Set by %1$s on %2$s', 'bw-lead-ai' ),
						$who->display_name,
						$this->local_dt( $row['outcome_at'], false )
					)
					: sprintf(
						/* translators: %s: a date. */
						__( 'Set on %s', 'bw-lead-ai' ),
						$this->local_dt( $row['outcome_at'], false )
					)
			) . '</span>';
		}
		echo '</form>';
	}

	/**
	 * The outcome cell: a select when the reader may set one, a label when not.
	 *
	 * Keyed by token hash rather than row id, because the hash is what already
	 * addresses a journey everywhere else on this screen and it is what the
	 * handler can validate the shape of without a database round trip.
	 *
	 * Returns escaped markup — every value that reaches the page goes through
	 * esc_attr() or esc_html() here, which is what the caller's phpcs:ignore
	 * refers to.
	 */
	private function outcome_control( $row, $outcomes, $can_flag ) {
		$current = isset( $row['outcome'] ) ? (string) $row['outcome'] : '';
		$hash    = isset( $row['token_hash'] ) ? (string) $row['token_hash'] : '';

		if ( ! $can_flag || '' === $hash ) {
			return '' === $current
				? '<span class="bw-outcome bw-outcome--none">&mdash;</span>'
				: '<span class="bw-outcome bw-outcome--' . esc_attr( $current ) . '">'
					. esc_html( isset( $outcomes[ $current ] ) ? $outcomes[ $current ] : $current ) . '</span>';
		}

		$out  = '<label class="screen-reader-text" for="bw-outcome-' . esc_attr( $hash ) . '">'
			. esc_html__( 'Outcome for this journey', 'bw-lead-ai' ) . '</label>';
		$out .= '<select id="bw-outcome-' . esc_attr( $hash ) . '" name="outcome[' . esc_attr( $hash ) . ']"'
			. ' class="bw-outcome-select bw-outcome--' . esc_attr( '' === $current ? 'none' : $current ) . '">';
		// The empty option is first and is a real choice — it is how a mis-click
		// gets undone, so it is worded as a state rather than as a blank.
		$out .= '<option value=""' . selected( $current, '', false ) . '>'
			. esc_html__( 'Not set', 'bw-lead-ai' ) . '</option>';
		foreach ( $outcomes as $key => $label ) {
			$out .= '<option value="' . esc_attr( $key ) . '"' . selected( $current, $key, false ) . '>'
				. esc_html( $label ) . '</option>';
		}
		$out .= '</select>';
		return $out;
	}

	/**
	 * Save whatever outcomes the reader just set.
	 *
	 * The same four steps every state-changing handler here takes — capability,
	 * nonce, work, redirect — with one thing worth naming: this is gated on the
	 * VIEW capability, not manage.
	 *
	 * That is deliberate. The split those capabilities draw is between reading the
	 * reports and changing what the site COLLECTS; deciding an enquiry was spam is
	 * neither. It is the daily work of exactly the person who was given read
	 * access — an admissions officer, a salesperson — and gating it on manage
	 * would hand them a screen full of enquiries they can read and cannot triage.
	 * Nothing here touches a setting, and every value is reversible.
	 */
	public function handle_outcome() {
		if ( ! BW_Lead_AI_Caps::can_view() ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'bw-lead-ai' ) );
		}
		check_admin_referer( self::OUTCOME_ACTION );

		$posted  = BW_Lead_AI_Admin::posted( 'outcome' );
		$changed = 0;
		if ( is_array( $posted ) ) {
			$user = get_current_user_id();
			foreach ( $posted as $hash => $value ) {
				$value = sanitize_key( (string) $value );
				// Unknown keys and unknown values are dropped rather than stored:
				// the register is the whitelist, so a crafted POST can only ever
				// write one of the values this site actually offers.
				if ( ! BW_Lead_AI_Handoff_Store::is_outcome( $value ) ) {
					continue;
				}
				if ( BW_Lead_AI_Handoff_Store::set_outcome( (string) $hash, $value, $user ) ) {
					$changed++;
				}
			}
		}

		$ret  = BW_Lead_AI_Admin::posted( 'ret' );
		$args = self::list_args();
		if ( is_array( $ret ) && ! empty( $ret['record'] ) ) {
			// Saved from a single journey — go back to it, not to the list.
			$args['record'] = sanitize_text_field( (string) $ret['record'] );
		} elseif ( is_array( $ret ) ) {
			foreach ( array( 'status', 's', 'paged' ) as $key ) {
				if ( ! empty( $ret[ $key ] ) ) {
					$args[ $key ] = sanitize_text_field( (string) $ret[ $key ] );
				}
			}
		}
		$args['bw_outcomes'] = $changed;

		wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
		exit;
	}

	/**
	 * Put every conversion in exactly one visit, by visit index.
	 *
	 * Anchored on `visit_ts` — the browser clock of the visit that was running
	 * when the server recorded it — because the two clocks cannot be compared.
	 *
	 * **Every conversion is placed somewhere.** A journey keeps only the first
	 * and last few visits in full, so the visit a conversion names can have been
	 * trimmed out of the blob by the time anyone reads it; an older record has no
	 * anchor at all. Dropping those would take the one thing on this page that
	 * says the visitor converted and remove it precisely on the longest journeys.
	 * Unmatched conversions fall to the last visit, which is the nearest true
	 * thing: the submission happened at or after everything still on record.
	 *
	 * @param array $conversions Server-recorded submissions.
	 * @param array $visits      The journey's visits, re-indexed from zero.
	 * @return array visit index => list of conversions.
	 */
	private function conversions_by_visit( $conversions, $visits ) {
		$out  = array();
		$last = count( $visits ) - 1;
		if ( $last < 0 ) {
			return $out;
		}

		$at = array();
		foreach ( $visits as $i => $visit ) {
			if ( ! empty( $visit['ts'] ) ) {
				$at[ (int) $visit['ts'] ] = $i;
			}
		}

		foreach ( (array) $conversions as $c ) {
			if ( ! is_array( $c ) ) {
				continue;
			}
			$key   = isset( $c['visit_ts'] ) ? (int) $c['visit_ts'] : 0;
			$index = isset( $at[ $key ] ) ? $at[ $key ] : $last;
			$out[ $index ][] = $c;
		}
		return $out;
	}

	/**
	 * Clock time for a conversion, from the GMT datetime the server stamped.
	 *
	 * Every other row on the timeline is a browser timestamp; this one is not,
	 * which is why it does not go through journey_dt(). Same display format, so
	 * the column still reads as one column.
	 */
	private function conversion_clock( $gmt ) {
		$ts = strtotime( (string) $gmt . ' +00:00' );
		return $ts ? wp_date( get_option( 'time_format' ), $ts ) : '';
	}

	/** Shorten a URL to its path for display, keeping the full value in a title attr. */
	private function short_path( $url ) {
		$path = wp_parse_url( (string) $url, PHP_URL_PATH );
		return $path ? $path : (string) $url;
	}

	private function decode_journey( $payload ) {
		if ( empty( $payload['journey'] ) ) {
			return null;
		}
		$j = json_decode( (string) $payload['journey'], true );
		return is_array( $j ) ? $j : null;
	}

	/** "12 days" / "same day" between two ms-epoch values. */
	private function span_label( $from_ms, $to_ms ) {
		if ( ! $from_ms || ! $to_ms || $to_ms < $from_ms ) {
			return '';
		}
		$days = (int) floor( ( $to_ms - $from_ms ) / 86400000 );
		if ( $days < 1 ) {
			return __( 'same day', 'bw-lead-ai' );
		}
		/* translators: %s is a number of days. */
		return sprintf( _n( '%s day', '%s days', $days, 'bw-lead-ai' ), number_format_i18n( $days ) );
	}

	// --- the browse list -------------------------------------------------

	/**
	 * One registered page, two modes: a single journey when the URL carries a
	 * token or record, otherwise the list.
	 *
	 * Deliberately not a separate hidden page — and now not a separate page at all.
	 * Under a top-level menu, admin.php resolves the required capability by looking
	 * the page up in $submenu, so a page removed from the menu with
	 * remove_submenu_page() becomes unreachable ("Sorry, you are not allowed to
	 * access this page"). Both modes are therefore views of the registered Reports
	 * page, which calls this; nothing here is registered, so there is nothing to
	 * authorise around.
	 */
	public function render_page() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only view selection.
		$has_one = isset( $_GET['token'] ) || isset( $_GET['record'] );
		// phpcs:enable
		if ( $has_one ) {
			$this->render_viewer();
			return;
		}
		$this->render_list();
	}

	public function render_list() {
		if ( ! current_user_can( self::view_capability() ) ) {
			wp_die( esc_html__( 'You do not have permission to view this.', 'bw-lead-ai' ) );
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only list filters.
		$status = isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : BW_Lead_AI_Handoff_Store::STATUS_SAVED;
		$search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
		$paged  = isset( $_GET['paged'] ) ? max( 1, absint( wp_unslash( $_GET['paged'] ) ) ) : 1;
		// phpcs:enable

		$per_page = 25;
		$result   = BW_Lead_AI_Handoff_Store::list_records(
			array( 'status' => $status, 'search' => $search, 'page' => $paged, 'per_page' => $per_page )
		);
		$rows  = $result['rows'];
		$total = $result['total'];

		echo '<div class="wrap bw-lead-ai">';
		// The page heading stays "Reports" across every view, and the tab strip under
		// it says which one you are on — wp-admin's own nav-tab strip, the same
		// control the settings page uses. A heading that renamed itself per view
		// would make the tabs look like separate pages that happen to share a menu
		// entry.
		echo '<h1>' . esc_html__( 'Reports', 'bw-lead-ai' ) . '</h1>';
		BW_Lead_AI_Admin::render_reports_tabs( 'journeys' );
		echo '<h2>' . esc_html__( 'Visitor journeys', 'bw-lead-ai' ) . '</h2>';
		echo '<div class="description bw-copy">'
			. esc_html__( 'Journeys this site has kept a copy of.', 'bw-lead-ai' )
			. ' <details class="bw-help"><summary aria-label="' . esc_attr__( 'More information', 'bw-lead-ai' ) . '"></summary><span class="bw-help-body">'
			. esc_html__( 'Saved journeys were confirmed by a form submission and are kept for the retention period. Held ones are provisional and expire unless a submission confirms them.', 'bw-lead-ai' )
			. '</span></details></div>';

		// Filters.
		$counts = BW_Lead_AI_Handoff_Store::stats();
		$tabs   = array(
			'saved' => sprintf( __( 'Saved (%d)', 'bw-lead-ai' ), (int) $counts['saved'] ),
			'held'  => sprintf( __( 'Held (%d)', 'bw-lead-ai' ), (int) $counts['held'] ),
			'any'       => __( 'All', 'bw-lead-ai' ),
		);
		$flagged = BW_Lead_AI_Admin::notice_count( 'bw_outcomes', -1 );
		if ( $flagged >= 0 ) {
			echo '<div class="notice notice-success inline"><p>' . esc_html(
				$flagged > 0
					? sprintf(
						/* translators: %d: how many journeys were updated. */
						_n( '%d journey updated.', '%d journeys updated.', $flagged, 'bw-lead-ai' ),
						$flagged
					)
					: __( 'Nothing changed.', 'bw-lead-ai' )
			) . '</p></div>';
		}

		echo '<ul class="subsubsub">';
		$i = 0;
		foreach ( $tabs as $key => $label ) {
			$url = add_query_arg( array_merge( self::list_args(), array( 'status' => $key ) ), admin_url( 'admin.php' ) );
			echo '<li>' . ( $i++ ? ' | ' : '' )
				. '<a href="' . esc_url( $url ) . '"' . ( $status === $key ? ' class="current"' : '' ) . '>'
				. esc_html( $label ) . '</a></li>';
		}
		echo '</ul>';

		// Every arg that addresses this view has to ride along as a hidden input: a GET
		// form replaces the whole query string, so a missing `view` would search the
		// journeys and land the reader on the aggregate report.
		echo '<form method="get" style="margin:12px 0;">';
		foreach ( self::list_args() as $arg_key => $arg_val ) {
			echo '<input type="hidden" name="' . esc_attr( $arg_key ) . '" value="' . esc_attr( $arg_val ) . '">';
		}
		echo '<input type="hidden" name="status" value="' . esc_attr( $status ) . '">';
		echo '<input type="search" name="s" value="' . esc_attr( $search ) . '" placeholder="'
			. esc_attr__( 'Search channel, campaign, page…', 'bw-lead-ai' ) . '" class="regular-text">';
		submit_button( __( 'Search', 'bw-lead-ai' ), '', '', false );
		echo '</form>';

		if ( empty( $rows ) ) {
			echo '<div class="notice notice-info inline"><p>'
				. esc_html__( 'No journeys yet.', 'bw-lead-ai' ) . '</p></div></div>';
			return;
		}

		$show_person = BW_Lead_AI_Settings::capture_identity();
		$outcomes    = BW_Lead_AI_Handoff_Store::outcomes();
		$can_flag    = BW_Lead_AI_Caps::can_view();

		/*
		 * One form around the whole table, with one save at the bottom.
		 *
		 * Triage is a scanning job — you read down twenty-five rows deciding which
		 * were real — so a control that reloads the page on every decision turns a
		 * two-minute pass into twenty-five page loads. Setting several and saving
		 * once is both faster and the only shape that needs no JavaScript, which
		 * matters for a control whose whole job is to still be there in five years.
		 *
		 * It cannot nest inside the search form above; that one is closed before
		 * this opens, and a nested form would be invalid HTML that browsers resolve
		 * by silently dropping one of them.
		 */
		if ( $can_flag ) {
			echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
			echo '<input type="hidden" name="action" value="' . esc_attr( self::OUTCOME_ACTION ) . '">';
			foreach ( array_merge( self::list_args(), array( 'status' => $status, 's' => $search, 'paged' => $paged ) ) as $arg_key => $arg_val ) {
				echo '<input type="hidden" name="ret[' . esc_attr( $arg_key ) . ']" value="' . esc_attr( $arg_val ) . '">';
			}
			wp_nonce_field( self::OUTCOME_ACTION );
		}

		echo '<table class="widefat striped"><thead><tr>';
		echo '<th>' . esc_html__( 'Enquired', 'bw-lead-ai' ) . '</th>';
		if ( $show_person ) {
			echo '<th>' . esc_html__( 'Who', 'bw-lead-ai' ) . '</th>';
		}
		echo '<th>' . esc_html__( 'Channel', 'bw-lead-ai' ) . '</th>';
		echo '<th>' . esc_html__( 'Campaign', 'bw-lead-ai' ) . '</th>';
		echo '<th>' . esc_html__( 'Engagement', 'bw-lead-ai' ) . '</th>';
		echo '<th>' . esc_html__( 'Reported by', 'bw-lead-ai' ) . '</th>';
		echo '<th>' . esc_html__( 'Outcome', 'bw-lead-ai' ) . '</th>';
		echo '<th></th></tr></thead><tbody>';

		foreach ( $rows as $row ) {
			$p       = $row['payload'];
			$journey = $this->decode_journey( $p );
			$when    = $row['confirmed_at'] ? $row['confirmed_at'] : $row['created_at'];
			$url     = add_query_arg(
				array_merge( self::list_args(), array( 'record' => $row['token_hash'] ) ),
				admin_url( 'admin.php' )
			);

			$visits = $journey && isset( $journey['counts']['visits'] ) ? (int) $journey['counts']['visits'] : (int) ( isset( $p['visits'] ) ? $p['visits'] : 0 );
			$pages  = $journey && isset( $journey['counts']['pages'] ) ? (int) $journey['counts']['pages'] : (int) ( isset( $p['pages'] ) ? $p['pages'] : 0 );

			echo '<tr>';
			echo '<td><a href="' . esc_url( $url ) . '" title="' . esc_attr( $this->local_dt( $when ) ) . '"><strong>'
				. esc_html( $this->local_dt( $when, false ) ) . '</strong></a>';
			if ( BW_Lead_AI_Handoff_Store::STATUS_HELD === $row['status'] ) {
				echo '<br><span class="bw-lead-ai-simlinks-sm">' . esc_html__( 'held — not saved yet', 'bw-lead-ai' ) . '</span>';
			}
			echo '</td>';
			if ( $show_person ) {
				$who = BW_Lead_AI_Identity::display_name( isset( $row['identity'] ) ? $row['identity'] : array() );
				echo '<td>' . ( '' !== $who ? '<strong>' . esc_html( $who ) . '</strong>' : '—' ) . '</td>';
			}
			echo '<td>' . esc_html( isset( $p['channel'] ) ? $p['channel'] : '—' ) . '</td>';
			echo '<td>' . esc_html( ! empty( $p['campaign'] ) ? $p['campaign'] : '—' ) . '</td>';
			echo '<td>' . esc_html(
				sprintf(
					/* translators: 1: visit count, 2: page count. */
					__( '%1$d visits · %2$d pages', 'bw-lead-ai' ),
					$visits,
					$pages
				)
			) . '</td>';
			echo '<td>' . esc_html( ! empty( $row['context'] ) ? $row['context'] : '—' ) . '</td>';
			echo '<td>' . $this->outcome_control( $row, $outcomes, $can_flag ) . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built escaped; see outcome_control().
			echo '<td><a class="button button-small" href="' . esc_url( $url ) . '">'
				. esc_html__( 'View journey', 'bw-lead-ai' ) . '</a></td>';
			echo '</tr>';
		}
		echo '</tbody></table>';

		if ( $can_flag ) {
			// Under the table, not above it: the decisions are made going down the
			// rows, so the button belongs where the reader finishes.
			echo '<p class="bw-outcome-save">';
			submit_button( __( 'Save outcomes', 'bw-lead-ai' ), 'secondary', 'submit', false );
			echo ' <span class="description">'
				. esc_html__( 'Set as many as you like, then save once.', 'bw-lead-ai' )
				. '</span></p>';
			echo '</form>';
		}

		$pages_total = (int) ceil( $total / $per_page );
		if ( $pages_total > 1 ) {
			echo '<div class="tablenav"><div class="tablenav-pages">';
			echo wp_kses_post(
				paginate_links(
					array(
						'base'      => add_query_arg( 'paged', '%#%' ),
						'format'    => '',
						'current'   => $paged,
						'total'     => $pages_total,
						'prev_text' => '&laquo;',
						'next_text' => '&raquo;',
					)
				)
			);
			echo '</div></div>';
		}

		echo '</div>';
	}

	// --- the single-journey report ---------------------------------------

	public function render_viewer() {
		if ( ! current_user_can( self::view_capability() ) ) {
			wp_die( esc_html__( 'You do not have permission to view this.', 'bw-lead-ai' ) );
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only lookup.
		$token  = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
		$record = isset( $_GET['record'] ) ? sanitize_text_field( wp_unslash( $_GET['record'] ) ) : '';
		// phpcs:enable

		// Reached either by the destination's stored token link, or from the browse
		// list, which only knows the hash.
		$row = '' !== $token
			? BW_Lead_AI_Handoff_Store::get_record( $token )
			: BW_Lead_AI_Handoff_Store::get_record_by_hash( $record );

		echo '<div class="wrap bw-lead-ai bw-journey">';

		if ( ! $row ) {
			echo '<h1>' . esc_html__( 'Visitor journey', 'bw-lead-ai' ) . '</h1>';
			echo '<div class="notice notice-warning"><p>'
				. esc_html__( 'No journey found for that link. It may have expired, or the visitor never completed the handoff.', 'bw-lead-ai' )
				. '</p></div>';
			echo '<p><a href="' . esc_url( self::list_url() ) . '">'
				. esc_html__( '← All journeys', 'bw-lead-ai' ) . '</a></p></div>';
			return;
		}

		$p           = $row['payload'];
		$journey     = $this->decode_journey( $p );
		$conversions = isset( $row['conversions'] ) ? $row['conversions'] : array();
		$enquiry     = $this->enquiry_touch( $journey, $conversions );

		$this->render_hero( $row, $p, $journey, $enquiry );
		$this->render_outcome_form( $row );
		$this->render_touchpoints( $journey, $enquiry, $conversions );
		$this->render_stats( $p, $journey );
		$this->render_timeline( $journey, $conversions );
		$this->render_details( $row, $p, $journey );

		echo '</div>';
	}

	private function render_hero( $row, $p, $journey, $enquiry = null ) {
		// The channel they ENQUIRED through, not the newest one on the record —
		// see enquiry_touch(). The payload's own channel is whatever the browser
		// last reported, and it keeps moving after the enquiry.
		$channel = ( $enquiry && ! empty( $enquiry['channel'] ) ) ? $enquiry['channel'] : '';
		if ( '' === $channel ) {
			$channel = ! empty( $p['channel'] ) ? $p['channel'] : __( 'Unknown channel', 'bw-lead-ai' );
		}
		$confirmed = BW_Lead_AI_Handoff_Store::STATUS_SAVED === $row['status'];
		$when      = $row['confirmed_at'] ? $row['confirmed_at'] : $row['created_at'];

		echo '<p class="bw-journey-back"><a href="' . esc_url( self::list_url() ) . '">'
			. esc_html__( '← All journeys', 'bw-lead-ai' ) . '</a></p>';

		$identity = isset( $row['identity'] ) ? $row['identity'] : array();
		$name     = BW_Lead_AI_Identity::display_name( $identity );

		echo '<div class="bw-journey-hero">';
		if ( '' !== $name ) {
			echo '<span class="bw-journey-eyebrow">' . esc_html__( 'Visitor journey', 'bw-lead-ai' ) . '</span>';
			echo '<h1>' . esc_html( $name ) . '</h1>';
			$contact = array_filter(
				array(
					! empty( $identity['email'] ) ? $identity['email'] : '',
					! empty( $identity['phone'] ) ? $identity['phone'] : '',
				)
			);
			if ( $contact ) {
				echo '<p class="bw-journey-contact">' . esc_html( implode( '  ·  ', $contact ) ) . '</p>';
			}
			/* translators: %s is a marketing channel, e.g. "Google Ads". */
			echo '<p class="bw-journey-meta"><strong>' . esc_html( sprintf( __( 'Enquired via %s', 'bw-lead-ai' ), $channel ) ) . '</strong></p>';
		} else {
			echo '<span class="bw-journey-eyebrow">' . esc_html__( 'Enquired via', 'bw-lead-ai' ) . '</span>';
			echo '<h1>' . esc_html( $channel ) . '</h1>';
		}

		$meta = array( $this->local_dt( $when ) );
		// To the visit they enquired on, not to the newest one — "first visit to
		// enquiry" measured against a visit that happened after the enquiry is a
		// span that never elapsed.
		$ended = ( $enquiry && ! empty( $enquiry['ts'] ) ) ? $enquiry['ts'] : 0;
		if ( ! $ended && $journey && ! empty( $journey['last']['ts'] ) ) {
			$ended = $journey['last']['ts'];
		}
		if ( $journey && ! empty( $journey['first']['ts'] ) && $ended ) {
			$span = $this->span_label( $journey['first']['ts'], $ended );
			if ( $span ) {
				/* translators: %s is a length of time, e.g. "12 days". */
				$meta[] = sprintf( __( '%s from first visit to enquiry', 'bw-lead-ai' ), $span );
			}
		}
		echo '<p class="bw-journey-meta">' . esc_html( implode( '  ·  ', array_filter( $meta ) ) ) . '</p>';

		if ( ! $confirmed ) {
			echo '<p class="bw-journey-badge bw-journey-badge--pending">'
				. esc_html__( 'Awaiting confirmation — no submission reported yet', 'bw-lead-ai' ) . '</p>';
		} elseif ( ! empty( $row['context'] ) ) {
			/* translators: %s is a label supplied by the destination, e.g. "Enrolment form". */
			echo '<p class="bw-journey-badge">' . esc_html( sprintf( __( 'Reported by %s', 'bw-lead-ai' ), $row['context'] ) ) . '</p>';
		}
		echo '</div>';
	}

	/**
	 * The visit they actually enquired on.
	 *
	 * **Not `journey.last`.** That is the NEWEST visit, and a journey carries on
	 * recording after the enquiry — so on a visitor who converted and then came
	 * back twice, `last` is a visit that happened after the thing this page is
	 * about, and the card would read "came back and enquired through" a channel
	 * they never enquired through. Same drift the reports had; this screen is the
	 * second place it surfaces.
	 *
	 * The conversion names the visit it happened on, so that visit is looked up
	 * by its own timestamp. A long journey keeps only the first and last few
	 * visits, so when the named one has been trimmed away the conversion's own
	 * frozen fields stand in — without a page or a time, because we genuinely no
	 * longer hold that visit and inventing one would be worse than the gap.
	 *
	 * Falls back to `journey.last` only for records stored before conversions
	 * were written, which is exactly the set whose credit has not been recovered
	 * either — so the card and the report still agree with each other.
	 */
	private function enquiry_touch( $journey, $conversions ) {
		$last = ( $journey && ! empty( $journey['last'] ) ) ? $journey['last'] : null;

		$c = ( is_array( $conversions ) && isset( $conversions[0] ) && is_array( $conversions[0] ) ) ? $conversions[0] : null;
		if ( ! $c ) {
			return $last;
		}

		$want = isset( $c['visit_ts'] ) ? (int) $c['visit_ts'] : 0;
		if ( $want && ! empty( $journey['visits'] ) && is_array( $journey['visits'] ) ) {
			foreach ( $journey['visits'] as $visit ) {
				if ( is_array( $visit ) && isset( $visit['ts'] ) && (int) $visit['ts'] === $want ) {
					return $visit;
				}
			}
		}

		$stand_in = array( 'ts' => 0, 'date' => '', 'page' => '' );
		foreach ( array( 'channel', 'source', 'medium', 'campaign', 'term' ) as $key ) {
			$stand_in[ $key ] = isset( $c[ $key ] ) ? (string) $c[ $key ] : '';
		}
		return ( '' === $stand_in['channel'] ) ? $last : $stand_in;
	}

	/**
	 * First touch vs the touch they enquired on — only shown as two columns when
	 * they differ, because on a same-session enquiry repeating the same thing
	 * twice is clutter.
	 */
	private function render_touchpoints( $journey, $enquiry, $conversions = array() ) {
		$first = $journey && ! empty( $journey['first'] ) ? $journey['first'] : null;
		if ( ! $first && ! $enquiry ) {
			return;
		}

		$differs = $first && $enquiry
			&& ( ( $first['channel'] !== $enquiry['channel'] ) || ( $first['campaign'] !== $enquiry['campaign'] ) );

		echo '<h2>' . esc_html__( 'How they found us', 'bw-lead-ai' ) . '</h2>';
		echo '<div class="bw-journey-touchpoints">';

		if ( $differs && $first ) {
			echo '<div class="bw-journey-card">';
			echo '<h3>' . esc_html__( 'First found us through', 'bw-lead-ai' ) . '</h3>';
			$this->render_touch_body( $first );
			echo '</div>';
		}

		if ( $enquiry ) {
			echo '<div class="bw-journey-card">';
			echo '<h3>' . esc_html( $differs ? __( 'Came back and enquired through', 'bw-lead-ai' ) : __( 'Found us and enquired through', 'bw-lead-ai' ) ) . '</h3>';
			$this->render_touch_body( $enquiry );
			echo '</div>';
		}
		echo '</div>';

		if ( $differs ) {
			echo '<p class="description bw-journey-note">'
				. esc_html__( 'They arrived by one route and enquired by another — the first created awareness, the second converted.', 'bw-lead-ai' )
				. '</p>';
		}

		// Say so when there are visits after the enquiry, rather than leaving a
		// reader to wonder why the newest source they can see in the timeline is
		// not the one being credited above.
		$later = $this->visits_after_enquiry( $journey, $conversions );
		if ( $later > 0 ) {
			echo '<p class="description bw-journey-note">' . esc_html(
				sprintf(
					/* translators: %d is a number of visits. */
					_n(
						'They came back %d time after enquiring. That visit is in the timeline below and does not take credit for an enquiry that had already happened.',
						'They came back %d times after enquiring. Those visits are in the timeline below and do not take credit for an enquiry that had already happened.',
						$later,
						'bw-lead-ai'
					),
					$later
				)
			) . '</p>';
		}
	}

	/** How many visits are recorded after the one they enquired on. */
	private function visits_after_enquiry( $journey, $conversions ) {
		if ( empty( $conversions[0]['visit_ts'] ) || empty( $journey['visits'] ) || ! is_array( $journey['visits'] ) ) {
			return 0;
		}
		$at    = (int) $conversions[0]['visit_ts'];
		$later = 0;
		foreach ( $journey['visits'] as $visit ) {
			if ( is_array( $visit ) && ! empty( $visit['ts'] ) && (int) $visit['ts'] > $at ) {
				$later++;
			}
		}
		return $later;
	}

	private function render_touch_body( $touch ) {
		echo '<p class="bw-journey-channel">' . esc_html( $touch['channel'] ? $touch['channel'] : '—' ) . '</p>';
		$bits = array();
		$when = $this->journey_dt( isset( $touch['ts'] ) ? $touch['ts'] : 0, isset( $touch['date'] ) ? $touch['date'] : '' );
		if ( '' !== $when )                 { $bits[] = $when; }
		if ( ! empty( $touch['campaign'] ) ) { $bits[] = sprintf( __( 'Campaign: %s', 'bw-lead-ai' ), $touch['campaign'] ); }
		if ( ! empty( $touch['term'] ) )     { $bits[] = sprintf( __( 'Searched: %s', 'bw-lead-ai' ), $touch['term'] ); }
		if ( $bits ) {
			echo '<p class="bw-journey-sub">' . esc_html( implode( '  ·  ', $bits ) ) . '</p>';
		}
		if ( ! empty( $touch['page'] ) ) {
			echo '<p class="bw-journey-sub"><a href="' . esc_url( $touch['page'] ) . '" target="_blank" rel="noopener">'
				. esc_html( $this->short_path( $touch['page'] ) ) . '</a></p>';
		}
	}

	private function render_stats( $p, $journey ) {
		$visits = $journey && isset( $journey['counts']['visits'] ) ? (int) $journey['counts']['visits'] : (int) ( isset( $p['visits'] ) ? $p['visits'] : 0 );
		$pages  = $journey && isset( $journey['counts']['pages'] ) ? (int) $journey['counts']['pages'] : (int) ( isset( $p['pages'] ) ? $p['pages'] : 0 );
		$events = 0;
		if ( $journey && ! empty( $journey['eventCounts'] ) ) {
			foreach ( $journey['eventCounts'] as $n ) { $events += (int) $n; }
		}
		$span = ( $journey && ! empty( $journey['first']['ts'] ) && ! empty( $journey['last']['ts'] ) )
			? $this->span_label( $journey['first']['ts'], $journey['last']['ts'] )
			: '';

		$tiles = array(
			array( $visits, _n( 'visit', 'visits', $visits, 'bw-lead-ai' ) ),
			array( $pages, _n( 'page viewed', 'pages viewed', $pages, 'bw-lead-ai' ) ),
		);
		if ( $events ) {
			$tiles[] = array( $events, _n( 'interaction', 'interactions', $events, 'bw-lead-ai' ) );
		}
		if ( $span ) {
			$tiles[] = array( $span, __( 'to enquiry', 'bw-lead-ai' ) );
		}

		echo '<div class="bw-journey-stats">';
		foreach ( $tiles as $tile ) {
			echo '<div class="bw-journey-stat"><span class="bw-journey-stat-n">' . esc_html( $tile[0] )
				. '</span><span class="bw-journey-stat-l">' . esc_html( $tile[1] ) . '</span></div>';
		}
		echo '</div>';
	}

	/**
	 * The main event: a timeline grouped by visit, with pages, interactions and
	 * submissions interleaved, **newest first**.
	 *
	 * @param array $journey     The decoded journey blob, as the browser sent it.
	 * @param array $conversions Submissions as the SERVER recorded them. A journey
	 *                           whose form lives on another domain has no other
	 *                           witness: the browser here never sees that submit
	 *                           event, so without these the timeline just stops at
	 *                           "left for <destination>" and the moment the visitor
	 *                           actually converted is nowhere on the page.
	 */
	private function render_timeline( $journey, $conversions = array() ) {
		echo '<h2>' . esc_html__( 'What they did', 'bw-lead-ai' ) . '</h2>';

		if ( ! $journey || empty( $journey['visits'] ) ) {
			echo '<div class="notice notice-info inline"><p>'
				. esc_html__( 'No browsing history was captured for this visitor. Tick "Full browsing history" under Settings → Journey Storage → Datapoints to collect it on future journeys.', 'bw-lead-ai' )
				. '</p></div>';
			return;
		}

		$visits = array_values( $journey['visits'] );
		$views  = isset( $journey['views'] ) ? $journey['views'] : array();
		$events = isset( $journey['events'] ) ? $journey['events'] : array();
		$subs   = isset( $journey['submissions'] ) ? $journey['submissions'] : array();

		$converted_by_visit = $this->conversions_by_visit( $conversions, $visits );

		/*
		 * Assembled oldest-first and RENDERED newest-first.
		 *
		 * The two have to be separate passes: a visit's window runs to the start of
		 * the NEXT visit, so the grouping can only be worked out in order, while the
		 * reading order people actually want is the other one — the question in front
		 * of somebody opening a journey is almost always "what did they just do", and
		 * on a long journey the answer was at the bottom of the page.
		 *
		 * Both levels flip, visits and the items inside them. Reversing only one
		 * gives newest-visit-first with oldest-item-first inside it, which is worse
		 * than either order on its own.
		 */
		$rows = array();

		foreach ( $visits as $i => $visit ) {
			$start = isset( $visit['ts'] ) ? (int) $visit['ts'] : 0;
			$end   = isset( $visits[ $i + 1 ]['ts'] ) ? (int) $visits[ $i + 1 ]['ts'] : PHP_INT_MAX;

			$converted = isset( $converted_by_visit[ $i ] ) ? $converted_by_visit[ $i ] : array();

			$items = array();
			foreach ( $views as $v ) {
				if ( ! empty( $v['ts'] ) && $v['ts'] >= $start && $v['ts'] < $end ) {
					$items[] = array( 'ts' => $v['ts'], 'kind' => 'page', 'text' => $v['page'] );
				}
			}
			foreach ( $events as $e ) {
				if ( ! empty( $e['ts'] ) && $e['ts'] >= $start && $e['ts'] < $end ) {
					$items[] = array( 'ts' => $e['ts'], 'kind' => 'event', 'text' => $e['label'], 'type' => $e['type'], 'detail' => $e['detail'] );
				}
			}
			// A submission the browser saw AND the server recorded is one
			// submission. The server's is the one kept — it knows the form's name
			// and it is the only one a cross-domain journey ever has.
			foreach ( $subs as $s ) {
				if ( $converted ) {
					continue;
				}
				if ( ! empty( $s['ts'] ) && $s['ts'] >= $start && $s['ts'] < $end ) {
					$items[] = array( 'ts' => $s['ts'], 'kind' => 'submit', 'text' => $s['page'] );
				}
			}
			foreach ( $converted as $c ) {
				/*
				 * Sorted by the SAME clock it is displayed with.
				 *
				 * It used to sort by `after_ts` — the last thing the browser had told
				 * us about before the submission — while showing the server's time,
				 * and those are not the same instant. Anything the browser reported
				 * after the save (the form-start event, the thank-you page) then
				 * landed below a row showing an earlier time, so the times column
				 * read 10:38, 10:40, 10:38, 10:40 and the sequence was unreadable.
				 *
				 * Sorting by the displayed time fixes that, and the cross-clock risk
				 * this file is careful about elsewhere does not apply here: which
				 * VISIT a conversion belongs to is still decided by `visit_ts`, on the
				 * browser's clock alone. This only orders it against its own visit,
				 * where both clocks are describing the same few minutes — and it is
				 * clamped so a badly-set device clock cannot push it out of that visit
				 * anyway, nor before the last thing we know it followed.
				 */
				$at = ! empty( $c['after_ts'] ) ? (int) $c['after_ts'] : 0;
				if ( ! empty( $c['ts'] ) ) {
					$secs = strtotime( (string) $c['ts'] . ' +00:00' );
					if ( $secs ) {
						$at = max( $at, $secs * 1000 );
					}
				}
				if ( ! $at ) {
					// No anchor and no clock: an older record. The end of the visit is
					// the honest place — the submission is the last thing we know of.
					$at = PHP_INT_MAX - 1;
				}
				if ( $at < $start ) {
					$at = $start;
				}
				if ( PHP_INT_MAX !== $end && $at >= $end ) {
					$at = $end - 1;
				}
				$items[] = array(
					'ts'      => $at,
					'order'   => 1,
					'kind'    => 'conversion',
					'text'    => isset( $c['context'] ) ? (string) $c['context'] : '',
					'clock'   => isset( $c['ts'] ) ? (string) $c['ts'] : '',
					'via'     => isset( $c['via'] ) ? (string) $c['via'] : '',
				);
			}
			// `order` breaks the tie a shared anchor creates: the conversion
			// happened on the page it is anchored to, so it comes after it.
			usort(
				$items,
				function ( $a, $b ) {
					if ( $a['ts'] !== $b['ts'] ) {
						return $a['ts'] <=> $b['ts'];
					}
					$ao = isset( $a['order'] ) ? $a['order'] : 0;
					$bo = isset( $b['order'] ) ? $b['order'] : 0;
					return $ao <=> $bo;
				}
			);

			if ( empty( $items ) && ! empty( $visit['page'] ) ) {
				$items[] = array( 'ts' => $start, 'kind' => 'page', 'text' => $visit['page'] );
			}

			$rows[] = array( 'visit' => $visit, 'items' => $items );
		}

		echo '<div class="bw-journey-timeline">';
		foreach ( array_reverse( $rows ) as $row ) {
			$visit = $row['visit'];
			$items = array_reverse( $row['items'] );

			$label = array_filter(
				array(
					$this->journey_dt( isset( $visit['ts'] ) ? $visit['ts'] : 0, isset( $visit['date'] ) ? $visit['date'] : '' ),
					$visit['channel'],
				)
			);
			echo '<div class="bw-journey-visit">';
			echo '<h3 class="bw-journey-visit-head">' . esc_html( implode( '  ·  ', $label ) ) . '</h3>';

			$sub = array_filter(
				array(
					! empty( $visit['campaign'] ) ? sprintf( __( 'Campaign: %s', 'bw-lead-ai' ), $visit['campaign'] ) : '',
					! empty( $visit['term'] ) ? sprintf( __( 'Searched: %s', 'bw-lead-ai' ), $visit['term'] ) : '',
				)
			);
			if ( $sub ) {
				echo '<p class="bw-journey-sub">' . esc_html( implode( '  ·  ', $sub ) ) . '</p>';
			}

			echo '<ul class="bw-journey-items">';
			foreach ( $items as $item ) {
				// A conversion carries the SERVER's clock, so it is the one item
				// here not read off a browser timestamp.
				$time = ( 'conversion' === $item['kind'] )
					? $this->conversion_clock( $item['clock'] )
					: ( $item['ts'] ? wp_date( get_option( 'time_format' ), (int) floor( $item['ts'] / 1000 ) ) : '' );

				echo '<li class="bw-journey-item bw-journey-item--' . esc_attr( $item['kind'] ) . '">';
				echo '<span class="bw-journey-time">' . esc_html( $time ) . '</span>';

				if ( 'conversion' === $item['kind'] ) {
					echo '<strong>' . esc_html__( 'Submitted the form', 'bw-lead-ai' ) . '</strong>';
					if ( '' !== $item['text'] ) {
						echo ' <span class="bw-journey-sub">' . esc_html( $item['text'] ) . '</span>';
					}
					if ( BW_Lead_AI_Handoff_Store::VIA_CONFIRM === $item['via'] ) {
						echo ' <span class="bw-journey-tag">' . esc_html__( 'reported by the destination', 'bw-lead-ai' ) . '</span>';
					}
				} elseif ( 'page' === $item['kind'] ) {
					echo '<a href="' . esc_url( $item['text'] ) . '" target="_blank" rel="noopener" title="' . esc_attr( $item['text'] ) . '">'
						. esc_html( $this->short_path( $item['text'] ) ) . '</a>';
				} elseif ( 'submit' === $item['kind'] ) {
					echo '<strong>' . esc_html__( 'Submitted a form', 'bw-lead-ai' ) . '</strong> '
						. '<span class="bw-journey-sub">' . esc_html( $this->short_path( $item['text'] ) ) . '</span>';
				} else {
					$verb = $this->event_verb( $item['type'] );
					echo '<strong>' . esc_html( $verb ) . '</strong> ' . esc_html( $item['text'] );
					if ( ! empty( $item['detail'] ) ) {
						echo ' <span class="bw-journey-sub">(' . esc_html( $item['detail'] ) . ')</span>';
					}
				}
				echo '</li>';
			}
			echo '</ul></div>';
		}
		echo '</div>';
		echo '<p class="description bw-journey-note">'
			. esc_html__( 'Most recent first. Times are shown in this site’s timezone, and only the first and last few visits are kept in full.', 'bw-lead-ai' )
			. '</p>';
	}

	/** Marketer-friendly phrasing for an interaction type. */
	private function event_verb( $type ) {
		$verbs = array(
			'video'      => __( 'Watched', 'bw-lead-ai' ),
			'download'   => __( 'Downloaded', 'bw-lead-ai' ),
			'phone'      => __( 'Clicked to call', 'bw-lead-ai' ),
			'email'      => __( 'Clicked to email', 'bw-lead-ai' ),
			'social'     => __( 'Went to social', 'bw-lead-ai' ),
			'outbound'   => __( 'Left for', 'bw-lead-ai' ),
			'custom'     => __( 'Did', 'bw-lead-ai' ),
			'scroll'     => __( 'Read down to', 'bw-lead-ai' ),
			'form_start' => __( 'Started filling in', 'bw-lead-ai' ),
		);
		return isset( $verbs[ $type ] ) ? $verbs[ $type ] : __( 'Did', 'bw-lead-ai' );
	}

	/**
	 * Everything else, folded away. Present so nothing is hidden, collapsed so the
	 * page stays readable.
	 */
	private function render_details( $row, $p, $journey ) {
		$shared = BW_Lead_AI_Settings::parse_handoff_share_datapoints();
		$hidden = array( 'journey', 'summary', 'summary_detailed' );

		echo '<p class="bw-journey-divider">' . esc_html__( 'More detail', 'bw-lead-ai' ) . '</p>';

		// All captured values, in plain labels.
		echo '<details class="bw-lead-ai-section"><summary><h3>' . esc_html__( 'Everything we captured', 'bw-lead-ai' ) . '</h3></summary>';
		echo '<table class="widefat striped"><tbody>';
		foreach ( $p as $key => $value ) {
			if ( in_array( $key, $hidden, true ) ) {
				continue;
			}
			echo '<tr><th scope="row" class="bw-journey-key">' . esc_html( BW_Lead_AI_Settings::handoff_datapoint_label( $key ) ) . '</th>';
			echo '<td>' . esc_html( '' === $value ? '—' : (string) $value );
			if ( in_array( $key, $shared, true ) ) {
				echo ' <span class="bw-journey-tag">' . esc_html__( 'sent to destination', 'bw-lead-ai' ) . '</span>';
			}
			echo '</td></tr>';
		}
		echo '</tbody></table></details>';

		// The written summaries, if they were captured.
		foreach ( array( 'summary', 'summary_detailed' ) as $key ) {
			if ( empty( $p[ $key ] ) ) {
				continue;
			}
			echo '<details class="bw-lead-ai-section"><summary><h3>'
				. esc_html( BW_Lead_AI_Settings::handoff_datapoint_label( $key ) ) . '</h3></summary>';
			echo '<div class="bw-journey-pre">' . nl2br( esc_html( (string) $p[ $key ] ) ) . '</div>';
			echo '</details>';
		}

		// What they actually filled in.
		if ( ! empty( $row['submission'] ) && is_array( $row['submission'] ) ) {
			echo '<details class="bw-lead-ai-section"><summary><h3>'
				. esc_html__( 'What they filled in', 'bw-lead-ai' ) . '</h3></summary>';
			echo '<table class="widefat striped"><tbody>';
			foreach ( $row['submission'] as $entry ) {
				if ( empty( $entry['label'] ) && empty( $entry['value'] ) ) {
					continue;
				}
				echo '<tr><th scope="row" style="width:260px;">' . esc_html( $entry['label'] ) . '</th>';
				echo '<td>' . nl2br( esc_html( (string) $entry['value'] ) ) . '</td></tr>';
			}
			echo '</tbody></table></details>';
		}

		// Record plumbing.
		echo '<details class="bw-lead-ai-section"><summary><h3>' . esc_html__( 'Record details', 'bw-lead-ai' ) . '</h3></summary>';
		echo '<table class="widefat striped"><tbody>';
		$meta = array(
			__( 'Journey captured', 'bw-lead-ai' )   => $this->local_dt( $row['created_at'] ),
			__( 'Enquiry confirmed', 'bw-lead-ai' )  => $row['confirmed_at'] ? $this->local_dt( $row['confirmed_at'] ) : __( 'not yet', 'bw-lead-ai' ),
			__( 'Reported by', 'bw-lead-ai' )        => ! empty( $row['context'] ) ? $row['context'] : '—',
			__( 'Data sent to destination', 'bw-lead-ai' ) => $row['data_claimed_at'] ? $this->local_dt( $row['data_claimed_at'] ) : __( 'not collected', 'bw-lead-ai' ),
			__( 'Kept until', 'bw-lead-ai' )         => ( BW_Lead_AI_Handoff_Store::NEVER === $row['expires_at'] )
				? __( 'kept indefinitely', 'bw-lead-ai' )
				: $this->local_dt( $row['expires_at'] ),
		);
		foreach ( $meta as $label => $value ) {
			echo '<tr><th scope="row" class="bw-journey-key">' . esc_html( $label ) . '</th><td>' . esc_html( $value ) . '</td></tr>';
		}
		echo '</tbody></table></details>';
	}
}
