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

/**
 * Admin UI: the Lead AI menu.
 *
 * Four submenu entries — Reports, UTM Builder, Help, Settings — and the settings
 * page's own tab strip underneath the last of them.
 */
class BW_Lead_AI_Admin {

	const PAGE_SLUG = 'bw-lead-ai';

	/**
	 * The Reports dashboard's own page, separate from the settings screen.
	 *
	 * Reports is the one screen here that is read rather than configured. As a tab on
	 * the settings page it was only reachable by someone already editing settings, and
	 * it inherited a tab strip of eight configuration screens above a dashboard, which
	 * is the wrong furniture for it.
	 */
	const REPORTS_SLUG = 'bw-lead-ai-reports';

	/**
	 * The four views of the Reports page, as a `view` parameter rather than four
	 * registered pages.
	 *
	 * The journeys list used to be its own submenu entry. It is now a tab of
	 * Reports, and the only safe way to un-list a page under a top-level menu is to
	 * stop registering it: admin.php resolves the capability to check by looking the
	 * slug up in $submenu, so a page hidden with remove_submenu_page() answers "you
	 * are not allowed to access this page" to everyone (docs/UI-REVIEW.md B1). So the
	 * list became a mode of a page that IS registered — the same move that made the
	 * single-journey report a mode of the list.
	 *
	 * `journeys` is FIRST among the non-default values in every sense that matters:
	 * it is bookmarked, the moved-slug redirect targets it, and journey links written
	 * into other people's systems years ago resolve through it. Its value must never
	 * change. The two added later — `quality` and `ai` — split the two blocks that
	 * had grown into whole screens of their own at the bottom of the aggregate
	 * report: the data-quality checks, which are now a register a reader can browse,
	 * and the AI Analysis package, which is the longest thing the plugin renders.
	 *
	 * Whitelisted through reports_pill_param() like every other axis of this page, so
	 * a value that is absent, misspelled or hostile is the aggregate report.
	 */
	const REPORTS_VIEWS = array( 'reports', 'quality', 'ai', 'journeys' );

	/** The UTM link builder, promoted out of the settings tab strip. */
	const UTM_SLUG = 'bw-lead-ai-utm';

	/** The merge-tag reference, promoted out of the settings tab strip. */
	const HELP_SLUG = 'bw-lead-ai-help';

	/**
	 * Which preset window the Reports page opens on.
	 *
	 * Thirty days is the only window that answers "is this month working?" — seven
	 * is too short to survive one quiet week, ninety blurs a change that started
	 * three weeks ago.
	 */
	const REPORTS_DEFAULT_RANGE = '30';

	/**
	 * What a row on the Reports page is, before anybody chooses otherwise.
	 *
	 * Channel groups, not channels. A channel list on a real site is twenty-odd
	 * rows, most of them one lead each, and the question people arrive with — "is
	 * paid working, is search working" — is answered by five. The finer dimensions
	 * are one click away for the moment somebody needs to see the tags underneath
	 * the labels; the landing view should answer the common question first.
	 */
	const REPORTS_DEFAULT_DIMENSION = 'groups';

	/**
	 * A field has to be present on at least this share of journeys before a block
	 * built on it is drawn at all.
	 *
	 * A ranking assembled from a tenth of the rows still reads as authoritative —
	 * nothing on the page says "this is a tenth of the picture", and nobody reads a
	 * table thinking it might be. Below the floor the block hides, the Reports tab
	 * says one is missing, and the reason is named in full on the Data Quality tab —
	 * which is the honest place for it.
	 */
	const REPORTS_COVERAGE_FLOOR = 20;

	/**
	 * admin-post action that streams the AI Analysis package as a file.
	 *
	 * A download cannot be a link straight to the Reports page: the package has to
	 * arrive with a Content-Disposition on it, and a screen that has already emitted
	 * wp-admin's header cannot send one. So it is its own request, with its own nonce
	 * and its own capability check — see handle_ai_export().
	 */
	const AI_EXPORT_ACTION = 'bw_lead_ai_ai_export';

	/**
	 * Dismiss / un-dismiss one data-quality finding.
	 *
	 * One action for both directions rather than two, because they are one decision
	 * with a sign on it, and two handlers is two places to forget a nonce. The
	 * direction rides in the form as `state`.
	 */
	const QUALITY_DISMISS_ACTION = 'bw_lead_ai_quality_dismiss';

	/** Download the site's portable settings as JSON. Same shape as the AI export. */
	const SETTINGS_EXPORT_ACTION = 'bw_lead_ai_settings_export';

	/**
	 * Step one of an import: validate the file and show what it would change.
	 *
	 * Two steps, not one. An import overwrites work somebody did by hand, and the
	 * only way to make that safe is to let them see the change before it happens —
	 * which a single POST cannot do, because a state-changing request has to redirect
	 * and a redirect cannot carry the file. So step one parks the validated payload in
	 * a short-lived transient and redirects to a preview; step two applies it.
	 */
	const SETTINGS_IMPORT_ACTION = 'bw_lead_ai_settings_import';

	/** Step two: apply the payload the preview showed. */
	const SETTINGS_IMPORT_APPLY_ACTION = 'bw_lead_ai_settings_import_apply';

	/** Put every setting back to the shipped defaults. */
	const SETTINGS_RESET_ACTION = 'bw_lead_ai_settings_reset';

	/** What has to be typed into the reset box, unlocalised on purpose — see the tab. */
	const SETTINGS_RESET_WORD = 'RESET';

	/**
	 * How long a parked import payload survives.
	 *
	 * Long enough to read a diff, short enough that a half-finished import does not
	 * sit in the options table for a week waiting to be applied by accident.
	 */
	const IMPORT_PREVIEW_TTL = 900;

	private static $instance = null;

	/** Hook suffix returned by add_menu_page(), so enqueue() never has to guess it. */
	private $hook_suffix = '';

	/**
	 * Hook suffix returned by add_submenu_page() for Reports — same reason as above.
	 * Empty when the page was never registered (no journey store, or the current user
	 * cannot view it), which is why enqueue() filters falsy entries out rather than
	 * comparing against it directly.
	 */
	private $reports_hook = '';

	/**
	 * Hook suffixes for the two pages promoted out of the settings tab strip.
	 *
	 * Same reason again, and the reason it is a property per page rather than a
	 * prefix match: enqueue() compares against what add_submenu_page() actually
	 * returned. A page whose hook is never captured keeps rendering and silently
	 * loses every stylesheet and script — the UTM builder's script in particular,
	 * without which its form is inert.
	 */
	private $utm_hook = '';
	private $help_hook = '';

	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' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
		add_action( 'admin_init', array( $this, 'redirect_moved_screens' ) );
		// admin_init is too late for a slug that is no longer REGISTERED. WordPress
		// resolves page access in wp-admin/includes/menu.php, which is required from
		// wp-admin/admin.php BEFORE it fires admin_init — so an unregistered
		// `page=` has already hit `wp_die( 'Sorry, you are not allowed to access
		// this page.' )` by the time admin_init runs, and a bookmark to a moved
		// screen dies instead of forwarding. `admin_page_access_denied` fires on the
		// line immediately above that wp_die, which is the only point early enough
		// to catch it. Same handler: it is guarded by slug, so a genuine denial for
		// somebody else's page falls straight through to core.
		add_action( 'admin_page_access_denied', array( $this, 'redirect_moved_screens' ) );
		add_action( 'admin_post_' . self::AI_EXPORT_ACTION, array( $this, 'handle_ai_export' ) );
		// Everything below is state-changing and lives outside the settings form, so
		// each one is the same four steps in the same order: capability, nonce, do the
		// work, redirect with a result. One shape, so there is one thing to get right.
		add_action( 'admin_post_' . self::QUALITY_DISMISS_ACTION, array( $this, 'handle_quality_dismiss' ) );
		add_action( 'admin_post_' . self::SETTINGS_EXPORT_ACTION, array( $this, 'handle_settings_export' ) );
		add_action( 'admin_post_' . self::SETTINGS_IMPORT_ACTION, array( $this, 'handle_settings_import' ) );
		add_action( 'admin_post_' . self::SETTINGS_IMPORT_APPLY_ACTION, array( $this, 'handle_settings_import_apply' ) );
		add_action( 'admin_post_' . self::SETTINGS_RESET_ACTION, array( $this, 'handle_settings_reset' ) );
	}

	/**
	 * The plugin's one raw read of the POST.
	 *
	 * Every handler that takes a posted value wants exactly one, and each place a
	 * superglobal is touched is one more place for somebody to forget wp_unslash() or
	 * to sanitize with the wrong function for the shape of the value. So the raw
	 * touch happens once, here, unslashed and nowhere else — including from the
	 * handoff screens, which is why this is public. The caller says what shape it
	 * wants.
	 *
	 * Deliberately NOT sanitizing here. The import payload is a JSON document that
	 * sanitize_text_field() would destroy (it strips newlines and collapses the file
	 * into one line), while a nonce field wants sanitize_text_field and a checkbox
	 * wants a boolean. A single sanitizer for all of them would be wrong for most of
	 * them — so this returns the raw string and every caller states its own rule on
	 * the line it uses it.
	 *
	 * @param string $key     The POST key.
	 * @param string $default Returned when the key is absent or not a scalar.
	 * @return string
	 */
	public static function posted( $key, $default = '' ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- every caller is behind check_admin_referer(); this is the read, not the gate.
		$raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : null;

		return is_scalar( $raw ) ? (string) $raw : $default;
	}

	/**
	 * Send a URL for a screen that has moved to wherever that screen is now.
	 *
	 * Four have moved. Reports, UTM Builder and Help were tabs on the settings page
	 * and are their own pages; the journeys list had its own page and is a view of
	 * Reports. Links to all four exist in browser bars, in notes, and — for a journey
	 * — in records on other people's systems, written the moment a visitor converted
	 * and meant to still open years later.
	 *
	 * Without this the journeys slug is the worse failure of the two: it is not a
	 * registered page any more, and admin.php resolves the capability to check by
	 * looking the slug up in $submenu, so an unregistered slug is not "missing", it is
	 * unauthorisable — "Sorry, you are not allowed to access this page" (see
	 * docs/UI-REVIEW.md B1). A missing tab merely falls back to the settings strip,
	 * which is wrong quietly.
	 *
	 * On admin_init rather than in a page callback because a redirect issued from a
	 * callback runs after wp-admin has already sent its header markup. It is also why
	 * this can be reached at all: WordPress fires admin_init BEFORE it resolves the
	 * page parameter against $submenu, so a redirect here runs whether or not the
	 * target would have been authorised.
	 *
	 * One method for all four, because "where did this screen go" is one question and
	 * two answers to it drift apart.
	 */
	/**
	 * Where to send somebody who opened Settings and cannot configure anything.
	 *
	 * Returns '' when there is nothing to do — they can manage, or there is no
	 * Reports screen on this site to fall back to, in which case the ordinary
	 * empty render is the honest answer.
	 *
	 * A 302 and not a 301: it is true of this USER, not of this address.
	 */
	public static function settings_fallback_for_viewer() {
		if ( BW_Lead_AI_Caps::can_manage() || ! BW_Lead_AI_Caps::can_view() ) {
			return '';
		}
		if ( ! BW_Lead_AI_Handoff_Admin::journeys_ui_enabled() ) {
			return '';
		}
		return add_query_arg( array( 'page' => self::REPORTS_SLUG ), admin_url( 'admin.php' ) );
	}

	public function redirect_moved_screens() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only navigation.
		$page  = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
		$tab   = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : '';
		$range = isset( $_GET['range'] ) ? sanitize_key( wp_unslash( $_GET['range'] ) ) : '';
		// phpcs:enable

		// The journeys list, with its token, its record hash and its filters — the
		// handoff class carries those, since it is the same job its own legacy path
		// does and the parameters must not fall out of step between two copies.
		if ( BW_Lead_AI_Handoff_Admin::LIST_SLUG === $page ) {
			BW_Lead_AI_Handoff_Admin::instance()->redirect_legacy_journey_url();
			return;
		}

		/*
		 * Settings, reached by somebody who may read but not configure.
		 *
		 * They arrive here without asking to. `add_submenu_page()` refuses the
		 * Settings entry for them, and WordPress then auto-inserts a link back to
		 * the PARENT as the first submenu item — carrying the parent's capability,
		 * which is the read one. So a read-only user is offered a menu item whose
		 * callback is the settings screen, and that screen correctly renders
		 * nothing: a menu entry that leads to a blank page.
		 *
		 * Nothing errors, which is what makes it worth catching here rather than
		 * leaving. Sent to Reports instead — the screen they came for. Checked
		 * before the tab test below, because the address has no tab on it.
		 */
		if ( self::PAGE_SLUG === $page && '' !== self::settings_fallback_for_viewer() ) {
			wp_safe_redirect( self::settings_fallback_for_viewer(), 302 );
			exit;
		}

		if ( self::PAGE_SLUG !== $page || '' === $tab ) {
			return;
		}

		if ( 'utm' === $tab ) {
			wp_safe_redirect( add_query_arg( array( 'page' => self::UTM_SLUG ), admin_url( 'admin.php' ) ), 301 );
			exit;
		}
		if ( 'help' === $tab ) {
			wp_safe_redirect( add_query_arg( array( 'page' => self::HELP_SLUG ), admin_url( 'admin.php' ) ), 301 );
			exit;
		}
		if ( 'reports' !== $tab ) {
			return;
		}

		// The Reports page only exists where there are journeys to report on.
		// Redirecting to an unregistered slug would trade a wrong tab for "you are not
		// allowed to access this page", so leave the fallback to the settings tab
		// strip alone in that case.
		if ( ! BW_Lead_AI_Handoff_Admin::journeys_ui_enabled() ) {
			return;
		}

		$args = array( 'page' => self::REPORTS_SLUG );
		if ( '' !== $range ) {
			$args['range'] = $range;
		}
		wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ), 301 );
		exit;
	}

	public function menu() {
		// Top-level rather than a Settings submenu: with settings, the journey list
		// and per-journey reports this is several screens, and burying it under
		// Settings made it hard to find.
		/*
		 * The top-level entry is gated on VIEW, not manage. A read-only user needs
		 * the menu to APPEAR — gate the parent on manage and the tab is invisible to
		 * exactly the people the read capability exists for, while every screen
		 * inside it would have been perfectly willing to render.
		 *
		 * The parent shares its slug with Settings, which is manage-only. WordPress
		 * drops a submenu the user cannot reach and points the parent at the first
		 * one that survives, so a view-only user lands on Reports. That is core
		 * behaviour rather than something asserted here, which is why
		 * tests/menu-routing.php resolves this menu as a view-only user rather than
		 * trusting the description.
		 */
		$this->hook_suffix = add_menu_page(
			__( 'BW Lead Attribution Intelligence', 'bw-lead-ai' ),
			__( 'Lead AI', 'bw-lead-ai' ),
			BW_Lead_AI_Caps::view(),
			self::PAGE_SLUG,
			array( $this, 'render_page' ),
			'dashicons-chart-area',
			58
		);

		// Without this the first submenu entry inherits the menu title.
		add_submenu_page(
			self::PAGE_SLUG,
			__( 'Settings', 'bw-lead-ai' ),
			__( 'Settings', 'bw-lead-ai' ),
			BW_Lead_AI_Caps::manage(),
			self::PAGE_SLUG,
			array( $this, 'render_page' )
		);

		/*
		 * Reports is a real, visible submenu entry — never registered and then hidden
		 * with remove_submenu_page().
		 *
		 * admin.php?page=X decides which capability to check by looking X up in the
		 * global $submenu. A page whose entry has been removed cannot be authorised at
		 * all, so it answers "Sorry, you are not allowed to access this page" to
		 * everyone, administrators included. That exact bug cost a day here already —
		 * docs/UI-REVIEW.md, finding B1.
		 *
		 * Registered only once there are journeys to report on: a site that has never
		 * stored one would get a menu item leading to empty tables, which reads as a
		 * broken feature rather than an unused one.
		 *
		 * The condition is the UNION of the two that used to gate Reports and the
		 * Visitor Journeys entry separately, because this one page is now both of
		 * them. Anything narrower would take the journeys list away from a site that
		 * has it today — see BW_Lead_AI_Handoff_Admin::journeys_ui_enabled().
		 *
		 * Position 0 lifts it above Settings, which was registered a moment ago. The
		 * order has to be built this way round because Settings owns the parent slug,
		 * and registering a differently-slugged page into an empty submenu makes
		 * WordPress synthesise a "Lead AI" entry pointing back at the parent.
		 */
		if ( BW_Lead_AI_Handoff_Admin::journeys_ui_enabled() ) {
			$this->reports_hook = add_submenu_page(
				self::PAGE_SLUG,
				__( 'Reports', 'bw-lead-ai' ),
				__( 'Reports', 'bw-lead-ai' ),
				BW_Lead_AI_Handoff_Admin::view_capability(),
				self::REPORTS_SLUG,
				array( $this, 'render_reports_page' ),
				0
			);
		}

		/*
		 * Two screens that were tabs on the settings page and are not settings.
		 *
		 * The UTM builder writes campaign links and the Help screen is a reference
		 * table; neither configures how capture behaves, and both were reachable only
		 * by someone already editing settings. They get their own entries for the same
		 * reason Reports did.
		 *
		 * The position is ASKED FOR, never hardcoded. add_submenu_page()'s $position
		 * is an insertion index into the list as it stands at that moment, and whether
		 * Reports is in that list varies per site — so a fixed number lands in the
		 * wrong slot on half of them. settings_submenu_index() answers "where is
		 * Settings right now", which is exactly the slot an entry that belongs above
		 * it should take. Asked for again for the second entry, because the first one
		 * just moved Settings down by one.
		 */
		$this->utm_hook = add_submenu_page(
			self::PAGE_SLUG,
			__( 'UTM Builder', 'bw-lead-ai' ),
			__( 'UTM Builder', 'bw-lead-ai' ),
			// Saves tracked links, so it writes — manage, not view.
			BW_Lead_AI_Caps::manage(),
			self::UTM_SLUG,
			array( $this, 'render_utm_page' ),
			self::settings_submenu_index()
		);

		$this->help_hook = add_submenu_page(
			self::PAGE_SLUG,
			__( 'Help', 'bw-lead-ai' ),
			__( 'Help', 'bw-lead-ai' ),
			// Reference material, and it explains the reports a view-only user is
			// reading. Nothing on it writes.
			BW_Lead_AI_Caps::view(),
			self::HELP_SLUG,
			array( $this, 'render_help_page' ),
			self::settings_submenu_index()
		);
	}

	/**
	 * Where the Settings entry currently sits in this menu, for a sibling that wants
	 * to insert itself above it.
	 *
	 * add_submenu_page()'s $position is an insertion INDEX into the list as it stands
	 * at that moment, not a stable weight sorted later. So no entry can reach a given
	 * slot with a fixed number: the index it needs depends on whether Reports was
	 * registered at all, which varies per site, and on how many entries have already
	 * been inserted above Settings.
	 *
	 * Settings is registered first and is meant to stay last, so its index is exactly
	 * the slot a later entry should take to land below Reports and above Settings.
	 * Call it again for each further entry — every insertion moves Settings down.
	 * Returning null means "append", which is add_submenu_page()'s own default.
	 *
	 * @return int|null Index of the Settings entry, or null if it is not there.
	 */
	public static function settings_submenu_index() {
		global $submenu;

		if ( empty( $submenu[ self::PAGE_SLUG ] ) || ! is_array( $submenu[ self::PAGE_SLUG ] ) ) {
			return null;
		}
		foreach ( $submenu[ self::PAGE_SLUG ] as $index => $entry ) {
			if ( isset( $entry[2] ) && self::PAGE_SLUG === $entry[2] ) {
				return (int) $index;
			}
		}

		return null;
	}

	public function enqueue( $hook ) {
		// Compare against the hooks WordPress actually gave us, not a guessed
		// "settings_page_"/"toplevel_page_"/"admin_page_" prefix: the prefix changes if
		// the menu ever moves, and a stale guess silently drops every stylesheet and
		// script on this page while the page itself keeps rendering.
		//
		// Every registered page of this menu, each by the hook its own registration
		// returned. Reports is the screen that most depends on the stylesheet —
		// unstyled, its blocks and chart collapse into a stack of bare tables — and
		// the UTM builder is the one that most depends on the scripts, since its form
		// does nothing at all without utm-builder.js. array_filter() drops the
		// empties: a hook is '' when that page was not registered, and
		// add_submenu_page() returns false when the current user cannot see it.
		$pages = array_filter(
			array( $this->hook_suffix, $this->reports_hook, $this->utm_hook, $this->help_hook )
		);
		if ( ! in_array( $hook, $pages, true ) ) {
			return;
		}
		wp_enqueue_style(
			'bw-lead-ai-admin',
			BW_LEAD_AI_URL . 'assets/css/admin.css',
			array(),
			self::asset_ver( 'assets/css/admin.css' )
		);
		wp_register_script(
			'bw-lead-ai-capture',
			BW_LEAD_AI_URL . 'assets/js/capture.js',
			array(),
			self::asset_ver( 'assets/js/capture.js' ),
			true
		);
		wp_localize_script( 'bw-lead-ai-capture', 'bwLeadAI', BW_Lead_AI_Frontend::instance()->client_config() );
		wp_register_script(
			'bw-lead-ai-admin-test',
			BW_LEAD_AI_URL . 'assets/js/admin-test.js',
			array( 'bw-lead-ai-capture' ),
			self::asset_ver( 'assets/js/admin-test.js' ),
			true
		);
		// The snippet's copy button lives on the Handoff tab, where neither the UTM
		// builder nor the Test scripts load. Enqueued (not registered) because it
		// is tiny, listener-only, and needed on more than one tab.
		wp_enqueue_script(
			'bw-lead-ai-admin-copy',
			BW_LEAD_AI_URL . 'assets/js/admin-copy.js',
			array(),
			self::asset_ver( 'assets/js/admin-copy.js' ),
			true
		);
		// Expand/collapse for the Reports channel table's breakdown subrows. Same
		// deal as admin-copy: one delegated listener, inert on pages without the
		// markup, cheaper to always load than to special-case which page needs it.
		wp_enqueue_script(
			'bw-lead-ai-admin-reports',
			BW_LEAD_AI_URL . 'assets/js/admin-reports.js',
			array(),
			self::asset_ver( 'assets/js/admin-reports.js' ),
			true
		);
		// Add / remove / reorder for the mapping row editors on Settings and
		// Interactions. Same deal again: delegated listener, inert without the
		// markup, and the page is already correct and submittable before it runs.
		wp_enqueue_script(
			'bw-lead-ai-admin-rows',
			BW_LEAD_AI_URL . 'assets/js/admin-rows.js',
			array(),
			self::asset_ver( 'assets/js/admin-rows.js' ),
			true
		);
		// Show/hide for the settings a feature owns, driven by that feature's own
		// checkbox. Same deal again: delegated listener, inert without the markup,
		// and the block is already server-rendered in the right state — this only
		// swaps it live, so the page is correct with the script blocked entirely.
		wp_enqueue_script(
			'bw-lead-ai-admin-gate',
			BW_LEAD_AI_URL . 'assets/js/admin-gate.js',
			array(),
			self::asset_ver( 'assets/js/admin-gate.js' ),
			true
		);
		wp_register_script(
			'bw-lead-ai-utm-builder',
			BW_LEAD_AI_URL . 'assets/js/utm-builder.js',
			array(),
			self::asset_ver( 'assets/js/utm-builder.js' ),
			true
		);
	}

	public function render_page() {
		if ( ! BW_Lead_AI_Caps::can_manage() ) {
			return;
		}
		$tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$tabs = array(
			// The slug stays `settings` while the label does not. Bookmarks point at
			// `&tab=settings`, and so does the round-trip test — renaming the slug to
			// match the label would break both to fix nothing a reader can see.
			'settings' => __( 'Tracking Rules', 'bw-lead-ai' ),
			'events'   => __( 'Interactions', 'bw-lead-ai' ),
			// Split from a single "Handoff" tab: retention, identity capture and
			// how long a journey keeps growing apply whether or not anything is
			// ever handed to another domain, so filing them under "Handoff" put
			// general settings behind a feature most sites never turn on.
			//
			// Labelled "Journey Storage" rather than "Journeys", which is now the
			// name of a tab on Reports. Two entries called Journeys in one menu,
			// one of them settings and one of them a list of records, is one word
			// doing two jobs. The `journeys` slug is unchanged — bookmarks and the
			// purge and reprocess redirects point at it.
			'journeys' => __( 'Journey Storage', 'bw-lead-ai' ),
			'handoff'  => __( 'Cross-domain', 'bw-lead-ai' ),
			'targets'  => __( 'Form Field Mapping', 'bw-lead-ai' ),
			// The two switches that belong to no mapping section. They sat at the
			// bottom of the mapping tab and read as a seventh mapping rule; on their
			// own tab the mapping tab is six sections that are all the same kind of
			// thing, which is what makes it scannable.
			'other'    => __( 'Other Settings', 'bw-lead-ai' ),
			'test'     => __( 'Test', 'bw-lead-ai' ),
		);
		// Reports, UTM Builder and Help are no longer tabs here — each is its own
		// submenu page. Bookmarked &tab= links to all three are redirected on
		// admin_init; see redirect_moved_screens().
		// "handoff" was one tab before the split; keep old bookmarks working.
		if ( 'handoff' === $tab && isset( $_GET['journeys'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$tab = 'journeys';
		}
		if ( ! isset( $tabs[ $tab ] ) ) {
			$tab = 'settings';
		}
		echo '<div class="wrap bw-lead-ai">';
		echo '<h1>' . esc_html__( 'BW Lead Attribution Intelligence', 'bw-lead-ai' ) . '</h1>';
		echo '<h2 class="nav-tab-wrapper">';
		foreach ( $tabs as $key => $label ) {
			$class = ( $tab === $key ) ? ' nav-tab-active' : '';
			$url   = add_query_arg(
				array(
					'page' => self::PAGE_SLUG,
					'tab'  => $key,
				),
				admin_url( 'admin.php' )
			);
			echo '<a class="nav-tab' . esc_attr( $class ) . '" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
		}
		echo '</h2>';

		switch ( $tab ) {
			case 'events':
				$this->render_events_tab();
				break;
			case 'journeys':
				$this->render_journeys_tab();
				break;
			case 'handoff':
				$this->render_handoff_tab();
				break;
			case 'targets':
				$this->render_targets_tab();
				break;
			case 'other':
				$this->render_other_tab();
				break;
			case 'test':
				$this->render_test_tab();
				break;
			case 'settings':
			default:
				$this->render_settings_tab();
				break;
		}
		echo '</div>';
	}

	/**
	 * Emit hidden inputs carrying every setting this tab does not itself render.
	 *
	 * All tabs post to the same option, and BW_Lead_AI_Settings::sanitize() falls
	 * back to the DEFAULT for any key missing from the POST. Without this, saving
	 * one tab silently resets everything the other tabs own.
	 *
	 * @param array $settings Current settings.
	 * @param array $skip     Keys this tab renders real inputs for.
	 */
	/**
	 * Detail that stays out of the way until asked for.
	 *
	 * Always-visible descriptions under every field turn a settings screen into a
	 * wall of prose and the reader stops seeing any of it. A short label plus a
	 * help icon keeps the page scannable while losing nothing — and it needs no
	 * JavaScript, so it works before scripts load and in any admin colour scheme.
	 */
	/**
	 * The collapsed-state summary line for a settings section.
	 *
	 * Collapsing the sections made the page scannable but cost the reader the one
	 * thing a settings screen is for: knowing what is currently set. Without this,
	 * finding out whether data handoff is on means opening five panels in turn. The
	 * status belongs on the closed row.
	 */
	/** "N rules" for a mapping textarea, counting non-blank lines. */
	private function rule_count_status( $text ) {
		$lines = array_filter( array_map( 'trim', preg_split( '/\R/', (string) $text ) ) );
		return $this->section_status(
			/* translators: %d: number of mapping rules. */
			sprintf( _n( '%d rule', '%d rules', count( $lines ), 'bw-lead-ai' ), count( $lines ) ),
			count( $lines ) ? null : false
		);
	}

	private function section_status( $text, $on = null ) {
		if ( '' === $text ) {
			return '';
		}
		$class = 'bw-section-status';
		if ( true === $on ) {
			$class .= ' is-on';
		} elseif ( false === $on ) {
			$class .= ' is-off';
		}
		return '<span class="' . esc_attr( $class ) . '">' . esc_html( $text ) . '</span>';
	}

	/**
	 * Cache-busting version for an asset: its modification time, not the plugin
	 * version. Two files change on different schedules from the version number —
	 * a CSS fix between releases would otherwise sit behind a browser cache keyed
	 * to a version that did not move, and the fix appears not to have worked.
	 */
	public static function asset_ver( $relative ) {
		$path = BW_LEAD_AI_DIR . $relative;
		$mtime = file_exists( $path ) ? filemtime( $path ) : 0;
		return $mtime ? BW_LEAD_AI_VERSION . '.' . $mtime : BW_LEAD_AI_VERSION;
	}

	public static function help( $html ) {
		return '<details class="bw-help"><summary aria-label="' . esc_attr__( 'More information', 'bw-lead-ai' ) . '"></summary><span class="bw-help-body">'
			. wp_kses( $html, array( 'code' => array(), 'strong' => array(), 'em' => array(), 'br' => array() ) )
			. '</span></details>';
	}

	/**
	 * A milestone list, as the on/off question it really is.
	 *
	 * Both lists — how much of a video was watched, how far down a page they
	 * read — were a bare text field whose EMPTY state was the off switch, with
	 * that fact mentioned in help text nobody opens. So the most useful setting
	 * on the tab was reachable only by deleting something and trusting that
	 * deleting it meant what you hoped.
	 *
	 * It is a checkbox now, with the percentages revealed behind it. The stored
	 * value is unchanged — still the comma-separated list, still empty for off —
	 * so nothing migrates and every consumer is untouched; the checkbox is a
	 * reading of that list rather than a second thing to keep in step with it.
	 *
	 * The field is DISABLED rather than removed when the box is unticked, because
	 * a disabled control posts nothing and the presence marker is what then tells
	 * the sanitizer this tab meant "off" rather than "not on this form". Same
	 * reasoning as the row editors' text escape hatch.
	 *
	 * @param string $flag     Marker/checkbox name stem, e.g. 'video_marks'.
	 * @param string $key      The stored setting key.
	 * @param string $label    Row heading.
	 * @param string $question The checkbox's own label.
	 * @param string $help     What the percentages cost, in plain terms.
	 */
	private function render_milestone_row( $flag, $key, $label, $question, $help, $settings ) {
		$opt  = BW_LEAD_AI_OPTION;
		$list = isset( $settings[ $key ] ) ? (string) $settings[ $key ] : '';
		$on   = ( '' !== trim( $list ) );
		$id   = 'bw-lead-ai-ev-' . str_replace( '_', '-', $flag );

		echo '<tr>';
		echo '<th scope="row">' . esc_html( $label ) . '</th>';
		echo '<td>';

		echo '<input type="hidden" name="' . esc_attr( $opt ) . '[' . esc_attr( $flag ) . '_present]" value="1">';
		echo '<p><label><input type="checkbox" class="bw-milestone-toggle" data-bw-target="' . esc_attr( $id ) . '"'
			. ' name="' . esc_attr( $opt ) . '[' . esc_attr( $flag ) . '_on]" value="1" ' . checked( $on, true, false ) . '> '
			. esc_html( $question ) . '</label></p>';

		echo '<p class="bw-milestone-marks' . ( $on ? '' : ' is-off' ) . '">';
		echo '<label for="' . esc_attr( $id ) . '" class="screen-reader-text">' . esc_html__( 'Percentages', 'bw-lead-ai' ) . '</label>';
		echo '<input type="text" id="' . esc_attr( $id ) . '" name="' . esc_attr( $opt ) . '[' . esc_attr( $key ) . ']"'
			. ' value="' . esc_attr( $list ) . '" class="regular-text" placeholder="25, 50, 75, 100"'
			. ( $on ? '' : ' disabled' ) . '>';
		echo '</p>';

		echo wp_kses_post( self::help( $help ) );
		echo '</td></tr>';
	}

	/**
	 * Row editor for a `label : value1, value2` setting.
	 *
	 * Five settings share exactly one shape — parameter aliases, referrer
	 * classification, click-ID inference, channel mappings, custom interaction
	 * selectors — so this is one component used five times rather than five
	 * editors. Everything that differs between them is copy: the two column
	 * headings, the placeholders, whether row order is load-bearing enough to
	 * deserve reorder buttons, and which labels are required.
	 *
	 * What it does NOT change is the stored format. The rows are a projection of
	 * the same newline-delimited text the textarea always held: parsed into rows
	 * to render, folded back to text to save. Nothing downstream — the resolution
	 * cascade, parse_channels(), reprocessing, the reports — can tell which
	 * editor produced the value.
	 *
	 * @param string $key  Settings key, e.g. 'channels'.
	 * @param string $text The stored value.
	 * @param array  $args Per-setting copy and behaviour.
	 */
	private function render_pair_rows( $key, $text, $args = array() ) {
		$args = wp_parse_args(
			$args,
			array(
				'key_head'         => __( 'Label', 'bw-lead-ai' ),
				'val_head'         => __( 'Values', 'bw-lead-ai' ),
				'key_placeholder'  => '',
				'val_placeholder'  => '',
				'add_label'        => __( 'Add rule', 'bw-lead-ai' ),
				// Labels that cannot be deleted: no minus button, label not editable.
				'locked'           => array(),
				// Reorder buttons. On for the settings where the first matching rule
				// wins, off where row order carries no meaning and the arrows would
				// only be one more thing to wonder about.
				'ordered'          => true,
				'textarea_rows'    => 8,
				'textarea_placeholder' => '',
			)
		);

		$opt  = esc_attr( BW_LEAD_AI_OPTION );
		$name = esc_attr( $key );
		$rows = BW_Lead_AI_Settings::split_pair_lines( $text );
		?>
		<div class="bw-rows" data-bw-rows="<?php echo $name; ?>" data-bw-option="<?php echo $opt; ?>">
			<?php
			// Marks the setting as owned by a row editor on this form — which is how
			// the sanitizer tells "the user deleted every row" (store nothing) apart
			// from "this form has no editor for it" (keep the default). Deliberately
			// outside the rows pane so it is still submitted in text mode.
			?>
			<input type="hidden" name="<?php echo $opt; ?>[<?php echo $name; ?>_editor]" value="1">

			<div data-bw-pane="rows">
				<table class="bw-rows-table">
					<thead>
						<tr>
							<th scope="col" class="bw-row-key"><?php echo esc_html( $args['key_head'] ); ?></th>
							<th scope="col" class="bw-row-val"><?php echo esc_html( $args['val_head'] ); ?></th>
							<th scope="col" class="bw-row-act"><span class="screen-reader-text"><?php esc_html_e( 'Row actions', 'bw-lead-ai' ); ?></span></th>
						</tr>
					</thead>
					<tbody>
						<?php
						foreach ( $rows as $index => $row ) {
							$locked = in_array( sanitize_key( $row['key'] ), $args['locked'], true );
							// Fully escaped inside pair_row_html().
							echo $this->pair_row_html( $key, (int) $index, $row['key'], $row['val'], $args, $locked ); // phpcs:ignore WordPress.Security.EscapingOutput.OutputNotEscaped
						}
						?>
					</tbody>
					<?php
					// The blank row the + button clones. Template content is inert —
					// its inputs carry no name and are never submitted — and it is
					// never rendered at all without JavaScript, which is exactly when
					// there is no + button to use it.
					?>
					<template data-bw-row-template><?php echo $this->pair_row_html( $key, null, '', '', $args, false ); // phpcs:ignore WordPress.Security.EscapingOutput.OutputNotEscaped ?></template>
				</table>
				<p class="bw-rows-add">
					<button type="button" class="button bw-row-add" data-bw-row="add"><span aria-hidden="true">+</span> <?php echo esc_html( $args['add_label'] ); ?></button>
				</p>
			</div>

			<?php
			// The escape hatch: bulk paste, and the one place content the row parser
			// could not split shows up as the line the user actually wrote. Rendered
			// `disabled` so exactly one of the two editors is ever submitted — the
			// script swaps which one as it swaps which one is visible.
			?>
			<div data-bw-pane="text" hidden>
				<textarea name="<?php echo $opt; ?>[<?php echo $name; ?>]" rows="<?php echo (int) $args['textarea_rows']; ?>" class="large-text code" placeholder="<?php echo esc_attr( $args['textarea_placeholder'] ); ?>" disabled><?php echo esc_textarea( $text ); ?></textarea>
				<p class="description"><?php
					echo wp_kses(
						__( 'One rule per line, format <code>label : value1, value2</code>. Switching back to rows re-reads whatever is in here.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
			</div>

			<p class="bw-rows-switch">
				<button type="button" class="button-link bw-rows-toggle" data-bw-toggle aria-expanded="false"
					data-bw-label-text="<?php esc_attr_e( 'Edit as text', 'bw-lead-ai' ); ?>"
					data-bw-label-rows="<?php esc_attr_e( 'Back to rows', 'bw-lead-ai' ); ?>"><?php esc_html_e( 'Edit as text', 'bw-lead-ai' ); ?></button>
			</p>
		</div>
		<?php
	}

	/**
	 * One table row of a row editor.
	 *
	 * A null index renders the blank template row, which carries no name
	 * attributes — the script names every row from its position after any add,
	 * remove or move, so the posted indexes are always 0..n-1 in the order shown
	 * on screen.
	 *
	 * Returns escaped markup — callers echo it directly.
	 */
	private function pair_row_html( $key, $index, $label, $value, $args, $locked = false ) {
		$opt      = esc_attr( BW_LEAD_AI_OPTION );
		$name     = esc_attr( $key );
		$key_name = ( null === $index ) ? '' : sprintf( ' name="%s[%s_key][%d]"', $opt, $name, (int) $index );
		$val_name = ( null === $index ) ? '' : sprintf( ' name="%s[%s_val][%d]"', $opt, $name, (int) $index );

		$buttons = '';
		if ( ! empty( $args['ordered'] ) ) {
			$buttons .= sprintf(
				'<button type="button" class="button bw-row-btn" data-bw-row="up" title="%1$s" aria-label="%1$s"><span aria-hidden="true">&uarr;</span></button>',
				esc_attr__( 'Move this rule up', 'bw-lead-ai' )
			);
			$buttons .= sprintf(
				'<button type="button" class="button bw-row-btn" data-bw-row="down" title="%1$s" aria-label="%1$s"><span aria-hidden="true">&darr;</span></button>',
				esc_attr__( 'Move this rule down', 'bw-lead-ai' )
			);
		}
		if ( $locked ) {
			// U+2013 as a spacer keeps the actions column the same width as its
			// neighbours, so a locked row does not look like a rendering mistake.
			$buttons .= '<span class="bw-row-locked" aria-hidden="true">&ndash;</span>';
		} else {
			$buttons .= sprintf(
				'<button type="button" class="button bw-row-btn bw-row-remove" data-bw-row="remove" title="%1$s" aria-label="%1$s"><span aria-hidden="true">&minus;</span></button>',
				esc_attr__( 'Remove this row', 'bw-lead-ai' )
			);
		}

		return sprintf(
			'<tr class="bw-row"%1$s>'
				. '<td class="bw-row-key"><input type="text"%2$s value="%3$s" data-bw-field="key" class="regular-text" placeholder="%4$s" aria-label="%5$s"%6$s></td>'
				. '<td class="bw-row-val"><input type="text"%7$s value="%8$s" data-bw-field="val" class="large-text" placeholder="%9$s" aria-label="%10$s"></td>'
				. '<td class="bw-row-act">%11$s</td>'
				. '</tr>',
			$locked ? ' data-bw-locked="1"' : '',
			$key_name,
			esc_attr( $label ),
			esc_attr( $args['key_placeholder'] ),
			esc_attr( $args['key_head'] ),
			$locked ? ' readonly' : '',
			$val_name,
			esc_attr( $value ),
			esc_attr( $args['val_placeholder'] ),
			esc_attr( $args['val_head'] ),
			$buttons
		);
	}

	private function render_passthrough( $settings, $skip = array() ) {
		$opt = esc_attr( BW_LEAD_AI_OPTION );

		foreach ( array_keys( BW_Lead_AI_Settings::defaults() ) as $key ) {
			if ( in_array( $key, $skip, true ) || in_array( $key, self::RETIRED_SETTINGS, true ) ) {
				continue;
			}
			$val = isset( $settings[ $key ] ) ? $settings[ $key ] : '';

			// field_targets is the one nested setting — carry each leaf through.
			if ( 'field_targets' === $key && is_array( $val ) ) {
				foreach ( $val as $target_key => $row ) {
					printf(
						'<input type="hidden" name="%1$s[field_targets][%2$s][attr]" value="%3$s">',
						$opt,
						esc_attr( $target_key ),
						esc_attr( isset( $row['attr'] ) ? $row['attr'] : '' )
					);
					printf(
						'<input type="hidden" name="%1$s[field_targets][%2$s][val]" value="%3$s">',
						$opt,
						esc_attr( $target_key ),
						esc_attr( isset( $row['val'] ) ? $row['val'] : '' )
					);
				}
				continue;
			}
			if ( is_array( $val ) ) {
				continue;
			}
			printf(
				'<input type="hidden" name="%1$s[%2$s]" value="%3$s">',
				$opt,
				esc_attr( $key ),
				esc_attr( $val )
			);
		}
	}

	/**
	 * Settings no screen edits any more, and no screen should carry either.
	 *
	 * A retired key is kept in the stored option — it is still read as a migration
	 * source — but it has no editor, so a hidden pass-through would be a form field
	 * posting a value nobody can see or change. Left in, it also makes "is this
	 * setting gone from the UI?" unanswerable by looking at the page.
	 * BW_Lead_AI_Settings::sanitize() preserves these from the current stored value
	 * instead, which is what keeps them intact without a form carrying them.
	 */
	const RETIRED_SETTINGS = array( 'self_referral_hosts' );

	/** How many channels earn their own row before the tail is collapsed. */
	const REPORT_CHANNEL_ROWS = 8;

	/** Whole-number percentage, guarding the empty-range divide. */
	private static function pct_of( $part, $whole ) {
		return $whole > 0 ? (int) round( ( $part / $whole ) * 100 ) : 0;
	}

	/**
	 * The Reports dashboard, as its own admin page.
	 *
	 * Registered as a real submenu entry, so WordPress can find it in $submenu and
	 * work out the capability to check — see menu() and docs/UI-REVIEW.md B1.
	 *
	 * The capability is checked here as well as by the menu. A registered page is
	 * routed on the strength of its $submenu entry alone, and this method is also
	 * called directly by tests, so the entry's capability is not a guarantee that the
	 * caller has it. This is the filterable capability every other journey surface
	 * checks, so a site that has raised it above manage_options — or handed it to a
	 * marketing role — gets the same answer here as on the journeys list.
	 *
	 * The tab strip is wp-admin's own nav-tab strip, the same control the settings
	 * page uses. Four tabs — the numbers, whether you can trust them, the package for
	 * an assistant, and the journeys all of it was counted from.
	 */
	public function render_reports_page() {
		if ( ! current_user_can( BW_Lead_AI_Handoff_Admin::view_capability() ) ) {
			wp_die( esc_html__( 'You do not have permission to view this.', 'bw-lead-ai' ) );
		}

		$view = self::reports_view();

		// The journeys list draws its own wrapper, heading and strip — it is a
		// different screen inside the same page, with its own filters and paging.
		if ( 'journeys' === $view ) {
			BW_Lead_AI_Handoff_Admin::instance()->render_page();
			return;
		}

		echo '<div class="wrap bw-lead-ai">';
		echo '<h1>' . esc_html__( 'Reports', 'bw-lead-ai' ) . '</h1>';
		self::render_reports_tabs( $view );

		switch ( $view ) {
			case 'quality':
				$this->render_reports_quality_view();
				break;
			case 'ai':
				$this->render_reports_ai_view();
				break;
			default:
				$this->render_reports_body();
				break;
		}
		echo '</div>';
	}

	/**
	 * Which of the Reports page's views the URL is asking for.
	 *
	 * Read through the same whitelisted reader as the page's other navigation, so
	 * anything absent, misspelled or hostile is the aggregate view.
	 *
	 * @return string
	 */
	public static function reports_view() {
		return self::reports_pill_param( 'view', self::REPORTS_VIEWS, 'reports' );
	}

	/**
	 * The Reports page's tab strip.
	 *
	 * wp-admin's `nav-tab-wrapper`, matching the settings page, because these are
	 * four different SCREENS rather than four readings of one table. That distinction
	 * is what the two controls mean on this plugin: a nav-tab changes what you are
	 * looking at, a `subsubsub` pill changes how the thing in front of you is
	 * counted. Reports has three of the latter — window, credit, dimension — and
	 * putting the screen switch in the same idiom made four controls that looked
	 * interchangeable and were not.
	 *
	 * EVERY TAB CARRIES ALL THREE AXES. The Data Quality and AI Analysis tabs do not
	 * SHOW the credit and dimension pills, because nothing on them varies by either —
	 * but they carry them anyway, so a reader who has set the report to first-click
	 * mediums, gone to check the data quality and come back finds their view intact
	 * rather than reset to the default.
	 *
	 * Public and static because the journeys view renders it too, from the other
	 * class — one strip, written once, so the views cannot disagree about which one
	 * is lit.
	 *
	 * @param string $current A value from REPORTS_VIEWS.
	 */
	public static function render_reports_tabs( $current ) {
		$tabs = array(
			'reports'  => __( 'Reports', 'bw-lead-ai' ),
			'quality'  => __( 'Data Quality', 'bw-lead-ai' ),
			'ai'       => __( 'AI Analysis', 'bw-lead-ai' ),
			'journeys' => __( 'Journeys', 'bw-lead-ai' ),
		);

		// Read from the request rather than passed in: this is called from two
		// classes and three code paths, and an axis that has to be threaded through
		// all of them is an axis that gets dropped on one of them.
		$ranges      = BW_Lead_AI_Reports::ranges();
		$range_key   = self::reports_pill_param( 'range', array_map( 'strval', array_keys( $ranges ) ), self::REPORTS_DEFAULT_RANGE );
		$attribution = self::reports_pill_param( 'attribution', array( 'last', 'first' ), 'last' );
		$dimension   = self::reports_pill_param( 'grouping', array_keys( BW_Lead_AI_Reports::dimensions() ), self::REPORTS_DEFAULT_DIMENSION );

		echo '<h2 class="nav-tab-wrapper bw-report-tabs">';
		foreach ( $tabs as $key => $label ) {
			$class = ( $current === $key ) ? ' nav-tab-active' : '';
			$url   = add_query_arg( self::reports_link_args( $range_key, $attribution, $dimension, $key ), admin_url( 'admin.php' ) );
			echo '<a class="nav-tab' . esc_attr( $class ) . '" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
		}
		echo '</h2>';
	}

	/**
	 * The UTM link builder, as its own page.
	 *
	 * It writes outbound campaign URLs — a thing you do, not a thing you configure —
	 * and as a settings tab it was only reachable by somebody already editing
	 * settings.
	 */
	public function render_utm_page() {
		if ( ! BW_Lead_AI_Caps::can_manage() ) {
			return;
		}
		echo '<div class="wrap bw-lead-ai">';
		echo '<h1>' . esc_html__( 'UTM Builder', 'bw-lead-ai' ) . '</h1>';
		$this->render_utm_tab();
		echo '</div>';
	}

	/**
	 * The merge-tag reference, as its own page.
	 *
	 * Help that lives behind a settings tab is help you have to already be
	 * configuring something to find.
	 */
	public function render_help_page() {
		if ( ! BW_Lead_AI_Caps::can_view() ) {
			return;
		}
		echo '<div class="wrap bw-lead-ai">';
		echo '<h1>' . esc_html__( 'Help', 'bw-lead-ai' ) . '</h1>';
		$this->render_help_tab();
		echo '</div>';
	}

	/**
	 * One whitelisted GET parameter from the Reports screen's link pills.
	 *
	 * The screen navigates by plain links on two axes — the date window and the
	 * attribution mode — and both are read the same way through this one reader,
	 * so there is exactly one place where "what does a stranger's query string
	 * get to choose" is decided: a fixed whitelist, and anything else — absent,
	 * misspelled, hostile — is the default.
	 *
	 * Read-only navigation, so no nonce: the parameters only choose which rows
	 * get counted and which end of each journey gets the credit, and demanding
	 * one would break a bookmarked link to last quarter.
	 *
	 * Callers pass the whitelist as STRINGS — note that array_keys() over the
	 * ranges yields integers for the numeric windows (PHP folds '30' to 30 as an
	 * array key), which a strict in_array against sanitize_key's string output
	 * would silently refuse. Hence the strval mapping at the call site.
	 *
	 * @param string $name    The query parameter.
	 * @param array  $allowed The permitted string values.
	 * @param string $default Returned for anything not on the list.
	 * @return string
	 */
	/**
	 * A count a redirect handed back, for a "12 journeys updated" notice.
	 *
	 * One reader rather than a copy of the same three tokens at every call site.
	 * Five of those had accumulated and a sixth was about to; each is a raw
	 * superglobal read the security scan counts, and each is a place somebody can
	 * forget `wp_unslash()` or reach for the wrong sanitizer. `absint()` is the
	 * whole validation, which is why this can be one function: the value is only
	 * ever a number the plugin itself put in the URL a moment ago.
	 *
	 * @param string $name The query arg.
	 * @param mixed  $none What to return when it is absent — distinguishing "no
	 *                     notice" from "a notice that says zero".
	 */
	public static function notice_count( $name, $none = null ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a count this plugin put in its own redirect, read as an integer and rendered as one.
		return isset( $_GET[ $name ] ) ? absint( wp_unslash( $_GET[ $name ] ) ) : $none;
	}

	private static function reports_pill_param( $name, $allowed, $default ) {
		$raw = isset( $_GET[ $name ] ) ? sanitize_key( wp_unslash( $_GET[ $name ] ) ) : $default; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

		return in_array( $raw, $allowed, true ) ? $raw : $default;
	}

	/**
	 * Every noun the screen needs for one dimension, in one place.
	 *
	 * The dimension switch drives the whole page — the KPI card, the chart, the
	 * performance table and the transitions block all rename themselves — and a
	 * block headed "Which channels are working" over a table of mediums is a lie
	 * the reader has no way to catch. So no block writes its own noun: each asks
	 * here, and a sixth dimension is one entry in this table rather than a hunt
	 * through five renderers for the word "channel".
	 *
	 * `field` is what the JOURNEY had to record, which is not always the row's own
	 * noun: a group row exists because a CHANNEL was recorded, so a shortfall in
	 * group coverage is still a shortfall of channels and saying otherwise would
	 * point the reader at a setting that is not the problem.
	 *
	 * `link` decides whether a row name is offered as a link into the journeys
	 * list, which searches the stored payload: a source is a value that is in
	 * there, a joined `source / medium` pair is a string this screen assembled and
	 * no journey contains, and a group name is only findable when the catch-all
	 * left a channel standing on its own as a group of one.
	 *
	 * @param string $dim A key from BW_Lead_AI_Reports::dimensions().
	 * @return array
	 */
	private static function reports_nouns( $dim ) {
		switch ( $dim ) {
			case 'groups':
				return array(
					'one'    => __( 'channel group', 'bw-lead-ai' ),
					'many'   => __( 'channel groups', 'bw-lead-ai' ),
					'column' => __( 'Group', 'bw-lead-ai' ),
					'field'  => __( 'channel', 'bw-lead-ai' ),
					'link'   => 'channel',
				);
			case 'source_medium':
				return array(
					'one'    => __( 'source / medium pair', 'bw-lead-ai' ),
					'many'   => __( 'source / medium pairs', 'bw-lead-ai' ),
					'column' => __( 'Source / Medium', 'bw-lead-ai' ),
					'field'  => __( 'source or medium', 'bw-lead-ai' ),
					'link'   => 'never',
				);
			case 'source':
				return array(
					'one'    => __( 'source', 'bw-lead-ai' ),
					'many'   => __( 'sources', 'bw-lead-ai' ),
					'column' => __( 'Source', 'bw-lead-ai' ),
					'field'  => __( 'source', 'bw-lead-ai' ),
					'link'   => 'always',
				);
			case 'medium':
				return array(
					'one'    => __( 'medium', 'bw-lead-ai' ),
					'many'   => __( 'mediums', 'bw-lead-ai' ),
					'column' => __( 'Medium', 'bw-lead-ai' ),
					'field'  => __( 'medium', 'bw-lead-ai' ),
					'link'   => 'always',
				);
			default:
				return array(
					'one'    => __( 'channel', 'bw-lead-ai' ),
					'many'   => __( 'channels', 'bw-lead-ai' ),
					'column' => __( 'Channel', 'bw-lead-ai' ),
					'field'  => __( 'channel', 'bw-lead-ai' ),
					'link'   => 'always',
				);
		}
	}

	/**
	 * "N other sources" for the row that collapses the tail of the table.
	 *
	 * Its own function rather than a noun substituted into one string, because the
	 * plural of the noun and the plural of the count are the same decision in
	 * English and different decisions elsewhere — a translator needs the whole
	 * phrase to work with.
	 */
	private static function reports_tail_label( $dim, $count ) {
		$count = (int) $count;
		switch ( $dim ) {
			case 'groups':
				/* translators: %d: how many further channel groups there were. */
				return sprintf( _n( '%d other group', '%d other groups', $count, 'bw-lead-ai' ), $count );
			case 'source_medium':
				/* translators: %d: how many further source / medium pairs there were. */
				return sprintf( _n( '%d other pair', '%d other pairs', $count, 'bw-lead-ai' ), $count );
			case 'source':
				/* translators: %d: how many further sources there were. */
				return sprintf( _n( '%d other source', '%d other sources', $count, 'bw-lead-ai' ), $count );
			case 'medium':
				/* translators: %d: how many further mediums there were. */
				return sprintf( _n( '%d other medium', '%d other mediums', $count, 'bw-lead-ai' ), $count );
			default:
				/* translators: %d: how many further channels there were. */
				return sprintf( _n( '%d other channel', '%d other channels', $count, 'bw-lead-ai' ), $count );
		}
	}

	/**
	 * How much of the field this dimension ranks by is actually there.
	 *
	 * The blocks that rank need the credited end present on most rows to mean
	 * anything, and WHICH field that is moves with the dimension: a page reading
	 * sources is not made drawable by a full set of channel labels. A pair can be
	 * drawn from either half, so it takes the better-covered of the two.
	 *
	 * @param array  $coverage Percentages from BW_Lead_AI_Reports::build().
	 * @param string $dim      A key from BW_Lead_AI_Reports::dimensions().
	 * @param bool   $first    Whether the first touch is the credited end.
	 * @return int Whole-number percentage.
	 */
	private static function reports_dimension_coverage( $coverage, $dim, $first ) {
		switch ( $dim ) {
			case 'source':
				$keys = array( $first ? 'first_source' : 'source' );
				break;
			case 'medium':
				$keys = array( $first ? 'first_medium' : 'medium' );
				break;
			case 'source_medium':
				$keys = $first ? array( 'first_source', 'first_medium' ) : array( 'source', 'medium' );
				break;
			default:
				$keys = array( $first ? 'first_channel' : 'channel' );
				break;
		}

		$pct = 0;
		foreach ( $keys as $key ) {
			$pct = max( $pct, isset( $coverage[ $key ] ) ? (int) $coverage[ $key ] : 0 );
		}
		return $pct;
	}

	/**
	 * The navigation axes as query args, so every link carries the others.
	 *
	 * Written once because it is a correctness rule rather than a convenience:
	 * changing the window must not silently reset the credit mode or the
	 * dimension, or the number somebody was watching changes meaning mid-
	 * comparison. The tab is a fourth axis and follows the same rule from the other
	 * direction: a range pill on the Data Quality tab must keep you on the Data
	 * Quality tab. Each axis is carried only when it is not the default, so the
	 * plainest URL stays the canonical, bookmarkable shape — and `view` in particular
	 * is omitted for the reports tab, which is what keeps every existing bookmark and
	 * the moved-slug redirect pointing at exactly the URL they always did.
	 *
	 * @param string $range_key   A key from BW_Lead_AI_Reports::ranges().
	 * @param string $attribution 'last' or 'first'.
	 * @param string $dimension   A key from BW_Lead_AI_Reports::dimensions().
	 * @param string $view        A value from REPORTS_VIEWS.
	 */
	private static function reports_link_args( $range_key, $attribution, $dimension, $view = 'reports' ) {
		$args = array(
			'page'  => self::REPORTS_SLUG,
			'range' => $range_key,
		);
		if ( 'first' === $attribution ) {
			$args['attribution'] = 'first';
		}
		if ( self::REPORTS_DEFAULT_DIMENSION !== $dimension ) {
			$args['grouping'] = $dimension;
		}
		if ( 'reports' !== $view && in_array( $view, self::REPORTS_VIEWS, true ) ) {
			$args['view'] = $view;
		}

		return $args;
	}

	/**
	 * Everything inside the Reports page wrapper: where the leads came from, and what
	 * last-click is not telling you.
	 *
	 * Split from render_reports_page() so the page chrome — capability check, wrap,
	 * heading — is stated once and the blocks below can bail out early with a plain
	 * return without leaving the wrapper unclosed.
	 *
	 * Every block here is drawn from ONE aggregate — BW_Lead_AI_Reports::build()
	 * makes a single pass over the range's rows — so a block costs markup, not
	 * another query. Nothing is computed in this file; if a number looks wrong, it
	 * is wrong in the aggregate and can be checked there without a browser.
	 *
	 * Two rules run through the whole screen.
	 *
	 * Nothing is drawn from a field the data cannot support. A block whose backing
	 * field is under REPORTS_COVERAGE_FLOOR per cent present hides itself and says so
	 * — here in one line, and in full on the Data Quality tab — rather than ranking a
	 * tenth of the rows in a table that reads exactly as confidently as a full one.
	 *
	 * And everything printed here started life as a URL parameter a stranger chose
	 * — channel labels, campaign names, page paths, the form title a third-party
	 * site sent us. It is all escaped on output. That is the single most important
	 * detail in this method.
	 */
	private function render_reports_body() {
		$ranges      = BW_Lead_AI_Reports::ranges();
		$range_key   = self::reports_pill_param( 'range', array_map( 'strval', array_keys( $ranges ) ), self::REPORTS_DEFAULT_RANGE );
		$attribution = self::reports_pill_param( 'attribution', array( 'last', 'first' ), 'last' );

		// Third navigation axis, read through the same whitelisted reader: WHAT a
		// row is. It drives the whole page — the KPI card, the weekly chart, the
		// performance table and the transitions block all re-bucket and rename
		// themselves — because a page that answered one question at group level and
		// the three beside it at channel level would be four reports in a trench
		// coat. What does NOT follow it: the per-form table on this tab, and the two
		// tabs beside it — Data Quality is about the tags a journey recorded rather
		// than about which end gets the credit, and the AI Analysis package is a
		// self-contained artifact that is always per-channel and always last-click.
		// Both say so where they are drawn.
		//
		// The parameter is still named `grouping`: it is hours old, it does exactly
		// the same job, and a second parameter meaning the same thing is how a page
		// ends up with two switches that disagree.
		$dimension = self::reports_pill_param(
			'grouping',
			array_keys( BW_Lead_AI_Reports::dimensions() ),
			self::REPORTS_DEFAULT_DIMENSION
		);
		$data     = BW_Lead_AI_Reports::build( $range_key, $attribution, $dimension );
		$coverage = $data['coverage'];
		$leads    = (int) $data['totals']['leads'];

		$this->render_reports_ranges( $ranges, $range_key, $attribution, $dimension );
		$this->render_reports_attribution( $range_key, $attribution, $dimension );
		$this->render_reports_dimension( $range_key, $attribution, $dimension );

		if ( 0 === $leads ) {
			echo '<div class="notice notice-info inline bw-report-block"><p>'
				. esc_html__( 'No leads in this window yet.', 'bw-lead-ai' ) . ' '
				. esc_html__( 'If you know there are older leads, try a wider date range.', 'bw-lead-ai' )
				. '</p></div>';
			return;
		}

		// Blocks that rank need the CREDITED end on most rows to mean anything —
		// which end that is depends on the mode, and which FIELD on the dimension.
		$first_mode   = ( 'first' === $attribution );
		$has_channels = ( self::reports_dimension_coverage( $coverage, $dimension, $first_mode ) >= self::REPORTS_COVERAGE_FLOOR );
		// The switched-journey figure under the performance table needs BOTH ends,
		// so it is gated on the first touch whichever end is being credited.
		$has_first    = ( self::reports_dimension_coverage( $coverage, $dimension, true ) >= self::REPORTS_COVERAGE_FLOOR );

		if ( $data['sparse'] ) {
			echo '<p class="description bw-copy bw-report-lede">' . esc_html(
				sprintf(
					/* translators: 1: number of leads so far, 2: number of leads needed before trends are drawn. */
					_n(
						'%1$s lead in this window — too few to read a trend from. The rest of this page appears once there are %2$s.',
						'%1$s leads in this window — too few to read a trend from. The rest of this page appears once there are %2$s.',
						$leads,
						'bw-lead-ai'
					),
					number_format_i18n( $leads ),
					number_format_i18n( BW_Lead_AI_Reports::SPARSE_LEADS )
				)
			) . '</p>';
			// The chart draws here too. It used to be withheld below ten leads, on
			// the reasoning that three bars invite a trend nobody should read — but
			// the sentence above already says that in words, and what a new site got
			// instead was a dashboard whose top half was missing, which reads as
			// broken rather than as early. A bar of height one is honest; the claim
			// that it is a trend is what had to be prevented, and prose does that.
			if ( $has_channels ) {
				$this->render_reports_chart( $data, $attribution, $dimension );
				$this->render_reports_breakdown( $data, $attribution, $dimension, $has_first );
			}
			return;
		}

		if ( $has_channels ) {
			$this->render_reports_chart( $data, $attribution, $dimension );
			$this->render_reports_breakdown( $data, $attribution, $dimension, $has_first );
		}
	}

	/**
	 * Sentences naming the blocks this view did not draw, and why.
	 *
	 * Its own function because two screens need the identical list from the identical
	 * inputs: the Reports tab, to say that a block a reader expected is missing, and
	 * the Data Quality tab, where the gaps belong alongside the checks because "some
	 * of this page could not be drawn" is the same question as "can you trust these
	 * numbers". Written twice they would drift, and the copy is the whole value.
	 *
	 * @param bool  $has_channels Whether the credited end is present often enough to rank.
	 * @param bool  $has_first    Whether the FIRST touch is, whichever end is credited.
	 * @param bool  $first_mode   Whether first-click is the credited end.
	 * @param array $nouns        From reports_nouns().
	 * @return array
	 */
	private static function reports_gaps( $has_channels, $has_first, $first_mode, $nouns ) {
		$gaps = array();
		if ( ! $has_channels ) {
			$gaps[] = $first_mode
				? sprintf(
					/* translators: %s: what the journey had to record, e.g. "channel" or "source". */
					__( 'The breakdown table and the weekly chart: too few journeys recorded which %s first found the visitor.', 'bw-lead-ai' ),
					$nouns['field']
				)
				: sprintf(
					/* translators: %s: what the journey had to record, e.g. "channel" or "source". */
					__( 'The breakdown table and the weekly chart: too few journeys recorded which %s brought the visitor.', 'bw-lead-ai' ),
					$nouns['field']
				);
		}
		if ( ! $has_first ) {
			$gaps[] = $first_mode
				? sprintf(
					/* translators: %s: what the journey had to record, e.g. "channel" or "source". */
					__( 'What first-click is hiding: too few journeys recorded the %s that first found the visitor.', 'bw-lead-ai' ),
					$nouns['field']
				)
				: sprintf(
					/* translators: %s: what the journey had to record, e.g. "channel" or "source". */
					__( 'What last-click is hiding: too few journeys recorded the %s that first found the visitor.', 'bw-lead-ai' ),
					$nouns['field']
				);
		}

		return $gaps;
	}


	/**
	 * The preset windows, as links rather than a form.
	 *
	 * A link set is shareable, bookmarkable and needs no nonce, and it is the same
	 * control the journeys list uses for its status filter — one interaction to
	 * learn instead of two. Each link keeps the OTHER axes where they are: changing
	 * the window must not silently reset the attribution mode or the dimension, or
	 * the number somebody was watching changes meaning mid-comparison. Nor the tab:
	 * all three tabs that vary by window draw this one control, and a range pill that
	 * dropped `view` would answer "last 90 days" by walking the reader back to the
	 * Reports tab.
	 */
	private function render_reports_ranges( $ranges, $current, $attribution, $dimension = self::REPORTS_DEFAULT_DIMENSION, $view = 'reports' ) {
		echo '<div class="bw-report-ranges"><ul class="subsubsub">';
		$i = 0;
		foreach ( $ranges as $key => $label ) {
			$args          = self::reports_link_args( $key, $attribution, $dimension, $view );
			$url           = add_query_arg( $args, admin_url( 'admin.php' ) );
			echo '<li>' . ( $i++ ? ' | ' : '' )
				. '<a href="' . esc_url( $url ) . '"' . ( (string) $current === (string) $key ? ' class="current"' : '' ) . '>'
				. esc_html( $label ) . '</a></li>';
		}
		echo '</ul></div>';
	}

	/**
	 * Last-click / first-click, as the same link-pill control as the windows.
	 *
	 * Two views of the same journeys, not two reports: every channel block credits
	 * one end of each journey, and this chooses which. Last-click is the default
	 * because it is what every form notification and most analytics tools mean by
	 * "where did this lead come from" — first-click is the deliberate second
	 * question. The one-line explainer renders only in first mode so the default
	 * page stays exactly as it always was.
	 */
	private function render_reports_attribution( $range_key, $current, $dimension = self::REPORTS_DEFAULT_DIMENSION ) {
		$modes = array(
			'last'  => __( 'Last click', 'bw-lead-ai' ),
			'first' => __( 'First click', 'bw-lead-ai' ),
		);

		echo '<div class="bw-report-ranges bw-report-attribution"><ul class="subsubsub">';
		echo '<li class="bw-report-attribution-label">' . esc_html__( 'Credit each lead to:', 'bw-lead-ai' ) . '</li>';
		$i = 0;
		foreach ( $modes as $key => $label ) {
			// The dimension travels with the credit switch, and vice versa: three
			// independent axes that each survive a click on either other one.
			$args = self::reports_link_args( $range_key, $key, $dimension );
			$url  = add_query_arg( $args, admin_url( 'admin.php' ) );
			echo '<li>' . ( $i++ ? ' | ' : '' )
				. '<a href="' . esc_url( $url ) . '"' . ( $current === $key ? ' class="current"' : '' ) . '>'
				. esc_html( $label ) . '</a></li>';
		}
		echo '</ul></div>';

		if ( 'first' === $current ) {
			echo '<p class="description bw-copy bw-report-note">' . esc_html__(
				'First-click view: every lead below is credited to the channel that FIRST found them, not the one they enquired through.',
				'bw-lead-ai'
			) . '</p>';
		}
	}


	/**
	 * Reports tab, block 2 — leads per week, stacked by channel.
	 *
	 * Weeks rather than days: at these volumes a daily bucket is noise with the
	 * trend hidden somewhere inside it. The aggregate emits empty weeks on purpose
	 * and they are drawn as gaps, because a chart that quietly omits its dead weeks
	 * reads as steady growth.
	 *
	 * Hand-drawn SVG. A charting library would be a third-party bundle loaded into
	 * wp-admin to place sixty rectangles, plus a CDN or a vendored copy to keep
	 * patched; rectangles are cheaper than either. Colour never carries meaning on
	 * its own — every bar has a hover title and the table below prints the same
	 * figures as text.
	 */
	private function render_reports_chart( $data, $attribution, $dimension = self::REPORTS_DEFAULT_DIMENSION ) {
		$nouns                 = self::reports_nouns( $dimension );
		list( $weeks, $stack ) = self::reports_list( $data['weekly'], 'channels' );
		// One bar is a thin trend, but an empty space where the chart belongs reads
		// as a broken page rather than as a new site — and on an unbounded range
		// weekly() now pads the span so this is only ever reached by a deliberately
		// short window, where one bar is what the reader asked for.
		if ( count( $weeks ) < 1 || empty( $stack ) ) {
			return;
		}

		$peak = 0;
		foreach ( $weeks as $week ) {
			if ( (int) $week['total'] > $peak ) {
				$peak = (int) $week['total'];
			}
		}
		if ( $peak < 1 ) {
			return; // Every week empty: the KPI strip has already said so.
		}

		// Fixed viewBox scaled to the container: the chart has to stay legible at
		// 900px and inside a narrow admin column, and CSS cannot reflow the inside
		// of an SVG. Every coordinate below is an integer — PHP before 8.0 casts a
		// float to string using the locale's decimal separator, and one comma in a
		// coordinate voids the attribute silently.
		$w      = 720;
		$h      = 250;
		$pad_l  = 34;
		$pad_r  = 6;
		$pad_t  = 10;
		$pad_b  = 30;
		$plot_w = $w - $pad_l - $pad_r;
		$plot_h = $h - $pad_t - $pad_b;
		$base   = $pad_t + $plot_h;
		$n      = count( $weeks );
		$slot   = $plot_w / $n;
		$bar_w  = max( 1, (int) floor( $slot * 0.72 ) );

		// Round the top of the axis to a number a person would have picked, so it
		// reads 0 / 10 / 20 / 30 rather than 0 / 8 / 16 / 24.
		$step = 1;
		foreach ( array( 1, 2, 5, 10, 20, 25, 50, 100, 250, 500 ) as $candidate ) {
			$step = $candidate;
			if ( ceil( $peak / $candidate ) <= 4 ) {
				break;
			}
		}
		$top   = (int) ( ceil( $peak / $step ) * $step );
		$lines = (int) ( $top / $step );

		echo '<div class="bw-report-block">';
		echo '<h2>' . esc_html__( 'Leads per week', 'bw-lead-ai' ) . '</h2>';
		echo '<div class="bw-chart-wrap">';
		echo '<svg class="bw-chart" viewBox="0 0 ' . (int) $w . ' ' . (int) $h . '" role="img" aria-labelledby="bw-report-chart-title" preserveAspectRatio="xMidYMid meet">';
		if ( 'first' === $attribution ) {
			$chart_title = sprintf(
				/* translators: 1: the dimension noun, e.g. "channel" or "source". 2: the selected date range, e.g. "Last 30 days". */
				__( 'Leads per week, stacked by first-click %1$s — %2$s', 'bw-lead-ai' ),
				$nouns['one'],
				$data['range']['label']
			);
		} else {
			$chart_title = sprintf(
				/* translators: 1: the dimension noun, e.g. "channel" or "source". 2: the selected date range, e.g. "Last 30 days". */
				__( 'Leads per week, stacked by %1$s — %2$s', 'bw-lead-ai' ),
				$nouns['one'],
				$data['range']['label']
			);
		}
		echo '<title id="bw-report-chart-title">' . esc_html( $chart_title ) . '</title>';

		for ( $i = 0; $i <= $lines; $i++ ) {
			$value = $i * $step;
			$y     = (int) round( $base - ( ( $value / $top ) * $plot_h ) );
			echo '<line class="' . ( 0 === $i ? 'bw-chart-base' : 'bw-chart-grid' ) . '"'
				. ' x1="' . (int) $pad_l . '" y1="' . (int) $y . '" x2="' . (int) ( $w - $pad_r ) . '" y2="' . (int) $y . '"/>';
			echo '<text class="bw-chart-axis" x="' . (int) ( $pad_l - 6 ) . '" y="' . (int) ( $y + 3 ) . '" text-anchor="end">'
				. esc_html( number_format_i18n( $value ) ) . '</text>';
		}

		foreach ( $weeks as $i => $week ) {
			$x = (int) round( $pad_l + ( $i * $slot ) + ( ( $slot - $bar_w ) / 2 ) );
			echo '<g><title>' . esc_html(
				sprintf(
					/* translators: 1: week start date, 2: number of leads that week. */
					_n( 'Week of %1$s — %2$s lead', 'Week of %1$s — %2$s leads', (int) $week['total'], 'bw-lead-ai' ),
					$week['label'],
					number_format_i18n( (int) $week['total'] )
				)
			) . '</title>';

			// Stacked from a running total rather than segment by segment: rounding
			// each band on its own accumulates a few pixels of drift up the bar and
			// the tallest ones stop matching the axis.
			$acc    = 0;
			$prev_y = $base;
			foreach ( $stack as $index => $channel ) {
				$count = isset( $week['by_channel'][ $channel ] ) ? (int) $week['by_channel'][ $channel ] : 0;
				if ( $count < 1 ) {
					continue;
				}
				$acc += $count;
				$y    = (int) round( $base - ( ( $acc / $top ) * $plot_h ) );
				$seg  = $prev_y - $y;
				if ( $seg < 1 ) {
					// A week that happened must never render as nothing.
					$seg = 1;
					$y   = $prev_y - 1;
				}
				echo '<rect class="bw-chart-band ' . esc_attr( self::reports_band_class( $channel, $index ) ) . '"'
					. ' x="' . (int) $x . '" y="' . (int) $y . '" width="' . (int) $bar_w . '" height="' . (int) $seg . '"/>';
				$prev_y = $y;
			}
			echo '</g>';
		}

		// Thin the date labels rather than the bars: overlapping text is unreadable
		// at every width, a dense bar is still a shape.
		$every = max( 1, (int) ceil( $n / 12 ) );
		foreach ( $weeks as $i => $week ) {
			if ( 0 !== $i % $every ) {
				continue;
			}
			$cx = (int) round( $pad_l + ( $i * $slot ) + ( $slot / 2 ) );
			echo '<text class="bw-chart-axis" x="' . (int) $cx . '" y="' . (int) ( $base + 16 ) . '" text-anchor="middle">'
				. esc_html( $week['label'] ) . '</text>';
		}

		echo '</svg></div>';

		echo '<ul class="bw-chart-legend">';
		foreach ( $stack as $index => $channel ) {
			$label = ( BW_Lead_AI_Reports::OTHER === $channel ) ? __( 'Other', 'bw-lead-ai' ) : $channel;
			echo '<li><span class="bw-chart-key ' . esc_attr( self::reports_band_class( $channel, $index ) ) . '"></span>'
				. esc_html( $label ) . '</li>';
		}
		echo '</ul>';
		echo '</div>';
	}

	/**
	 * Reports tab, block 3 — one row per bucket, volume beside the engagement volume hides.
	 *
	 * Ranked by leads because that is the question people arrive with, but the
	 * engagement columns are why the table exists: they are what shows that a small
	 * channel sending people who read five pages is worth more per lead than a big
	 * one sending people who read one, which is the opposite of the conclusion a
	 * volume-only table invites.
	 *
	 * What a row IS comes from the page's dimension, and every noun in here —
	 * heading, column header, help text, button labels, the tail row — is read from
	 * reports_nouns() rather than written out per dimension. Five dimensions times
	 * two credit modes is ten variants of a six-sentence help paragraph, which is
	 * ten places for one of them to go quietly stale.
	 */
	private function render_reports_breakdown( $data, $attribution, $dimension = self::REPORTS_DEFAULT_DIMENSION, $has_first = false ) {
		if ( empty( $data['rows'] ) ) {
			return;
		}
		$first_mode = ( 'first' === $attribution );
		$nouns      = self::reports_nouns( $dimension );
		$unknown    = BW_Lead_AI_Reports::unknown_label( $dimension );

		$channel_names = self::reports_channel_names( $data, $nouns );

		echo '<div class="bw-report-block">';
		// Heading is the dimension name itself, ucfirst — "Channel groups",
		// "Channels", "Sources". The table below is the whole content.
		echo '<h2>' . esc_html( ucfirst( $nouns['many'] ) ) . '</h2>';

		// Striped by hand rather than with core's .striped: the collapsed
		// breakdown subrows below are real <tr>s, and :nth-child cannot tell a
		// hidden subrow from a channel row — with them in the DOM the zebra falls
		// out of step with the rows a reader can actually see.
		echo '<table class="widefat"><thead><tr>';
		echo '<th>' . esc_html( $nouns['column'] ) . '</th>';
		echo '<th class="bw-report-num">' . esc_html__( 'Leads', 'bw-lead-ai' ) . '</th>';
		echo '<th class="bw-report-num">' . esc_html__( 'Share', 'bw-lead-ai' ) . '</th>';
		echo '<th class="bw-report-num">' . esc_html__( 'Pages read', 'bw-lead-ai' ) . '</th>';
		echo '</tr></thead><tbody>';

		// The tail of a channel list is one-lead referrers at 0% share — eighteen
		// rows here, eleven of which say nothing. They are real, so they are
		// counted, not dropped: the tail collapses into a single row that keeps
		// the arithmetic whole while leaving the reader with the buckets that
		// actually carry the site.
		$shown  = array_slice( $data['rows'], 0, self::REPORT_CHANNEL_ROWS );
		$tail   = array_slice( $data['rows'], self::REPORT_CHANNEL_ROWS );

		// Decided once, up front: rows without a breakdown get a spacer where the
		// button would sit, so every channel name starts at the same x — but only
		// when some row on this table actually has a button to align with.
		$any_expand = false;
		foreach ( $shown as $row ) {
			if ( self::reports_row_expands( $row ) ) {
				$any_expand = true;
				break;
			}
		}

		$stripe = 0;
		$group  = 0;
		foreach ( $shown as $row ) {
			$expands = self::reports_row_expands( $row );
			echo '<tr' . ( ( $stripe++ % 2 ) ? '' : ' class="bw-report-alt"' ) . '>';
			echo '<td>';
			if ( $expands ) {
				$group++;
				// Only the subrows that will exist — the same-bucket entries are
				// skipped below, and aria-controls pointing at an id that is never
				// rendered is a promise to a screen reader that is not kept.
				$ids = array();
				foreach ( (array) $row['breakdown'] as $j => $entry ) {
					if ( 'same' === $entry['kind'] ) {
						continue;
					}
					$ids[] = 'bw-report-sub-' . $group . '-' . $j;
				}
				$btn_label = $first_mode
					? sprintf(
						/* translators: 1: the plural dimension noun, e.g. "channels". 2: the row's name. */
						__( 'Show the %1$s the %2$s leads converted through, where it differs', 'bw-lead-ai' ),
						$nouns['many'],
						$row['channel']
					)
					: sprintf(
						/* translators: 1: the plural dimension noun, e.g. "channels". 2: the row's name. */
						__( 'Show the %1$s the %2$s leads originally came through, where it differs', 'bw-lead-ai' ),
						$nouns['many'],
						$row['channel']
					);
				echo '<button type="button" class="bw-report-expand" data-bw-expand="' . (int) $group . '"'
					. ' aria-expanded="false" aria-controls="' . esc_attr( implode( ' ', $ids ) ) . '"'
					. ' aria-label="' . esc_attr( $btn_label ) . '"><span aria-hidden="true">+</span></button>';
			} elseif ( $any_expand ) {
				echo '<span class="bw-report-expand-gap" aria-hidden="true"></span>';
			}
			// reports_name_link() returns escaped markup; see its docblock.
			$searchable = self::reports_name_searchable( $row['channel'], $nouns, $channel_names, $unknown );
			echo '<strong>' . $this->reports_name_link( $row['channel'], $searchable ) . '</strong></td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( (int) $row['leads'] ) ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( (int) $row['share_pct'] ) . '%' ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( (float) $row['avg_pages'], 1 ) ) . '</td>';
			echo '</tr>';
			if ( $expands ) {
				$this->render_reports_subrows( $row, $group, $attribution );
			}
		}

		if ( ! empty( $tail ) ) {
			$tail_leads = 0;
			foreach ( $tail as $row ) {
				$tail_leads += (int) $row['leads'];
			}
			echo '<tr class="bw-report-tail' . ( ( $stripe++ % 2 ) ? '' : ' bw-report-alt' ) . '">';
			echo '<td>' . ( $any_expand ? '<span class="bw-report-expand-gap" aria-hidden="true"></span>' : '' )
				. esc_html( self::reports_tail_label( $dimension, count( $tail ) ) ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( $tail_leads ) ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( self::pct_of( $tail_leads, (int) $data['totals']['leads'] ) ) . '%' ) . '</td>';
			echo '<td class="bw-report-num">—</td>';
			echo '</tr>';
		}
		echo '</tbody></table></div>';
	}

	/**
	 * What a row is, as the same link-pill control as the windows and the credit
	 * switch — and in the same place, under the page heading, because it changes
	 * the same amount: everything.
	 *
	 * It began life inside the performance table, switching that one table between
	 * channels and groups, and sitting there was correct while that was true. It is
	 * not true any more — the KPI card, the weekly chart and the transitions block
	 * all re-bucket with it — and a control that looks block-level while acting
	 * page-level is how a reader ends up believing the cards above it did NOT move.
	 *
	 * Three things deliberately do not follow it, and two of them are now their own
	 * tabs — which is why this pill is not drawn on either. The AI Analysis package
	 * is a self-contained artifact, always per-channel and always last-click, and
	 * says so on its tab because it is the one that looks like it should follow.
	 * Data Quality is about the tags a journey recorded rather than about attribution
	 * and the per-form table is about forms, so neither makes a claim about the
	 * dimension that could be wrong.
	 *
	 * The dimension composes with the other two axes rather than replacing them:
	 * the links carry the window and the credit mode, exactly as those carry this.
	 *
	 * @param string $range_key   The validated preset window.
	 * @param string $attribution 'last' or 'first' — preserved across the switch.
	 * @param string $current     The active key from BW_Lead_AI_Reports::dimensions().
	 */
	private function render_reports_dimension( $range_key, $attribution, $current ) {
		echo '<div class="bw-report-ranges bw-report-attribution"><ul class="subsubsub">';
		echo '<li class="bw-report-attribution-label">' . esc_html__( 'Break leads down by:', 'bw-lead-ai' ) . '</li>';
		$i = 0;
		foreach ( BW_Lead_AI_Reports::dimensions() as $key => $label ) {
			$url = add_query_arg( self::reports_link_args( $range_key, $attribution, $key ), admin_url( 'admin.php' ) );
			echo '<li>' . ( $i++ ? ' | ' : '' )
				. '<a href="' . esc_url( $url ) . '"' . ( $current === $key ? ' class="current"' : '' ) . '>'
				. esc_html( $label ) . '</a></li>';
		}
		echo '</ul></div>';
	}

	/**
	 * Every channel that earned a row in the per-channel table, as a set.
	 *
	 * Only built when the dimension needs it. A GROUP name is not a value any
	 * journey stored, so searching the journeys list for "Paid" finds nothing —
	 * except when the catch-all left a channel standing on its own as a group of
	 * one, where the name IS the stored value and the link works exactly as it
	 * always did. This is how reports_name_searchable() is told which is which.
	 */
	private static function reports_channel_names( $data, $nouns ) {
		$names = array();
		if ( 'channel' !== $nouns['link'] ) {
			return $names;
		}
		foreach ( (array) $data['channels'] as $channel_row ) {
			$names[ (string) $channel_row['channel'] ] = true;
		}
		return $names;
	}

	/**
	 * Would searching the journeys list for this name actually land on them?
	 *
	 * The journeys list searches the stored payload, so a source or a channel is
	 * findable and a joined `source / medium` pair is not — that string is
	 * assembled by this screen and appears in no journey. Nor is the invented
	 * "unknown" bucket label. A link to an empty list is worse than no link, which
	 * is the rule reports_name_link() is built on; this decides which side of it a
	 * given row falls.
	 */
	private static function reports_name_searchable( $name, $nouns, $channel_names, $unknown ) {
		if ( 'never' === $nouns['link'] ) {
			return false;
		}
		if ( '' !== $unknown && $unknown === (string) $name ) {
			return false;
		}
		if ( 'channel' === $nouns['link'] ) {
			return isset( $channel_names[ (string) $name ] );
		}
		return true;
	}


	/**
	 * Whether a row earns an expander: at least one of its leads came in through
	 * one bucket and enquired through a DIFFERENT one.
	 *
	 * That difference is the entire question the expander answers. It used to open
	 * for same-bucket returners too, on the reasoning that returned-vs-single-visit
	 * is itself a reveal — but on most rows that is the only thing inside, so the
	 * button opened to say "they came from where the row says", and a button that
	 * opens onto nothing teaches the reader to stop pressing buttons.
	 */
	private static function reports_row_expands( $row ) {
		if ( empty( $row['breakdown'] ) ) {
			return false;
		}
		foreach ( (array) $row['breakdown'] as $entry ) {
			if ( 'switch' === $entry['kind'] && (int) $entry['count'] > 0 ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * The collapsed breakdown rows under one channel row.
	 *
	 * Server-rendered and merely un-hidden by admin-reports.js, so nothing is
	 * fetched on click and the numbers are in the page for anything that reads
	 * HTML rather than pixels. Each subrow names the OTHER end of the journeys
	 * counted in the row above — where its leads actually enquired (first-click
	 * view) or what first found them (last-click view) — plus a line for
	 * returners who came back through the same channel and one for journeys with
	 * no other end recorded. The single-visit bulk is implied, not listed.
	 *
	 * The subrows name the other end at the SAME granularity as the row they hang
	 * under — a source row's subrows name sources. Naming channels beneath a group
	 * row would make the subrows and the row disagree about what is being counted,
	 * and a reader would have no way to tell which number to believe.
	 */
	private function render_reports_subrows( $row, $group, $attribution ) {
		$first_mode = ( 'first' === $attribution );

		foreach ( (array) $row['breakdown'] as $j => $entry ) {
			// Same in as out is dropped, not relabelled. It is the commonest entry
			// and it restates the row it hangs under, so it was the noise the
			// difference had to be found among. Its leads are still in the row's
			// own total — nothing is lost from the arithmetic, only from the list.
			if ( 'same' === $entry['kind'] ) {
				continue;
			}

			if ( 'switch' === $entry['kind'] ) {
				// Named from the reader's position. In last-click the row IS the
				// bucket they converted through, so the thing worth naming is where
				// they originally came from; in first-click it is the other way up.
				$label = $first_mode
					? sprintf(
						/* translators: %s: the name of the bucket at the other end of the journey. */
						__( 'Converted through %s', 'bw-lead-ai' ),
						$entry['channel']
					)
					: sprintf(
						/* translators: %s: the name of the bucket at the other end of the journey. */
						__( 'Originally came through %s', 'bw-lead-ai' ),
						$entry['channel']
					);
			} else {
				$label = $first_mode
					? __( 'No conversion source recorded', 'bw-lead-ai' )
					: __( 'No original source recorded', 'bw-lead-ai' );
			}

			echo '<tr class="bw-report-sub" id="bw-report-sub-' . (int) $group . '-' . (int) $j . '" data-bw-sub="' . (int) $group . '" hidden>';
			echo '<td class="bw-report-sub-label"><span class="bw-report-sub-arrow" aria-hidden="true">&#8627;</span> ' . esc_html( $label ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( (int) $entry['count'] ) ) . '</td>';
			echo '<td class="bw-report-num">' . esc_html( number_format_i18n( (int) $entry['pct'] ) . '%' ) . '</td>';
			echo '<td></td>';
			echo '</tr>';
		}
	}



	/**
	 * The Data Quality tab — every check this plugin runs, and what each one found.
	 *
	 * It was a block at the bottom of the Reports tab that listed only the problems
	 * that applied. That was the right instinct badly served: a list which is empty
	 * when all is well is also empty when nothing ran, and a reader had no way to
	 * tell those apart or to find out what was being watched for in the first place.
	 * A tab with room on it can show the whole register — what fired, what did not,
	 * and what each check is looking for — which is what makes an empty result mean
	 * something.
	 *
	 * ONE PILL, on purpose. The window changes which journeys are examined, so it is
	 * here. The credit mode and the dimension change how leads are BUCKETED, and no
	 * check on this tab buckets anything — they count journeys whose tags are wrong.
	 * Showing those two pills would invite a reader to click them and conclude the
	 * checks are insensitive to attribution, when the truth is that the question does
	 * not apply. Both are still carried in every link so the Reports tab is exactly
	 * as it was left.
	 */
	private function render_reports_quality_view() {
		$ranges      = BW_Lead_AI_Reports::ranges();
		$range_key   = self::reports_pill_param( 'range', array_map( 'strval', array_keys( $ranges ) ), self::REPORTS_DEFAULT_RANGE );
		$attribution = self::reports_pill_param( 'attribution', array( 'last', 'first' ), 'last' );
		$dimension   = self::reports_pill_param(
			'grouping',
			array_keys( BW_Lead_AI_Reports::dimensions() ),
			self::REPORTS_DEFAULT_DIMENSION
		);

		// Built with the same three axes the Reports tab is using, carried by the tab
		// strip. The checks do not vary by two of them — but building with a
		// different dimension from the tab next door would let the two screens quote
		// different numbers for the same window, which is precisely the kind of
		// quiet disagreement this tab exists to catch elsewhere.
		$data  = BW_Lead_AI_Reports::build( $range_key, $attribution, $dimension );
		$nouns = self::reports_nouns( $dimension );

		self::result_notice(
			'bw_dq',
			array(
				'dismissed'   => array( 'success', __( 'Finding dismissed. It stays on this tab, marked dismissed — and it comes back on its own if it grows.', 'bw-lead-ai' ) ),
				'undismissed' => array( 'success', __( 'Finding restored.', 'bw-lead-ai' ) ),
				'unknown'     => array( 'error', __( 'That is not a check this plugin runs, so nothing was changed.', 'bw-lead-ai' ) ),
			)
		);

		echo '<h2>' . esc_html__( 'Can you trust these numbers?', 'bw-lead-ai' ) . '</h2>';
		echo '<p class="description">' . esc_html__( 'What might be skewing the numbers on the Reports tab.', 'bw-lead-ai' ) . '</p>';

		$this->render_reports_ranges( $ranges, $range_key, $attribution, $dimension, 'quality' );

		$leads = (int) $data['totals']['leads'];
		if ( 0 === $leads ) {
			echo '<div class="notice notice-info inline bw-report-block"><p>'
				. esc_html__( 'No leads in this window, so there is nothing for the checks to examine. The checks themselves are listed below.', 'bw-lead-ai' )
				. '</p></div>';
		}

		$this->render_reports_quality( $data, self::reports_link_args( $range_key, $attribution, $dimension, 'quality' ) );

		// The blocks the Reports tab could not draw, stated here as well as there.
		// "Part of that report is missing" is the same question as "can you trust
		// these numbers", and this is where somebody comes to ask it.
		$first_mode   = ( 'first' === $attribution );
		$has_channels = ( self::reports_dimension_coverage( $data['coverage'], $dimension, $first_mode ) >= self::REPORTS_COVERAGE_FLOOR );
		$has_first    = ( self::reports_dimension_coverage( $data['coverage'], $dimension, true ) >= self::REPORTS_COVERAGE_FLOOR );
		$gaps         = ( $leads > 0 ) ? self::reports_gaps( $has_channels, $has_first, $first_mode, $nouns ) : array();

		if ( ! empty( $gaps ) ) {
			echo '<div class="bw-report-block">';
			echo '<h2>' . esc_html__( 'Blocks the Reports tab did not draw', 'bw-lead-ai' ) . '</h2>';
			echo '<p class="bw-report-lede">' . esc_html__( 'A ranking built on a tenth of the data reads as confidently as a full one, so these are withheld:', 'bw-lead-ai' ) . '</p>';
			echo '<ul class="bw-report-gaps">';
			foreach ( $gaps as $gap ) {
				echo '<li>' . esc_html( $gap ) . '</li>';
			}
			echo '</ul>';
			echo '</div>';
		}
	}

	/**
	 * The register, in three groups: what needs a look, what was dismissed, what is
	 * clear.
	 *
	 * Order is the whole design. Somebody arriving here is asking "is anything
	 * wrong", and the answer has to be the first thing on the screen — not the
	 * seventh row of a table sorted by check name. Dismissed findings sit in the
	 * middle, de-emphasised but present and reversible: a warning that disappeared
	 * entirely would be indistinguishable from a warning that was fixed, and this
	 * whole tab is built on the reader being able to believe what it says.
	 *
	 * @param array $data Aggregate from BW_Lead_AI_Reports::build().
	 * @param array $back The query args that address THIS view, so a dismissal can
	 *                    return the reader to the window and view they acted from —
	 *                    and so the window is recorded with the dismissal, since a
	 *                    later reader needs to know which set of numbers was accepted.
	 */
	private function render_reports_quality( $data, $back ) {
		$checks = BW_Lead_AI_Reports::quality_with_dismissals( $data['quality_checks'] );

		$attention = array();
		$dismissed = array();
		$clear     = array();
		foreach ( $checks as $check ) {
			if ( BW_Lead_AI_Reports::quality_needs_attention( $check['state'] ) ) {
				$attention[] = $check;
			} elseif ( 'dismissed' === $check['state'] ) {
				$dismissed[] = $check;
			} else {
				$clear[] = $check;
			}
		}
		// Biggest first inside the group that matters; the other two keep register
		// order, which is a stable reading order rather than a ranking of nothing.
		usort(
			$attention,
			function ( $a, $b ) {
				if ( (int) $a['count'] === (int) $b['count'] ) {
					return strcmp( $a['key'], $b['key'] );
				}
				return ( (int) $a['count'] < (int) $b['count'] ) ? 1 : -1;
			}
		);

		echo '<div class="bw-report-block">';

		if ( empty( $attention ) ) {
			echo '<h2>' . esc_html__( 'Nothing to flag', 'bw-lead-ai' ) . '</h2>';
			echo '<p class="bw-report-lede">' . esc_html(
				sprintf(
					/* translators: %s: how many checks ran. */
					_n(
						'%s check ran over this window and found nothing.',
						'All %s checks ran over this window and found nothing.',
						count( $checks ),
						'bw-lead-ai'
					),
					number_format_i18n( count( $checks ) )
				)
			) . '</p>';
		} else {
			echo '<h2>' . esc_html__( 'Found something', 'bw-lead-ai' ) . '</h2>';
			$this->render_quality_table( $attention, $back );
		}

		if ( ! empty( $dismissed ) ) {
			echo '<h2 class="bw-quality-subhead">' . esc_html__( 'Dismissed', 'bw-lead-ai' ) . '</h2>';
			echo '<p class="bw-report-lede">' . esc_html__( 'You have marked these known. One that grows 5 points or more comes back as a finding.', 'bw-lead-ai' ) . '</p>';
			$this->render_quality_table( $dismissed, $back );
		}

		// Collapsed by default: a clear check is reference material — the register of
		// what is being watched — not something competing for attention with findings.
		// The count on the closed row is what most readers need from it.
		if ( empty( $clear ) ) {
			echo '<h2 class="bw-quality-subhead">' . esc_html__( 'Clear', 'bw-lead-ai' ) . '</h2>';
			echo '<p class="bw-report-lede">' . esc_html__( 'Every check found something. Nothing is clear in this window.', 'bw-lead-ai' ) . '</p>';
		} else {
			echo '<details class="bw-lead-ai-section bw-quality-clear"><summary><h2>'
				. esc_html__( 'Clear', 'bw-lead-ai' ) . '</h2>'
				. wp_kses_post( $this->section_status( sprintf(
					/* translators: %d: number of checks that found nothing. */
					_n( '%d check found nothing', '%d checks found nothing', count( $clear ), 'bw-lead-ai' ),
					count( $clear )
				) ) )
				. '</summary>';
			$this->render_quality_table( $clear, $back );
			echo '</details>';
		}

		echo '</div>';
	}

	/**
	 * One group of checks as a table.
	 *
	 * Every row carries the SAME three things whatever its state: what the check
	 * looks for, what it found (or that it found nothing), and the control that
	 * changes that. A clear check that rendered as a bare tick would answer "is
	 * anything wrong" and drop the more useful question — what is being watched — on
	 * the floor.
	 *
	 * @param array $rows Checks from BW_Lead_AI_Reports::quality_with_dismissals().
	 * @param array $back The query args that address this view.
	 */
	private function render_quality_table( $rows, $back ) {
		echo '<table class="widefat striped bw-quality-table"><thead><tr>';
		echo '<th>' . esc_html__( 'Check', 'bw-lead-ai' ) . '</th>';
		echo '<th class="bw-report-num">' . esc_html__( 'Leads', 'bw-lead-ai' ) . '</th>';
		echo '<th>' . esc_html__( 'Action', 'bw-lead-ai' ) . '</th>';
		echo '</tr></thead><tbody>';

		foreach ( $rows as $check ) {
			$state = $check['state'];
			echo '<tr class="bw-quality-row is-' . esc_attr( $state ) . '">';

			echo '<td><strong>' . esc_html( $check['label'] ) . '</strong>';
			if ( 'dismissed' === $state ) {
				echo ' <span class="bw-section-status">' . esc_html__( 'dismissed', 'bw-lead-ai' ) . '</span>';
			} elseif ( 'lapsed' === $state ) {
				echo ' <span class="bw-section-status is-off">' . esc_html__( 'grown since you dismissed it', 'bw-lead-ai' ) . '</span>';
			} elseif ( 'clear' === $state ) {
				echo ' <span class="bw-section-status is-on">' . esc_html__( 'clear', 'bw-lead-ai' ) . '</span>';
			}

			// One explanation, behind the ?: the fired detail (which names the
			// specifics) if it fired, otherwise what the check watches for. Never
			// both — the heading and the count carry the row at a glance.
			$explain = ( ! empty( $check['fired'] ) && '' !== $check['detail'] ) ? $check['detail'] : $check['watches'];
			if ( '' !== $explain ) {
				echo ' ' . self::help( $explain );
			}

			if ( ! empty( $check['dismissal'] ) ) {
				echo '<span class="bw-report-detail">' . esc_html( $this->quality_dismissal_line( $check ) ) . '</span>';
			}
			echo '</td>';

			echo '<td class="bw-report-num">';
			if ( ! empty( $check['fired'] ) ) {
				echo esc_html(
					sprintf(
						/* translators: 1: number of leads affected, 2: whole-number percentage. */
						__( '%1$s (%2$s%%)', 'bw-lead-ai' ),
						number_format_i18n( (int) $check['count'] ),
						number_format_i18n( (int) $check['pct'] )
					)
				);
			} else {
				echo '<span class="bw-quality-none">&mdash;</span>';
			}
			echo '</td>';

			echo '<td>';
			$this->render_quality_dismiss_form( $check, $back );
			echo '</td>';

			echo '</tr>';
		}

		echo '</tbody></table>';
	}

	/**
	 * "Dismissed 3 August at 12% of leads (78) — now 41%."
	 *
	 * The numbers are the reason this line exists. A dismissal without them is a
	 * decision nobody can review; with them, the row says what was accepted and what
	 * is true now, and a reader can see for themselves whether the two are still the
	 * same thing.
	 */
	private function quality_dismissal_line( $check ) {
		$record = $check['dismissal'];
		$when   = ( '' !== $record['time'] )
			? mysql2date( get_option( 'date_format' ), get_date_from_gmt( $record['time'] ) )
			: __( 'an unknown date', 'bw-lead-ai' );

		$then = sprintf(
			/* translators: 1: a date, 2: a whole-number percentage, 3: a number of leads. */
			__( 'Dismissed %1$s, when it was %2$s%% of leads (%3$s).', 'bw-lead-ai' ),
			$when,
			number_format_i18n( (int) $record['pct'] ),
			number_format_i18n( (int) $record['count'] )
		);

		if ( empty( $check['fired'] ) ) {
			return $then . ' ' . __( 'Not happening in this window.', 'bw-lead-ai' );
		}
		if ( 'lapsed' === $check['state'] ) {
			return $then . ' ' . sprintf(
				/* translators: 1: a whole-number percentage, 2: a number of leads. */
				__( 'It is now %1$s%% (%2$s) — grown past what was dismissed, so it is listed as a finding again.', 'bw-lead-ai' ),
				number_format_i18n( (int) $check['pct'] ),
				number_format_i18n( (int) $check['count'] )
			);
		}

		return $then;
	}

	/**
	 * The dismiss / un-dismiss control for one check.
	 *
	 * A real form posting to admin-post.php, not a link: it changes stored state, so
	 * it gets a nonce and a POST, and the handler redirects afterwards so a refresh
	 * cannot fire it a second time. A GET link would be re-fired by every prefetcher
	 * and every browser "reopen last tabs".
	 *
	 * A clear check gets no dismiss button — there is nothing to dismiss — but a
	 * clear check that was dismissed earlier keeps its un-dismiss control, because
	 * otherwise the record would be unreachable until the problem came back.
	 */
	private function render_quality_dismiss_form( $check, $back ) {
		$has_record = ! empty( $check['dismissal'] );
		// Reading the reports is a filterable capability a site can hand to a
		// marketing role; changing what the screen says is not. Same split the purge
		// and reprocess actions already make — and the control is not drawn for
		// somebody the handler would refuse, rather than drawn and then refused.
		if ( ! BW_Lead_AI_Caps::can_manage() || ( empty( $check['fired'] ) && ! $has_record ) ) {
			echo '<span class="bw-quality-none">&mdash;</span>';
			return;
		}

		$undo = ( 'dismissed' === $check['state'] ) || ( empty( $check['fired'] ) && $has_record );

		echo '<form action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" method="post" class="bw-quality-act">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::QUALITY_DISMISS_ACTION ) . '">';
		wp_nonce_field( self::QUALITY_DISMISS_ACTION );
		echo '<input type="hidden" name="check" value="' . esc_attr( $check['key'] ) . '">';
		// The view to come back to, carried as fields rather than rebuilt by the
		// handler: a redirect that guessed the window would answer a dismissal made on
		// "this year" by dropping the reader back into the last thirty days.
		foreach ( array( 'range', 'attribution', 'grouping' ) as $axis ) {
			if ( isset( $back[ $axis ] ) ) {
				echo '<input type="hidden" name="' . esc_attr( $axis ) . '" value="' . esc_attr( $back[ $axis ] ) . '">';
			}
		}
		echo '<input type="hidden" name="count" value="' . esc_attr( (int) $check['count'] ) . '">';
		echo '<input type="hidden" name="pct" value="' . esc_attr( (int) $check['pct'] ) . '">';
		echo '<input type="hidden" name="state" value="' . ( $undo ? 'restore' : 'dismiss' ) . '">';
		echo '<button type="submit" class="button button-small">'
			. ( $undo ? esc_html__( 'Un-dismiss', 'bw-lead-ai' ) : esc_html__( 'Dismiss', 'bw-lead-ai' ) )
			. '</button>';
		echo '</form>';
	}

	/**
	 * The AI Analysis tab.
	 *
	 * Its own screen because it is the longest thing this plugin renders — a written
	 * brief plus the whole window as data, in a box you are meant to select and copy
	 * — and because building it costs a second pass over the window's journeys, which
	 * the Reports tab should not pay for on every load to show a panel most readers
	 * never open.
	 *
	 * ONE PILL, for the same reason as the Data Quality tab and one more: the package
	 * is ALWAYS built per channel and by last click whatever the Reports tab is
	 * showing, because it is a self-contained artifact and a dataset whose numbers
	 * quietly depended on which pills were lit when it was copied would poison every
	 * conclusion drawn from it. Only the window follows the page, so only the window
	 * is offered here. The other two axes still ride along in the links so the
	 * Reports tab keeps its view.
	 */
	private function render_reports_ai_view() {
		$ranges      = BW_Lead_AI_Reports::ranges();
		$range_key   = self::reports_pill_param( 'range', array_map( 'strval', array_keys( $ranges ) ), self::REPORTS_DEFAULT_RANGE );
		$attribution = self::reports_pill_param( 'attribution', array( 'last', 'first' ), 'last' );
		$dimension   = self::reports_pill_param(
			'grouping',
			array_keys( BW_Lead_AI_Reports::dimensions() ),
			self::REPORTS_DEFAULT_DIMENSION
		);

		$data = BW_Lead_AI_Reports::build( $range_key, $attribution, $dimension );

		echo '<h2>' . esc_html__( 'AI Analysis', 'bw-lead-ai' ) . '</h2>';
		$this->render_reports_ranges( $ranges, $range_key, $attribution, $dimension, 'ai' );
		$this->render_reports_ai_export( $range_key, $data, $attribution, $dimension );
	}

	/**
	 * The same window, packaged for somebody else's AI to investigate.
	 *
	 * The blocks above answer the questions this plugin knows how to ask. This one
	 * hands the whole window to an assistant that can also go and LOOK at the site,
	 * which is the half no report can do: a table can say that a landing page starts
	 * forty journeys, and only something with a browser can say that the page never
	 * mentions the service the campaign sending traffic to it advertises.
	 *
	 * It is a copy box rather than an integration on purpose. There is no API key to
	 * store, no outbound request, and nothing leaves this server unless the owner
	 * pastes it somewhere themselves — which is also the only honest way to offer a
	 * feature that can be asked to include customer names.
	 *
	 * It has its own tab because it is the longest thing this plugin renders and
	 * because building the package costs a second pass over the window's journeys —
	 * a cost the Reports tab used to pay on every load for a panel that was collapsed
	 * by default and rarely opened. On a tab you have chosen to open it is drawn open.
	 *
	 * The package itself is always built last-click and per channel — it explains its
	 * own attribution model in its brief, and handing an assistant a dataset whose
	 * numbers quietly depended on which pill was lit when the owner copied it would
	 * poison every conclusion drawn from it. The Off/On links carry the page's window,
	 * credit mode, dimension AND tab, so toggling PII lands the reader back exactly
	 * where they were rather than on the Reports tab with their view reset.
	 *
	 * @param string $range_key   The validated preset window this page is showing.
	 * @param array  $data        Aggregate from BW_Lead_AI_Reports::build().
	 * @param string $attribution 'last' or 'first' — the page view to preserve in links.
	 * @param string $dimension   The dimension to preserve in links.
	 */
	private function render_reports_ai_export( $range_key, $data, $attribution, $dimension = self::REPORTS_DEFAULT_DIMENSION ) {
		$include_pii = self::reports_pii_requested();

		$base    = self::reports_link_args( $range_key, $attribution, $dimension, 'ai' );
		$off_url = add_query_arg( $base, admin_url( 'admin.php' ) );
		$on_url  = add_query_arg( array_merge( $base, array( 'pii' => '1' ) ), admin_url( 'admin.php' ) );

		echo '<div class="bw-report-block">';
		echo '<p class="description bw-copy">'
			. wp_kses_post( $this->section_status( $data['range']['label'] ) )
			. '</p>';

		echo '<p class="description bw-copy">'
			. esc_html__( 'A ready-made investigation package — a written brief, plus this window as data — to paste into an AI assistant that can browse the web.', 'bw-lead-ai' )
			. wp_kses_post(
				$this->help(
					__( 'The <strong>brief</strong> tells the assistant how to work: ground every claim in a figure or a page it opened, never invent a conversion rate, and look at your pages before recommending changes to them. The <strong>dataset</strong> is this window — the same journeys as the report, plus the pages people read and a dozen full journeys — and it leads with its own limitations. Nothing is sent anywhere by this site; the text only travels when you paste or upload it.', 'bw-lead-ai' )
				)
			)
			. '</p>';

		// Said on the tab, not only in the brief inside the file. This is the one
		// screen here that does NOT follow the credit and dimension pills, and a
		// reader who has just switched the Reports tab to mediums would otherwise
		// reasonably expect the package to be about mediums too. It is also why
		// those two pills are not drawn on this tab: an inert control is a worse
		// explanation than a sentence.
		echo '<p class="description bw-copy">' . esc_html__(
			'The package is always built by channel and by last click, whichever view the Reports tab is showing; only the date range above follows the page.',
			'bw-lead-ai'
		) . '</p>';

		echo '<p class="bw-report-lede"><strong>' . esc_html__( 'Include names and emails', 'bw-lead-ai' ) . '</strong></p>';
		echo '<div class="bw-report-ranges"><ul class="subsubsub">';
		echo '<li><a href="' . esc_url( $off_url ) . '"' . ( $include_pii ? '' : ' class="current"' ) . '>'
			. esc_html__( 'Off', 'bw-lead-ai' ) . '</a> | </li>';
		echo '<li><a href="' . esc_url( $on_url ) . '"' . ( $include_pii ? ' class="current"' : '' ) . '>'
			. esc_html__( 'On', 'bw-lead-ai' ) . '</a></li>';
		echo '</ul></div>';

		if ( $include_pii ) {
			echo '<p class="description bw-copy bw-warn">' . esc_html__(
				'On — the package names the sampled enquirers and gives their email addresses. That is personal data about real people: only paste it into a service you would be willing to hand your customer list to, and delete the downloaded file when you are done with it.',
				'bw-lead-ai'
			) . '</p>';
		} else {
			echo '<p class="description bw-copy">' . esc_html__(
				'Off — names and emails are replaced with “Lead #” and the record number, so the package contains no personal data. Everything else is identical.',
				'bw-lead-ai'
			) . '</p>';
		}

		echo '<div class="bw-wizard-code">';
		echo '<div class="bw-wizard-code-bar">';
		echo '<span>' . esc_html__( 'Paste this into your AI assistant', 'bw-lead-ai' ) . '</span>';
		echo '<button type="button" class="button button-small bw-lead-ai-copy" data-target="data-ai-package">'
			. esc_html__( 'Copy', 'bw-lead-ai' ) . '</button>';
		echo '</div>';
		// esc_textarea(), not esc_html(): the package is markdown containing angle
		// brackets and ampersands from page paths a stranger chose, and it has to come
		// back out of the box byte-for-byte when it is copied.
		echo '<textarea readonly rows="14" class="bw-lead-ai-pre" data-ai-package>'
			. esc_textarea( BW_Lead_AI_AI_Export::package( $range_key, $include_pii ) )
			. '</textarea>';
		echo '</div>';

		echo '<p class="bw-report-lede">';
		echo '<a class="button" href="' . esc_url( $this->reports_ai_download_url( 'package', $range_key, $include_pii ) ) . '">'
			. esc_html__( 'Download package (.md)', 'bw-lead-ai' ) . '</a> ';
		echo '<a class="button" href="' . esc_url( $this->reports_ai_download_url( 'dataset', $range_key, $include_pii ) ) . '">'
			. esc_html__( 'Download dataset only (.md)', 'bw-lead-ai' ) . '</a>';
		echo '</p>';
		echo '<p class="description bw-copy">' . esc_html__(
			'The dataset on its own is for feeding into something that already has its own instructions. Most people want the package.',
			'bw-lead-ai'
		) . '</p>';

		echo '</div>';
	}

	/**
	 * A one-line result for an action that redirected back to a screen.
	 *
	 * Read through reports_pill_param(), the page's existing whitelisted GET reader,
	 * rather than touching the superglobal again: the set of results an action can
	 * report is fixed and small, so it is exactly the same problem the range and
	 * dimension pills already solved — a fixed list, and anything else is nothing.
	 * That is also why no message text ever travels in the URL. A handler with
	 * something specific to say parks it (see the import preview) instead.
	 *
	 * @param string $param    The query parameter the handler redirected with.
	 * @param array  $messages Result key => array( 'success'|'error'|'warning', text ).
	 */
	private static function result_notice( $param, $messages ) {
		$key = self::reports_pill_param( $param, array_keys( $messages ), '' );
		if ( '' === $key || ! isset( $messages[ $key ] ) ) {
			return;
		}
		$type = ( isset( $messages[ $key ][0] ) && in_array( $messages[ $key ][0], array( 'success', 'error', 'warning' ), true ) )
			? $messages[ $key ][0]
			: 'success';

		echo '<div class="notice notice-' . esc_attr( $type ) . ' is-dismissible"><p>'
			. esc_html( (string) $messages[ $key ][1] ) . '</p></div>';
	}

	/**
	 * Is the caller asking for the export to name people?
	 *
	 * One definition of "on" for both readers — the screen deciding what to render and
	 * the download handler deciding what to write — because two independently written
	 * truthiness checks is exactly how a flag ends up meaning one thing in the preview
	 * and another in the file. Strict comparison against the single value this plugin
	 * ever puts in the link: anything else, including a bare `?pii`, is off.
	 */
	private static function reports_pii_requested() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- on the Reports screen this only chooses what the preview shows; the download handler verifies its nonce before calling this.
		$raw = isset( $_GET['pii'] ) ? sanitize_key( wp_unslash( $_GET['pii'] ) ) : '';

		return ( '1' === $raw );
	}

	/**
	 * A nonced link to the download handler, carrying the window and the PII choice.
	 *
	 * The PII flag rides in the URL rather than being re-read from a session or
	 * silently defaulting: the file that arrives has to be the one the button on
	 * screen described, and the only way to guarantee that is for the request to say
	 * which it wants. The nonce is what stops somebody else's page linking a logged-in
	 * administrator's browser at the version containing names.
	 */
	private function reports_ai_download_url( $what, $range_key, $include_pii ) {
		$url = add_query_arg(
			array(
				'action' => self::AI_EXPORT_ACTION,
				'what'   => ( 'dataset' === $what ) ? 'dataset' : 'package',
				'range'  => $range_key,
				'pii'    => $include_pii ? '1' : '0',
			),
			admin_url( 'admin-post.php' )
		);

		return wp_nonce_url( $url, self::AI_EXPORT_ACTION );
	}

	/**
	 * Stream the AI Analysis package as a downloadable markdown file.
	 *
	 * Its own request rather than a link back to the Reports screen, because a
	 * Content-Disposition cannot be sent from a page that has already emitted
	 * wp-admin's header. Same shape as the other actions outside the settings form:
	 * capability, nonce, do the work, and in this case send instead of redirect.
	 *
	 * The body is echoed unescaped on purpose. It is a markdown document, not HTML —
	 * escaping it would put `&amp;` into the file the owner opens — and it is served
	 * as an attachment with nosniff so a browser cannot decide to render it as a page.
	 */
	public function handle_ai_export() {
		if ( ! current_user_can( BW_Lead_AI_Handoff_Admin::view_capability() ) ) {
			wp_die( esc_html__( 'You do not have permission to do that.', 'bw-lead-ai' ) );
		}
		check_admin_referer( self::AI_EXPORT_ACTION );

		$ranges    = BW_Lead_AI_Reports::ranges();
		$range_key = isset( $_GET['range'] ) ? sanitize_key( wp_unslash( $_GET['range'] ) ) : self::REPORTS_DEFAULT_RANGE;
		if ( ! isset( $ranges[ $range_key ] ) ) {
			$range_key = self::REPORTS_DEFAULT_RANGE;
		}
		$what = isset( $_GET['what'] ) ? sanitize_key( wp_unslash( $_GET['what'] ) ) : 'package';

		$response = BW_Lead_AI_AI_Export::download_response( $what, $range_key, self::reports_pii_requested() );

		nocache_headers();
		header( 'Content-Type: ' . $response['content_type'] );
		header( 'Content-Disposition: attachment; filename="' . $response['filename'] . '"' );
		header( 'Content-Length: ' . strlen( $response['body'] ) );
		// The file names channels and page paths a stranger chose. Served as an
		// attachment it is inert, but a browser that sniffs it as HTML would not be —
		// so it is told not to.
		header( 'X-Content-Type-Options: nosniff' );

		echo $response['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a markdown file body, not markup; escaping it would corrupt the download.
		exit;
	}


	// --- state-changing actions outside the settings form -------------------

	/**
	 * Dismiss or restore one data-quality finding.
	 *
	 * The count and share are read from the FORM rather than recomputed, and that is
	 * deliberate: a dismissal is an acceptance of the numbers the person was looking
	 * at, so those are the numbers to record. Recomputing here would silently record
	 * a different window's figures if the aggregate moved between render and click,
	 * and the whole regrowth rule is a comparison against what was accepted.
	 *
	 * Both values are clamped by BW_Lead_AI_Reports::quality_dismiss() and the key is
	 * checked against the register there, so a hand-crafted POST can record an
	 * implausible number against a real check and nothing else — no new keys, no
	 * arbitrary values, nothing outside this one option.
	 */
	public function handle_quality_dismiss() {
		// The manage capability, not the view one: this WRITES. A site that
		// has handed report-reading to a marketing role has not thereby handed over
		// the ability to silence a warning for everybody else.
		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::QUALITY_DISMISS_ACTION );

		$key    = sanitize_key( self::posted( 'check' ) );
		$undo   = ( 'restore' === self::posted( 'state' ) );
		$result = 'unknown';

		if ( $undo ) {
			$result = BW_Lead_AI_Reports::quality_undismiss( $key ) ? 'undismissed' : 'unknown';
		} elseif ( BW_Lead_AI_Reports::quality_dismiss( $key, (int) self::posted( 'count' ), (int) self::posted( 'pct' ), sanitize_key( self::posted( 'range' ) ) ) ) {
			$result = 'dismissed';
		}

		// Back to the view they acted from, whitelisted on the way out exactly as it
		// is on the way in — the same three readers, so a value that could not have
		// arrived through the page cannot leave through the redirect either.
		$ranges = BW_Lead_AI_Reports::ranges();
		$args   = self::reports_link_args(
			self::whitelisted( self::posted( 'range' ), array_map( 'strval', array_keys( $ranges ) ), self::REPORTS_DEFAULT_RANGE ),
			self::whitelisted( self::posted( 'attribution' ), array( 'last', 'first' ), 'last' ),
			self::whitelisted( self::posted( 'grouping' ), array_keys( BW_Lead_AI_Reports::dimensions() ), self::REPORTS_DEFAULT_DIMENSION ),
			'quality'
		);
		$args['bw_dq'] = $result;

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

	/**
	 * One value against a fixed list, for values that arrive by POST.
	 *
	 * reports_pill_param() is the same rule for the query string. This is its twin
	 * rather than a generalisation of it, because the two differ in the one place
	 * that matters — where the value comes from — and folding them together would
	 * mean a function that reads whichever superglobal it is told to, which is a
	 * worse thing to have than two short functions.
	 */
	private static function whitelisted( $raw, $allowed, $default ) {
		$raw = sanitize_key( (string) $raw );

		return in_array( $raw, $allowed, true ) ? $raw : $default;
	}

	/**
	 * Stream this site's portable settings as a JSON file.
	 *
	 * Its own admin-post request for the same reason the AI package download is: a
	 * Content-Disposition cannot be sent from a page that has already emitted
	 * wp-admin's header.
	 *
	 * The body is echoed unescaped on purpose — it is a JSON document, not markup,
	 * and escaping it would put `&amp;` and `&#039;` into a file whose entire job is
	 * to be read back byte-for-byte by the importer. It is served as an attachment
	 * with nosniff, so a browser cannot decide to render it as a page.
	 */
	public function handle_settings_export() {
		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::SETTINGS_EXPORT_ACTION );

		$body = wp_json_encode( BW_Lead_AI_Settings::export_payload(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
		if ( ! is_string( $body ) ) {
			wp_die( esc_html__( 'Could not build the export file.', 'bw-lead-ai' ) );
		}

		$host = wp_parse_url( home_url(), PHP_URL_HOST );
		$name = 'bw-lead-ai-settings-' . sanitize_file_name( (string) $host ) . '-' . gmdate( 'Y-m-d' ) . '.json';

		nocache_headers();
		header( 'Content-Type: application/json; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . $name . '"' );
		header( 'Content-Length: ' . strlen( $body ) );
		header( 'X-Content-Type-Options: nosniff' );

		echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- a JSON file body, not markup; escaping it would corrupt the download.
		exit;
	}

	/**
	 * Step one of an import: validate, park, and send the reader to the preview.
	 *
	 * NOTHING is written to the settings option here. The file has been validated as
	 * far as its shape — it says it is one of ours, it carries a schema this plugin
	 * understands, and every key in it is on the portable list — but its VALUES are
	 * still a stranger's strings and do not go near the option until they have been
	 * through sanitize() on the apply step.
	 *
	 * Parked in a per-user transient rather than round-tripped through a hidden field
	 * so the preview screen cannot be a form somebody else's page submits, and so a
	 * megabyte of JSON is not carried through a redirect.
	 */
	public function handle_settings_import() {
		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::SETTINGS_IMPORT_ACTION );

		$raw    = self::posted( 'bw_import_json' );
		$upload = self::uploaded_import();
		if ( '' !== $upload ) {
			$raw = $upload;
		}

		$parsed = BW_Lead_AI_Settings::parse_import( $raw );
		if ( empty( $parsed['ok'] ) ) {
			// The reason is specific and worth reading, and it must not travel in the
			// URL: it is a sentence chosen from a fixed set here, but a query
			// parameter is a place a stranger's text could be put later by somebody
			// who did not know that. Parked and read once instead.
			set_transient( self::import_error_key(), $parsed['error'], MINUTE_IN_SECONDS );
			wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'import_failed' ) ) );
			exit;
		}

		set_transient( self::import_key(), $parsed, self::IMPORT_PREVIEW_TTL );
		wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_import' => 'preview' ) ) );
		exit;
	}

	/**
	 * Step two: apply the parked import, or throw it away.
	 *
	 * The values are merged over the CURRENT settings and the whole array goes
	 * through BW_Lead_AI_Settings::sanitize() — the same function every settings tab
	 * posts through — before it is stored. Merged rather than replaced because an
	 * export carries only the portable keys: a replace would silently reset every
	 * site-specific setting the file deliberately does not contain.
	 */
	public function handle_settings_import_apply() {
		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::SETTINGS_IMPORT_APPLY_ACTION );

		$parked = get_transient( self::import_key() );
		delete_transient( self::import_key() );

		if ( 'discard' === self::posted( 'state' ) ) {
			wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'import_discarded' ) ) );
			exit;
		}
		if ( ! is_array( $parked ) || empty( $parked['settings'] ) || ! is_array( $parked['settings'] ) ) {
			// Expired, already applied, or never there. Never guessed at.
			wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'import_expired' ) ) );
			exit;
		}

		$merged    = array_merge( BW_Lead_AI_Settings::get(), $parked['settings'] );
		$sanitized = BW_Lead_AI_Settings::instance()->sanitize( $merged );
		update_option( BW_LEAD_AI_OPTION, $sanitized );

		wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'imported' ) ) );
		exit;
	}

	/**
	 * Put every setting back to the shipped defaults.
	 *
	 * The one genuinely destructive control in the plugin, so it is the only one that
	 * asks for a word to be typed. A confirm() dialog is a keystroke; typing RESET is
	 * a decision, and the box beside it says in as many words what will and will not
	 * be cleared.
	 *
	 * The word is compared case-insensitively and untranslated. It is a shibboleth
	 * rather than a message — the label above the box shows exactly what to type — and
	 * a translated one would mean a site in another language could not be reset by
	 * following the English documentation.
	 */
	public function handle_settings_reset() {
		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::SETTINGS_RESET_ACTION );

		$typed = strtoupper( trim( self::posted( 'bw_reset_confirm' ) ) );
		if ( self::SETTINGS_RESET_WORD !== $typed ) {
			wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'reset_unconfirmed' ) ) );
			exit;
		}

		BW_Lead_AI_Settings::factory_reset();

		wp_safe_redirect( self::settings_tab_url( 'other', array( 'bw_settings' => 'reset' ) ) );
		exit;
	}

	/** Where a parked import lives, per user so two administrators cannot collide. */
	private static function import_key() {
		return 'bw_lead_ai_import_' . get_current_user_id();
	}

	/** Where the reason a rejected import was rejected lives, until it is shown once. */
	private static function import_error_key() {
		return 'bw_lead_ai_import_error_' . get_current_user_id();
	}

	/** A settings tab's URL, with any result parameters. */
	private static function settings_tab_url( $tab, $args = array() ) {
		return add_query_arg(
			array_merge( array( 'page' => self::PAGE_SLUG, 'tab' => $tab ), $args ),
			admin_url( 'admin.php' )
		);
	}

	/**
	 * The body of an uploaded export file, or ''.
	 *
	 * Guarded by is_uploaded_file() before anything is read, which is what stops a
	 * crafted `tmp_name` turning this into a read of any path on the server — the
	 * classic version of this bug. Size is checked against PHP's own record of the
	 * upload rather than against the file, and parse_import() bounds the string
	 * again, because a limit that is only enforced in one place is a limit that gets
	 * removed by the next person who refactors the other.
	 */
	private static function uploaded_import() {
		// phpcs:disable WordPress.Security.NonceVerification.Missing -- the caller has already run check_admin_referer().
		if ( empty( $_FILES['bw_import_file'] ) || ! is_array( $_FILES['bw_import_file'] ) ) {
			return '';
		}
		$file = $_FILES['bw_import_file'];
		// phpcs:enable
		if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) || ! is_string( $file['tmp_name'] ) ) {
			return '';
		}
		if ( ! is_uploaded_file( $file['tmp_name'] ) ) {
			return '';
		}
		if ( isset( $file['size'] ) && (int) $file['size'] > 1048576 ) {
			return '';
		}
		$body = file_get_contents( $file['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- a local upload temp file that is_uploaded_file() has just vouched for, not a URL.

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

	// --- reports helpers ----------------------------------------------------

	/**
	 * Peel the one named entry off a list that is carrying both.
	 *
	 * `weekly` and `transitions` come back as numeric lists with a single string
	 * key riding along — the stack order on one, the most under-credited channel on
	 * the other. A plain foreach walks straight into it and hands the loop body an
	 * array of channel names, or a null, where it expected a row. Splitting it in
	 * one place means the hazard is understood once rather than remembered twice.
	 *
	 * @param array  $mixed The list-plus-one-key structure.
	 * @param string $named The string key to lift out.
	 * @return array [ list, named value or null ]
	 */
	private static function reports_list( $mixed, $named ) {
		$mixed = (array) $mixed;
		$extra = isset( $mixed[ $named ] ) ? $mixed[ $named ] : null;
		unset( $mixed[ $named ] );

		return array( array_values( $mixed ), $extra );
	}

	/**
	 * Which palette slot a stacked band and its legend key use.
	 *
	 * "Other" is pinned to the grey slot rather than taking the next colour along:
	 * it is the residue of everything unranked, and a hue of its own makes it look
	 * like a channel somebody could go and spend money on.
	 */
	private static function reports_band_class( $channel, $index ) {
		if ( BW_Lead_AI_Reports::OTHER === $channel ) {
			return 'bw-chart-other';
		}
		return 'bw-chart-c' . ( ( (int) $index % 5 ) + 1 );
	}

	/**
	 * A channel or form name, linked to the journeys behind it.
	 *
	 * The journeys list searches the stored payload AND the form name, so one link
	 * shape serves both. A name only links when searching for it would actually
	 * land on those journeys — the unknown-form bucket is a label this plugin
	 * invented rather than a value anybody stored, and a link to an empty list is
	 * worse than no link. add_query_arg() does not encode values it is handed, so
	 * the name is encoded here; a channel called "Cats & Dogs" would otherwise
	 * arrive as two query parameters.
	 *
	 * Returns escaped markup — callers echo it directly.
	 *
	 * @param string $name       The channel or form name, as stored.
	 * @param bool   $searchable False for a bucket label that matches no journey.
	 * @return string
	 */
	private function reports_name_link( $name, $searchable = true ) {
		$name = (string) $name;
		if ( '' === $name ) {
			return '&mdash;';
		}
		if ( ! $searchable ) {
			return esc_html( $name );
		}
		$url = add_query_arg( 's', rawurlencode( $name ), BW_Lead_AI_Handoff_Admin::list_url() );

		return '<a href="' . esc_url( $url ) . '">' . esc_html( $name ) . '</a>';
	}

	private function render_settings_tab() {
		$settings = BW_Lead_AI_Settings::get();
		$opt      = esc_attr( BW_LEAD_AI_OPTION );
		$standard_list = '<code>' . implode( '</code>, <code>', BW_Lead_AI_Settings::STANDARD_ALIAS_KEYS ) . '</code>';
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>
			<?php
			$this->render_passthrough(
				$settings,
				// Five mapping sections, five keys. `debug` and `capture_ga_client_id`
				// moved to the Other Settings tab and must NOT be listed here: a key on
				// this list with no input on the form is reset to its default the next
				// time the tab is saved, which is the exact bug tests/tab-save-roundtrip
				// exists to catch. `self_referral_hosts` is off the list because no
				// screen edits it any more — sanitize() preserves it instead, which is
				// what keeps the retired value readable as a migration source.
				array( 'parameter_aliases', 'referrer_classification', 'click_ids', 'channels', 'channel_groups' )
			);
			?>

			<p class="description bw-copy"><?php
				echo wp_kses(
					__( 'All mapping sections share one format: <code>label : value1, value2, ...</code>. One rule per line. Rules are walked in order — earlier rules win over later ones when both could match.', 'bw-lead-ai' ),
					array( 'code' => array() )
				);
				?></p>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Tracking Parameter Definitions', 'bw-lead-ai' ); ?></h2><?php
					$alias_n = count( BW_Lead_AI_Settings::split_pair_lines( $settings['parameter_aliases'] ) );
					/* translators: %d: number of parameter groups. */
					echo wp_kses_post( $this->section_status( sprintf( _n( '%d parameter', '%d parameters', $alias_n, 'bw-lead-ai' ), $alias_n ) ) );
				?></summary>
				<p class="description"><?php

					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( 'Any label beyond the built-ins (<strong>Source</strong>, <strong>Medium</strong>, <code>campaign</code>, <code>term</code>, <code>content</code>, <code>adgroup</code>) becomes a custom dimension — a merge tag <code>{bw:your_label}</code>.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array(), 'em' => array() )
					);
					?></p>
				<p class="description"><?php

					?></p>
				<?php
				$this->render_pair_rows(
					'parameter_aliases',
					$settings['parameter_aliases'],
					array(
						'key_head'             => __( 'Dimension', 'bw-lead-ai' ),
						'val_head'             => __( 'URL parameters', 'bw-lead-ai' ),
						'key_placeholder'      => 'match_type',
						'val_placeholder'      => 'mt, match, utm_match_type',
						'add_label'            => __( 'Add dimension', 'bw-lead-ai' ),
						'locked'               => array( 'source', 'medium' ),
						// Aliases are looked up by name, never walked in order, so
						// there is nothing for reorder buttons to decide.
						'ordered'              => false,
						'textarea_placeholder' => "campaign : utm_campaign, cmp\nmatch_type : mt, utm_match_type",
					)
				);
				?>
			</details>

			<?php
			// The DERIVED text, not the stored one. It carries the `internal` row a
			// site configured before that row existed would otherwise be missing, and
			// anything the retired owned-properties list was holding — so what is on
			// screen is what the plugin is actually matching, and saving the tab makes
			// it permanent. See BW_Lead_AI_Settings::referrer_classification_text().
			$classification = BW_Lead_AI_Settings::referrer_classification_text( $settings );
			?>
			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Medium Mappings', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post( $this->rule_count_status( $classification ) );
				?></summary>
				<p class="description"><?php
					echo wp_kses(
						__( 'When a visit has no UTM tags and no click-ID, the referring hostname decides the medium.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array() )
					);
					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( '<code>google</code> matches any host with that label, including <code>gemini.google.com</code> — so keep <code>ai</code> above <code>organic</code>, or AI referrals file as organic search.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array() )
					);
					?></p>
				<div class="description bw-copy"><?php
					echo wp_kses(
						/* translators: %s: this site's own hostname. */
						sprintf(
							__( 'The <code>internal</code> row lists the hostnames you own. Use <code>{this-domain}</code> for this site (<code>%s</code>), or a <code>*.</code> prefix for subdomains only.', 'bw-lead-ai' ),
							esc_html( BW_Lead_AI_Settings::own_hostname() )
						),
						array( 'code' => array(), 'strong' => array() )
					);
					echo wp_kses_post(
						$this->help(
							__( 'Without this row, a return from your own subdomain is credited as a new channel. After adding a hostname, run <strong>Reprocess</strong> (Journey Storage) to apply it to stored journeys.', 'bw-lead-ai' )
						)
					);
					?></div>
				<?php
				$this->render_pair_rows(
					'referrer_classification',
					$classification,
					array(
						'key_head'             => __( 'Medium', 'bw-lead-ai' ),
						'val_head'             => __( 'Referrer hostnames', 'bw-lead-ai' ),
						'key_placeholder'      => 'organic',
						'val_placeholder'      => 'google, bing, duckduckgo',
						'add_label'            => __( 'Add rule', 'bw-lead-ai' ),
						'ordered'              => true,
						'textarea_rows'        => 6,
						'textarea_placeholder' => "internal : *.{this-domain}\nai : chatgpt.com, claude.ai\norganic : google, bing",
					)
				);
				?>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Source / Medium Mappings', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post( $this->rule_count_status( $settings['click_ids'] ) );
				?></summary>
				<p class="description"><?php
					echo wp_kses(
						__( 'When a URL carries one of these click-ID parameters and no explicit UTMs, the visit is tagged with the <code>source/medium</code> on the left.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
				<?php
				$this->render_pair_rows(
					'click_ids',
					$settings['click_ids'],
					array(
						'key_head'             => __( 'Source / medium', 'bw-lead-ai' ),
						'val_head'             => __( 'Click-ID parameters', 'bw-lead-ai' ),
						'key_placeholder'      => 'google/cpc',
						'val_placeholder'      => 'gclid, gclsrc',
						'add_label'            => __( 'Add click-ID rule', 'bw-lead-ai' ),
						'ordered'              => true,
						'textarea_placeholder' => "google/cpc : gclid, gclsrc\nfacebook/social : fbclid",
					)
				);
				?>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Channel Mappings', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post( $this->rule_count_status( $settings['channels'] ) );
				?></summary>
				<p class="description"><?php
					echo wp_kses(
						__( 'Map <code>source / medium</code> combinations to friendly channel labels (e.g. "Google Ads"). Unmatched visits keep the raw <code>source / medium</code> string.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( '<code>*</code> matches any source or medium, and <code>{bw:source}</code> / <code>{bw:medium}</code> in a label stand for the visit&#8217;s own values &mdash; e.g. <code>{bw:source} : */referral</code>.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
				<?php
				$this->render_pair_rows(
					'channels',
					$settings['channels'],
					array(
						'key_head'             => __( 'Channel', 'bw-lead-ai' ),
						'val_head'             => __( 'Source / medium pairs', 'bw-lead-ai' ),
						'key_placeholder'      => 'Google Ads',
						'val_placeholder'      => 'google/cpc, google/ppc',
						'add_label'            => __( 'Add channel', 'bw-lead-ai' ),
						'ordered'              => true,
						'textarea_rows'        => 14,
						'textarea_placeholder' => "Google Ads : google/cpc, google/ppc\n{bw:source} : */referral",
					)
				);
				?>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Channel Groupings', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post( $this->rule_count_status( $settings['channel_groups'] ) );
				?></summary>
				<p class="description"><?php
					echo wp_kses(
						__( 'Collect the channels above into bigger buckets, so a report can answer &#8220;how much came from paid?&#8221; at a glance.', 'bw-lead-ai' ),
						array( 'strong' => array() )
					);
					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( '<code>*</code> matches any channel, and a final <code>{bw:channel} : *</code> row leaves every ungrouped channel on its own. Names must match your <strong>Channel Mappings</strong> labels exactly.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array() )
					);
					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( 'A <strong>reporting</strong> setting only &mdash; nothing is written to journeys, and changes need no reprocessing.', 'bw-lead-ai' ),
						array( 'strong' => array() )
					);
					?></p>
				<?php
				$this->render_pair_rows(
					'channel_groups',
					$settings['channel_groups'],
					array(
						'key_head'             => __( 'Group', 'bw-lead-ai' ),
						'val_head'             => __( 'Channels in this group', 'bw-lead-ai' ),
						'key_placeholder'      => 'Paid',
						'val_placeholder'      => 'Google Ads, Bing Ads',
						'add_label'            => __( 'Add group', 'bw-lead-ai' ),
						'ordered'              => true,
						'textarea_rows'        => 6,
						'textarea_placeholder' => "Paid : Google Ads, Bing Ads\n{bw:channel} : *",
					)
				);
				?>
			</details>

			<?php submit_button(); ?>
		</form>
		<?php
	}

	private function render_events_tab() {
		$settings = BW_Lead_AI_Settings::get();
		$opt      = esc_attr( BW_LEAD_AI_OPTION );
		$active   = BW_Lead_AI_Settings::parse_event_types( $settings['event_types'] );

		$descriptions = array(
			'video'      => __( 'Plays and progress milestones for HTML5 &lt;video&gt;, YouTube and Vimeo embeds.', 'bw-lead-ai' ),
			'download'   => __( 'Clicks on links to files with one of the extensions below — on your site or off it.', 'bw-lead-ai' ),
			'phone'      => __( 'Clicks on <code>tel:</code> links.', 'bw-lead-ai' ),
			'email'      => __( 'Clicks on <code>mailto:</code> links.', 'bw-lead-ai' ),
			'social'     => __( 'Clicks on links to the social hostnames listed below.', 'bw-lead-ai' ),
			'outbound'   => __( 'Clicks on links to any other external site (anything not social and not a download).', 'bw-lead-ai' ),
			'custom'     => __( 'Clicks on elements you name yourself by CSS selector, below.', 'bw-lead-ai' ),
			'scroll'     => __( 'How far down the page the visitor read. Fires once per milestone per page.', 'bw-lead-ai' ),
			'form_start' => __( 'The first time a visitor interacts with any form field — useful for spotting abandoned forms.', 'bw-lead-ai' ),
		);
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>
			<?php
			$this->render_passthrough(
				$settings,
				array( 'event_types', 'event_downloads', 'event_social_hosts', 'event_video_milestones', 'event_scroll_thresholds', 'event_custom_selectors' )
			);
			?>
			<input type="hidden" name="<?php echo $opt; ?>[event_types_present]" value="1">

			<h2><?php esc_html_e( 'Interaction tracking', 'bw-lead-ai' ); ?></h2>
			<div class="description bw-copy">
				<?php esc_html_e( 'Record what visitors did on the way to converting — videos watched, files downloaded, numbers called.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'Everything here is off by default — with nothing ticked, no extra JavaScript loads. Enabled interactions appear in <code>{bw:summary}</code> and as the merge tags <code>{bw:events}</code>, <code>{bw:events_list}</code> and <code>{bw:event.type}</code>.', 'bw-lead-ai' )
					)
				);
				?>
			</div>

			<table class="widefat striped bw-toggles">
				<thead><tr>
					<th><?php esc_html_e( 'Interaction', 'bw-lead-ai' ); ?></th>
					<th class="bw-toggle-cell"><?php esc_html_e( 'Track', 'bw-lead-ai' ); ?></th>
				</tr></thead>
				<tbody>
				<?php foreach ( BW_Lead_AI_Settings::EVENT_TYPES as $type ) : ?>
					<tr>
						<td>
							<label class="bw-toggle-label" for="bw-lead-ai-ev-<?php echo esc_attr( $type ); ?>">
								<span class="bw-toggle-name"><?php echo esc_html( BW_Lead_AI_GF_Field::event_type_label( $type ) ); ?></span>
							</label>
							<span class="bw-toggle-hint"><?php
								echo wp_kses(
									isset( $descriptions[ $type ] ) ? $descriptions[ $type ] : '',
									array( 'code' => array() )
								);
								?></span>
						</td>
						<td class="bw-toggle-cell">
							<input type="checkbox" id="bw-lead-ai-ev-<?php echo esc_attr( $type ); ?>" name="<?php echo $opt; ?>[event_types_checked][<?php echo esc_attr( $type ); ?>]" value="1" <?php checked( in_array( $type, $active, true ) ); ?>>
						</td>
					</tr>
				<?php endforeach; ?>
			</table>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Interaction options', 'bw-lead-ai' ); ?></h2><?php
					/* translators: %d: number of interaction types being tracked. */
					echo wp_kses_post( $this->section_status( sprintf( _n( '%d tracked', '%d tracked', count( $active ), 'bw-lead-ai' ), count( $active ) ), count( $active ) ? null : false ) );
				?></summary>
				<p class="description">
					<?php esc_html_e( 'These only apply to the interaction types you have ticked above.', 'bw-lead-ai' ); ?>
				</p>
				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><label for="bw-lead-ai-ev-downloads"><?php esc_html_e( 'Download extensions', 'bw-lead-ai' ); ?></label></th>
						<td>
							<input type="text" id="bw-lead-ai-ev-downloads" name="<?php echo $opt; ?>[event_downloads]" value="<?php echo esc_attr( $settings['event_downloads'] ); ?>" class="large-text" placeholder="pdf, doc, docx, zip">
							<?php echo wp_kses_post( $this->help( __( 'Comma-separated, no dots. A link ending in one of these counts as a download.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
					<tr>
						<th scope="row"><label for="bw-lead-ai-ev-social"><?php esc_html_e( 'Social hostnames', 'bw-lead-ai' ); ?></label></th>
						<td>
							<input type="text" id="bw-lead-ai-ev-social" name="<?php echo $opt; ?>[event_social_hosts]" value="<?php echo esc_attr( $settings['event_social_hosts'] ); ?>" class="large-text" placeholder="facebook.com, instagram.com">
							<p class="description"><?php
								echo wp_kses(
									__( 'Links to these hosts are recorded as <strong>social</strong> instead of <strong>outbound</strong>. Subdomains match automatically.', 'bw-lead-ai' ),
									array( 'strong' => array() )
								);
								?></p>
						</td>
					</tr>
					<?php
					$this->render_milestone_row(
						'video_marks',
						'event_video_milestones',
						__( 'How much was watched', 'bw-lead-ai' ),
						__( 'Also record how much of each video was watched', 'bw-lead-ai' ),
						__( 'Off, a video reports one interaction: they played it. On, it also reports how far they got — percentages, 1–100, each firing once per video per page. Four thresholds turn one watch into five records, so leave this off unless how far they watched is a question you actually ask.', 'bw-lead-ai' ),
						$settings
					);
					$this->render_milestone_row(
						'scroll_marks',
						'event_scroll_thresholds',
						__( 'How far they scrolled', 'bw-lead-ai' ),
						__( 'Record how far down each page they read', 'bw-lead-ai' ),
						__( 'Percentages, 1–100. Scroll depth is the noisiest interaction type there is — four thresholds on a four-page visit is sixteen records, which crowds earlier interactions out of the capped history. Leave it off on content-heavy sites.', 'bw-lead-ai' ),
						$settings
					);
					?>
				</table>

				<p class="description" style="margin-top: 16px;"><?php
					echo wp_kses(
						__( '<strong>Custom actions.</strong> One per line, format <code>Label : selector1, selector2</code>. Any click on a matching element (or inside one) is recorded under your label.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array() )
					);
					?></p>
				<p class="description"><?php
					echo wp_kses(
						__( 'Example: <code>Pricing CTA : .pricing-cta, #get-quote</code>. A custom match <em>wins</em> — if the element is also an outbound link or a download, only the custom action is recorded, so nothing is double-counted.', 'bw-lead-ai' ),
						array( 'code' => array(), 'em' => array() )
					);
					?></p>
				<?php
				$this->render_pair_rows(
					'event_custom_selectors',
					$settings['event_custom_selectors'],
					array(
						'key_head'             => __( 'Action name', 'bw-lead-ai' ),
						'val_head'             => __( 'CSS selectors', 'bw-lead-ai' ),
						'key_placeholder'      => 'Pricing CTA',
						'val_placeholder'      => '.pricing-cta, #get-quote',
						'add_label'            => __( 'Add action', 'bw-lead-ai' ),
						// Each selector list is matched independently; nothing walks
						// these top to bottom, so order carries no meaning.
						'ordered'              => false,
						'textarea_rows'        => 6,
						'textarea_placeholder' => "Pricing CTA : .pricing-cta, #get-quote\nBook a demo : a.demo-button",
					)
				);
				?>
			</details>

			<?php submit_button(); ?>
		</form>
		<?php
	}

	private function render_handoff_tab() {
		// The wizard takes the whole tab over when asked for. It is a mode of this
		// page rather than a menu entry of its own — a hidden page under a
		// top-level menu has no $submenu entry for admin.php to authorise against,
		// which is exactly how the journey report became unreachable.
		$wizard_step = BW_Lead_AI_Handoff_Wizard::requested_step();
		if ( $wizard_step ) {
			BW_Lead_AI_Handoff_Wizard::instance()->render( $wizard_step );
			return;
		}

		$settings   = BW_Lead_AI_Settings::get();
		$opt        = esc_attr( BW_LEAD_AI_OPTION );
		$selected   = BW_Lead_AI_Settings::parse_handoff_datapoints( $settings );
		$shared     = BW_Lead_AI_Settings::parse_handoff_share_datapoints( $settings );
		$available  = BW_Lead_AI_Settings::handoff_available_datapoints();
		$sensitive  = BW_Lead_AI_Settings::handoff_sensitive_datapoints();
		$retention  = (string) $settings['handoff_retention'];
		$presets    = array( '90', '365', '730', '1825', 'unlimited' );
		$is_preset  = in_array( $retention, $presets, true );
		$live       = BW_Lead_AI_Settings::handoff_enabled( $settings );
		$stats      = BW_Lead_AI_Handoff_Store::stats();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only confirmation after a nonce-checked redirect.
		$purged = self::notice_count( 'bw_purged' );
		if ( null !== $purged ) {
			echo '<div class="notice notice-success is-dismissible"><p>' . sprintf(
				/* translators: %d is a number of deleted records. */
				esc_html__( 'Deleted %d handoff record(s).', 'bw-lead-ai' ),
				$purged
			) . '</p></div>';
		}
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>
			<?php
			$this->render_passthrough(
				$settings,
				array(
					'handoff_enabled', 'handoff_mode_data', 'handoff_mode_link', 'handoff_ack',
					'handoff_domains', 'handoff_origins', 'handoff_self_origins',
					'handoff_param', 'handoff_ttl',
				)
			);
			?>

			<h2><?php esc_html_e( 'Form Submissions on External Sites', 'bw-lead-ai' ); ?></h2>
			<div class="description bw-copy">
				<?php esc_html_e( 'Have a form on a different domain? Install code on the external site to capture the lead journey information.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'After installing the code on the external site, we can pass chosen data into hidden tracking fields so that they get submitted with the form. We will also be able to track form submissions as part of the user journey.', 'bw-lead-ai' )
					)
				);
				?>
			</div>

			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><?php esc_html_e( 'Enable', 'bw-lead-ai' ); ?></th>
					<td>
						<label><input type="checkbox" id="bw-lead-ai-handoff-enabled" name="<?php echo $opt; ?>[handoff_enabled]" value="1" <?php checked( 1, (int) $settings['handoff_enabled'] ); ?>> <?php esc_html_e( 'Turn on cross-domain handoff', 'bw-lead-ai' ); ?></label>
						<?php echo wp_kses_post( $this->help( __( 'While off, nothing runs and nothing is stored. Handoff also stays off until a destination domain and a mode are set.', 'bw-lead-ai' ) ) ); ?>
						<?php if ( ! empty( $settings['handoff_enabled'] ) && ! $live ) : ?>
							<p class="description bw-warn"><strong><?php esc_html_e( 'Not active yet — add a destination domain and choose a mode below.', 'bw-lead-ai' ); ?></strong></p>
						<?php endif; ?>
					</td>
				</tr>
			</table>

			<?php // Everything else on this feature is inert until the box above is
			// ticked, so it is wrapped rather than listed. The class is server-rendered
			// from the saved value — no flash of settings on a site that has this off,
			// and no dependency on JavaScript to reach a correct page — and
			// admin-gate.js only swaps it live afterwards.
			//
			// Hidden with CSS, never omitted: every settings tab posts every field it
			// carries, and the sanitizer falls back to the DEFAULT for any key absent
			// from the POST. A field that stopped rendering here would therefore reset
			// itself the next time this tab was saved (tests/tab-save-roundtrip.php).
			// A display:none input is still a successful control, so it still submits. ?>
			<?php $gate_class = (int) $settings['handoff_enabled'] ? 'bw-gated' : 'bw-gated is-off'; ?>
			<div class="<?php echo esc_attr( $gate_class ); ?>" data-bw-gated-by="bw-lead-ai-handoff-enabled">

			<div class="bw-cta">
				<div class="bw-cta-body">
					<strong><?php echo $live ? esc_html__( 'Set up another destination', 'bw-lead-ai' ) : esc_html__( 'Not sure where to start?', 'bw-lead-ai' ); ?></strong>
					<span><?php esc_html_e( 'A few plain questions, and we configure everything and write the code for the other site.', 'bw-lead-ai' ); ?></span>
				</div>
				<a class="button button-primary" href="<?php echo esc_url( BW_Lead_AI_Handoff_Wizard::url( 1 ) ); ?>">
					<?php echo $live ? esc_html__( 'Run setup', 'bw-lead-ai' ) : esc_html__( 'Start setup', 'bw-lead-ai' ); ?>
				</a>
			</div>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Modes', 'bw-lead-ai' ); ?></h2><?php
					$mode_bits = array();
					if ( (int) $settings['handoff_mode_link'] ) { $mode_bits[] = __( 'Journey link', 'bw-lead-ai' ); }
					if ( (int) $settings['handoff_mode_data'] && (int) $settings['handoff_ack'] ) { $mode_bits[] = __( 'Data handoff', 'bw-lead-ai' ); }
					echo wp_kses_post( $this->section_status( $mode_bits ? implode( ' + ', $mode_bits ) : __( 'none selected', 'bw-lead-ai' ), (bool) $mode_bits ) );
				?></summary>
				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><?php esc_html_e( 'Journey link', 'bw-lead-ai' ); ?> <span class="bw-pill"><?php esc_html_e( 'Recommended', 'bw-lead-ai' ); ?></span></th>
						<td>
							<label><input type="checkbox" name="<?php echo $opt; ?>[handoff_mode_link]" value="1" <?php checked( 1, (int) $settings['handoff_mode_link'] ); ?>> <?php esc_html_e( 'Let the destination store a link back to this site', 'bw-lead-ai' ); ?></label>
							<?php echo wp_kses_post( $this->help( __( 'Only the token crosses domains. Your team follows the link and reads the journey here — no visitor data leaves this server.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Data handoff', 'bw-lead-ai' ); ?></th>
						<td>
							<label><input type="checkbox" name="<?php echo $opt; ?>[handoff_mode_data]" value="1" <?php checked( 1, (int) $settings['handoff_mode_data'] ); ?>> <?php esc_html_e( 'Let the destination read the datapoints and fill its own fields', 'bw-lead-ai' ); ?></label>
							<?php echo wp_kses_post( $this->help( __( 'The destination swaps the token for the datapoints you tick below and fills its own form fields.', 'bw-lead-ai' ) ) ); ?>
							<div class="notice notice-warning inline" style="margin:12px 0;max-width:760px;">
								<p><strong><?php esc_html_e( 'This sends visitor data to a third-party origin.', 'bw-lead-ai' ); ?></strong>
								<?php esc_html_e( 'This may belong in your privacy policy. Keep the datapoints to the minimum the destination needs.', 'bw-lead-ai' ); ?></p>
								<p><label><input type="checkbox" name="<?php echo $opt; ?>[handoff_ack]" value="1" <?php checked( 1, (int) $settings['handoff_ack'] ); ?>> <strong><?php esc_html_e( 'I understand and want to enable this', 'bw-lead-ai' ); ?></strong></label></p>
								<p class="description"><?php esc_html_e( 'Data handoff stays off until this is ticked.', 'bw-lead-ai' ); ?></p>
							</div>
						</td>
					</tr>
				</table>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Destinations', 'bw-lead-ai' ); ?></h2><?php
					$origins = BW_Lead_AI_Settings::parse_handoff_origins( $settings );
					echo wp_kses_post(
						$this->section_status(
							$origins
								/* translators: %d: number of destination domains. */
								? sprintf( _n( '%d domain', '%d domains', count( $origins ), 'bw-lead-ai' ), count( $origins ) )
								: __( 'none set', 'bw-lead-ai' ),
							(bool) $origins
						)
					);
				?></summary>
				<p class="description"><?php
					echo wp_kses(
						__( 'Hostnames whose links get a token. One per line. Subdomains match automatically, so <code>example.com</code> covers <code>portal.example.com</code>.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
				<textarea name="<?php echo $opt; ?>[handoff_domains]" rows="4" class="large-text code" placeholder="portal.example.com"><?php echo esc_textarea( str_replace( ', ', "\n", (string) $settings['handoff_domains'] ) ); ?></textarea>

				<p class="description" style="margin-top:16px;"><?php
					echo wp_kses(
						__( '<strong>Allowed origins</strong> — which sites may read the token. Leave empty (usually right) to allow <code>https://</code> and each destination. Set it if the destination uses a different scheme or port.', 'bw-lead-ai' ),
						array( 'code' => array(), 'strong' => array() )
					);
					?></p>
				<textarea name="<?php echo $opt; ?>[handoff_origins]" rows="3" class="large-text code" placeholder="https://portal.example.com"><?php echo esc_textarea( str_replace( ', ', "\n", (string) $settings['handoff_origins'] ) ); ?></textarea>
				<?php if ( $live ) : ?>
					<p class="description"><?php esc_html_e( 'Currently allowing:', 'bw-lead-ai' ); ?> <code><?php echo esc_html( implode( ', ', BW_Lead_AI_Settings::parse_handoff_origins( $settings ) ) ); ?></code></p>
				<?php endif; ?>
			</details>

			<?php $snippet_origins = BW_Lead_AI_Handoff_Wizard::snippet_origins( $settings ); ?>
			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'This site\'s addresses', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post(
						$this->section_status(
							/* translators: %d: number of addresses this site answers on. */
							sprintf( _n( '%d address', '%d addresses', count( $snippet_origins ), 'bw-lead-ai' ), count( $snippet_origins ) )
						)
					);
				?></summary>
				<div class="description bw-copy">
					<?php esc_html_e( 'Any other address this same site answers on — a live hostname while you set up on staging, or the reverse. One per line.', 'bw-lead-ai' ); ?>
					<?php
					echo wp_kses_post(
						$this->help(
							__( 'The code below calls whichever of these the visitor arrived from, so one copy of it keeps working after a launch. An address that is not listed is never called.', 'bw-lead-ai' )
						)
					);
					?>
				</div>
				<textarea name="<?php echo $opt; ?>[handoff_self_origins]" rows="3" class="large-text code" placeholder="https://www.example.com"><?php echo esc_textarea( str_replace( ', ', "\n", (string) $settings['handoff_self_origins'] ) ); ?></textarea>
				<p class="description"><?php esc_html_e( 'The code calls:', 'bw-lead-ai' ); ?> <code><?php echo esc_html( implode( ', ', $snippet_origins ) ); ?></code></p>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'The token', 'bw-lead-ai' ); ?></h2><?php
					/* translators: %d: token lifetime in minutes. */
					echo wp_kses_post( $this->section_status( sprintf( __( '?%1$s · %2$d min', 'bw-lead-ai' ), $settings['handoff_param'], (int) $settings['handoff_ttl'] ) ) );
				?></summary>
				<p class="description bw-copy"><?php esc_html_e( 'Defaults are fine unless the destination needs something specific.', 'bw-lead-ai' ); ?></p>
				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><label for="bw-lead-ai-handoff-param"><?php esc_html_e( 'Link parameter', 'bw-lead-ai' ); ?></label></th>
						<td>
							<input type="text" id="bw-lead-ai-handoff-param" name="<?php echo $opt; ?>[handoff_param]" value="<?php echo esc_attr( $settings['handoff_param'] ); ?>" class="regular-text">
							<?php echo wp_kses_post( $this->help( __( 'The query parameter carrying the token. The destination reads this name.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
					<tr>
						<th scope="row"><label for="bw-lead-ai-handoff-ttl"><?php esc_html_e( 'Token lifetime', 'bw-lead-ai' ); ?></label></th>
						<td>
							<input type="number" id="bw-lead-ai-handoff-ttl" name="<?php echo $opt; ?>[handoff_ttl]" value="<?php echo esc_attr( (int) $settings['handoff_ttl'] ); ?>" min="5" max="240" class="small-text"> <?php esc_html_e( 'minutes', 'bw-lead-ai' ); ?>
							<?php echo wp_kses_post( $this->help( __( 'How long a held record lives — it only has to survive the click-through. Held records are deleted automatically.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
				</table>
			</details>

			<?php // The code for the other site sits inside the gate with the settings
			// it is generated from: it says nothing a site with this feature off can
			// act on, and it is the last step of the same job. ?>
			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Setting up the destination', 'bw-lead-ai' ); ?></h2></summary>
				<p class="description"><?php esc_html_e( 'Goes on the destination site, before its closing body tag or as a Custom HTML tag firing on all pages. Remove any older Lead AI tag there first — the old one claims the details and leaves this with nothing.', 'bw-lead-ai' ); ?></p>
				<?php $snippet_state = BW_Lead_AI_Handoff_Wizard::state_from_settings( $settings ); ?>
				<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="14" class="bw-lead-ai-pre" data-wizard-snippet><?php
						echo esc_textarea( BW_Lead_AI_Handoff_Wizard::build_snippet( $snippet_state ) );
					?></textarea>
				</div>
				<?php if ( empty( $snippet_state['fields'] ) && BW_Lead_AI_Settings::handoff_mode_data( $settings ) ) : ?>
					<p class="description bw-warn">
						<?php esc_html_e( 'Data handoff is on but no form fields are mapped, so the code above fills nothing.', 'bw-lead-ai' ); ?>
						<a href="<?php echo esc_url( BW_Lead_AI_Handoff_Wizard::url( 1 ) ); ?>"><?php esc_html_e( 'Run setup to map them.', 'bw-lead-ai' ); ?></a>
					</p>
				<?php endif; ?>

				<h3 class="bw-subhead"><?php esc_html_e( 'The raw endpoints', 'bw-lead-ai' ); ?></h3>
				<p class="description"><?php esc_html_e( 'Only needed if the destination is doing something the generated code does not cover.', 'bw-lead-ai' ); ?></p>
				<table class="form-table" role="presentation">
					<tr><th scope="row"><?php esc_html_e( 'Parameter to read', 'bw-lead-ai' ); ?></th>
						<td><code><?php echo esc_html( $settings['handoff_param'] ); ?></code></td></tr>
					<?php if ( BW_Lead_AI_Settings::handoff_mode_data( $settings ) ) : ?>
						<tr><th scope="row"><?php esc_html_e( 'Claim the data', 'bw-lead-ai' ); ?></th>
							<td><code>GET <?php echo esc_html( rest_url( BW_Lead_AI_Handoff_REST::NAMESPACE_V1 . '/handoff/' ) ); ?>&lt;token&gt;</code>
							<p class="description"><?php esc_html_e( 'Works once per token. Retry twice on a 404 to cover the race between the click and the page loading.', 'bw-lead-ai' ); ?></p></td></tr>
					<?php endif; ?>
					<?php if ( BW_Lead_AI_Settings::handoff_mode_link( $settings ) ) : ?>
						<tr><th scope="row"><?php esc_html_e( 'Journey link to store', 'bw-lead-ai' ); ?></th>
							<td><code><?php echo esc_html( BW_Lead_AI_Handoff_Admin::viewer_url() ); ?>&amp;token=&lt;token&gt;</code></td></tr>
						<tr><th scope="row"><?php esc_html_e( 'Confirm on submit', 'bw-lead-ai' ); ?></th>
							<td><code>POST <?php echo esc_html( rest_url( BW_Lead_AI_Handoff_REST::NAMESPACE_V1 . '/handoff/' ) ); ?>&lt;token&gt;/confirm</code>
							<p class="description"><?php esc_html_e( 'Fire this from the form-submit trigger, or the journey is discarded when the token expires. What gets saved is decided on the Journey Storage tab.', 'bw-lead-ai' ); ?></p></td></tr>
					<?php endif; ?>
				</table>
			</details>

			</div><?php // end .bw-gated ?>

			<?php submit_button(); ?>
		</form>

			<h2><?php esc_html_e( 'Cross-domain Source Passthrough from External Landing Pages', 'bw-lead-ai' ); ?></h2>
			<div class="description bw-copy">
				<?php esc_html_e( 'Do users sometimes land on another site you own before clicking through to this site? Install code that will track the original source of that user.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'Without this code, the user will appear as a referral from your other property. With this code, the user\'s source will be the original source, and the visit to the other property will appear as a pageview before landing on this website.', 'bw-lead-ai' )
					)
				);
				?>
			</div>

			<?php // A separate feature, deliberately outside the gate above and outside
			// the form: a site with one landing-page subdomain and no cross-domain form
			// at all still loses the source across it. ?>
			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'The code', 'bw-lead-ai' ); ?></h2></summary>
				<div class="description bw-copy">
					<?php esc_html_e( 'No visitor data is sent — only the source, on your own links.', 'bw-lead-ai' ); ?>
					<?php
					echo wp_kses_post(
						$this->help(
							__( 'Marking a property internal (<strong>Medium Mappings</strong>) stops it counting as a channel, but loses its real source. This code is what recovers it.', 'bw-lead-ai' )
						)
					);
					?>
				</div>
				<div class="bw-wizard-code">
					<div class="bw-wizard-code-bar">
						<span><?php esc_html_e( 'Paste this on the property you own', 'bw-lead-ai' ); ?></span>
						<button type="button" class="button button-small bw-lead-ai-copy" data-target="data-owned-snippet">
							<?php esc_html_e( 'Copy', 'bw-lead-ai' ); ?>
						</button>
					</div>
					<textarea readonly rows="14" class="bw-lead-ai-pre" data-owned-snippet><?php
						echo esc_textarea( BW_Lead_AI_Handoff_Wizard::build_shared_property_snippet( $settings ) );
					?></textarea>
				</div>
				<p class="description bw-copy">
					<?php esc_html_e( 'It goes before the closing body tag, or as a Custom HTML tag set to fire on all pages. Safe to run alongside the destination code above.', 'bw-lead-ai' ); ?>
					<?php
					echo wp_kses_post(
						$this->help(
							__( 'To check it: open the property with <code>?bwlaidebug=true&amp;utm_source=test&amp;utm_medium=check</code>, then hover a link back here &mdash; it should carry those parameters. A search arrival adds <code>bwlai_ref</code> instead.', 'bw-lead-ai' )
						)
					);
					?>
				</p>
			</details>
		<?php
	}

	/**
	 * Journeys: what a stored journey holds, who it is attached to, and how long
	 * it lives.
	 *
	 * Split out of the old single "Handoff" tab. None of this depends on
	 * cross-domain handoff — a site that never hands anything to another domain
	 * still stores journeys, still decides whether to keep a name against one, and
	 * still needs a retention answer. Filing those under "Handoff" hid general
	 * settings behind a feature most sites never switch on.
	 */
	private function render_journeys_tab() {
		$settings  = BW_Lead_AI_Settings::get();
		$opt       = esc_attr( BW_LEAD_AI_OPTION );
		$selected  = BW_Lead_AI_Settings::parse_handoff_datapoints( $settings );
		$shared    = BW_Lead_AI_Settings::parse_handoff_share_datapoints( $settings );
		$available = BW_Lead_AI_Settings::handoff_available_datapoints();
		$sensitive = BW_Lead_AI_Settings::handoff_sensitive_datapoints();
		$retention = (string) $settings['handoff_retention'];
		$presets   = array( '90', '365', '730', '1825', 'unlimited' );
		$is_preset = in_array( $retention, $presets, true );
		$stats     = BW_Lead_AI_Handoff_Store::stats();

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only confirmation after a nonce-checked redirect.
		$purged = self::notice_count( 'bw_purged' );
		if ( null !== $purged ) {
			echo '<div class="notice notice-success is-dismissible"><p>' . sprintf(
				/* translators: %d is a number of deleted records. */
				esc_html__( 'Deleted %d stored journey(s).', 'bw-lead-ai' ),
				$purged
			) . '</p></div>';
		}

		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- display-only confirmation after a nonce-checked redirect.
		$reprocessed = self::notice_count( 'bw_reprocessed' );
		$examined    = self::notice_count( 'bw_examined', 0 );
		// phpcs:enable
		if ( null !== $reprocessed ) {
			// Both numbers, always. "Updated 0" on its own reads as a failure; "checked
			// 435, updated 0" reads as the answer it is — your history already agrees
			// with your settings.
			echo '<div class="notice notice-success is-dismissible"><p>' . esc_html(
				sprintf(
					/* translators: 1: journeys examined, 2: journeys updated. */
					__( 'Reprocessed: %1$d stored journeys checked, %2$d updated.', 'bw-lead-ai' ),
					$examined,
					$reprocessed
				)
			) . '</p></div>';
		}
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>
			<?php
			$this->render_passthrough(
				$settings,
				array(
					'capture_identity', 'capture_submission', 'journey_save_mode', 'journey_update_mode',
					'handoff_retention', 'handoff_datapoints', 'handoff_share_datapoints',
				)
			);
			?>

			<h2><?php esc_html_e( 'Visitor journeys', 'bw-lead-ai' ); ?></h2>
			<div class="description bw-copy">
				<?php esc_html_e( 'A journey is the record of how one visitor found you and what they did before enquiring.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'These settings apply to every journey this site stores, whether or not the form lives on another domain. Cross-domain handoff has its own tab.', 'bw-lead-ai' )
					)
				);
				?>
			</div>

			<?php $this->render_journey_policy_section( $settings ); ?>

<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Who the visitor is', 'bw-lead-ai' ); ?></h2><?php
					$id_bits = array();
					if ( (int) $settings['capture_identity'] )   { $id_bits[] = __( 'name and email', 'bw-lead-ai' ); }
					if ( (int) $settings['capture_submission'] ) { $id_bits[] = __( 'full submission', 'bw-lead-ai' ); }
					echo wp_kses_post( $this->section_status( $id_bits ? implode( ' + ', $id_bits ) : __( 'anonymous', 'bw-lead-ai' ), (bool) $id_bits ) );
				?></summary>
				<p class="description bw-copy"><?php esc_html_e( 'A journey on its own is anonymous. These options attach the person to it, so the report opens with a name rather than just a channel.', 'bw-lead-ai' ); ?></p>
				<div class="notice notice-warning inline" style="margin:12px 0;max-width:820px;">
					<p><strong><?php esc_html_e( 'These are the only settings in this plugin that store personal data.', 'bw-lead-ai' ); ?></strong>
					<?php esc_html_e( 'Everything else is anonymous traffic data. Turning these on means names and email addresses are kept alongside a record of someone\'s browsing — which is a decision worth making deliberately, and one that may belong in your privacy policy. Check your retention setting below at the same time.', 'bw-lead-ai' ); ?></p>
				</div>
				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><?php esc_html_e( 'Name and email', 'bw-lead-ai' ); ?></th>
						<td>
							<label><input type="checkbox" name="<?php echo $opt; ?>[capture_identity]" value="1" <?php checked( 1, (int) $settings['capture_identity'] ); ?>> <?php esc_html_e( 'Record who submitted the form', 'bw-lead-ai' ); ?></label>
							<?php echo wp_kses_post( $this->help( __( 'Read from the submission on the server — never stored in the visitor&#8217;s browser. Works for Gravity Forms on this site, and for a destination that includes the details in its confirmation ping.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Full submission', 'bw-lead-ai' ); ?></th>
						<td>
							<label><input type="checkbox" name="<?php echo $opt; ?>[capture_submission]" value="1" <?php checked( 1, (int) $settings['capture_submission'] ); ?>> <?php esc_html_e( 'Keep a copy of what they filled in', 'bw-lead-ai' ); ?></label>
							<?php echo wp_kses_post( $this->help( __( 'Shown in a collapsed section of the report, for a glance at what they actually said. Gravity Forms on this site only — we never see a form hosted elsewhere.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
				</table>
				<p class="description" style="max-width:820px;"><?php
					echo wp_kses(
						__( 'Neither is ever sent to the destination. Both live outside the datapoint system entirely, so there is no setting that could share them by mistake — filter <code>bw_lead_ai_capture_submission</code> to drop a sensitive question before it is stored.', 'bw-lead-ai' ),
						array( 'code' => array() )
					);
					?></p>
			</details>

			<input type="hidden" name="<?php echo $opt; ?>[handoff_datapoints_present]" value="1">

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Datapoints', 'bw-lead-ai' ); ?></h2><?php
					$n_store = count( BW_Lead_AI_Settings::parse_handoff_datapoints() );
					$n_send  = count( BW_Lead_AI_Settings::parse_handoff_share_datapoints() );
					echo wp_kses_post(
						$this->section_status(
							/* translators: 1: number stored, 2: number sent to the destination. */
							sprintf( __( '%1$d stored · %2$d sent', 'bw-lead-ai' ), $n_store, $n_send )
						)
					);
				?></summary>
				<p class="description bw-copy"><?php
					echo wp_kses(
						__( 'Two separate decisions. <strong>Store</strong> is what a journey record holds and what the journey report can show you — it never leaves this server. <strong>Send</strong> is the smaller subset the third-party destination is allowed to claim.', 'bw-lead-ai' ),
						array( 'strong' => array() )
					);
					?></p>
				<div class="description bw-copy"><?php esc_html_e( 'A datapoint can only be sent if it is also stored.', 'bw-lead-ai' ); ?>
					<?php echo wp_kses_post( $this->help( __( 'You can store a visitor&#8217;s full browsing history for your own reporting without handing any of it to the destination. Ticking Send on a marked row means that history leaves your site &mdash; storing it does not.', 'bw-lead-ai' ) ) ); ?></div>

				<table class="widefat striped" style="max-width:760px;margin-top:12px;">
					<thead><tr>
						<th><?php esc_html_e( 'Datapoint', 'bw-lead-ai' ); ?></th>
						<th class="bw-toggle-cell"><?php esc_html_e( 'Store', 'bw-lead-ai' ); ?></th>
						<th class="bw-toggle-cell"><?php esc_html_e( 'Send', 'bw-lead-ai' ); ?></th>
					</tr></thead>
					<tbody>
					<?php foreach ( $available as $point ) : ?>
						<?php $is_sensitive = in_array( $point, $sensitive, true ); ?>
						<tr>
							<td>
								<?php echo esc_html( BW_Lead_AI_Settings::handoff_datapoint_label( $point ) ); ?>
								<?php if ( $is_sensitive ) : ?>
									<br><span class="bw-flag"><?php esc_html_e( 'includes the pages they viewed', 'bw-lead-ai' ); ?></span>
								<?php endif; ?>
							</td>
							<td style="text-align:center;">
								<input type="checkbox" name="<?php echo $opt; ?>[handoff_datapoints_checked][<?php echo esc_attr( $point ); ?>]" value="1" <?php checked( in_array( $point, $selected, true ) ); ?>>
							</td>
							<td style="text-align:center;">
								<input type="checkbox" name="<?php echo $opt; ?>[handoff_share_checked][<?php echo esc_attr( $point ); ?>]" value="1" <?php checked( in_array( $point, $shared, true ) ); ?>>
							</td>
						</tr>
					<?php endforeach; ?>
					</tbody>
				</table>
			</details>

			<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'How long journeys live', 'bw-lead-ai' ); ?></h2><?php
					echo wp_kses_post(
						$this->section_status(
							'unlimited' === (string) $retention
								? __( 'kept indefinitely', 'bw-lead-ai' )
								/* translators: %d: retention period in days. */
								: sprintf( _n( 'kept %d day', 'kept %d days', (int) $retention, 'bw-lead-ai' ), (int) $retention )
						)
					);
				?></summary>
				<?php // "Keep growing" used to be a checkbox here; it is now the
				// "Every time they come back" end of the update choice above. ?>
				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><label for="bw-lead-ai-handoff-retention"><?php esc_html_e( 'Keep saved journeys', 'bw-lead-ai' ); ?></label></th>
						<td>
							<select id="bw-lead-ai-handoff-retention" name="<?php echo $opt; ?>[handoff_retention]">
								<?php
								$labels = 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' => __( 'Keep indefinitely', 'bw-lead-ai' ),
								);
								foreach ( $labels as $value => $label ) {
									printf(
										'<option value="%1$s"%2$s>%3$s</option>',
										esc_attr( $value ),
										selected( $retention, $value, false ),
										esc_html( $label )
									);
								}
								?>
							</select>
							<label style="margin-left:12px;"><?php esc_html_e( 'or custom:', 'bw-lead-ai' ); ?>
								<input type="number" name="<?php echo $opt; ?>[handoff_retention_custom]" value="<?php echo $is_preset ? '' : esc_attr( $retention ); ?>" min="1" class="small-text" placeholder="<?php esc_attr_e( 'days', 'bw-lead-ai' ); ?>">
							</label>
							<?php echo wp_kses_post( $this->help( __( 'Counted from the last time you heard from them, not from when they converted — so an active lead is not deleted mid-relationship. Keeping a browsing history indefinitely is a data-retention decision; set a finite value if you have a policy.', 'bw-lead-ai' ) ) ); ?>
						</td>
					</tr>
								</table>
			</details>

			<?php submit_button(); ?>
		</form>

		<?php // Outside the settings form — an action, not a field. ?>
		<p class="bw-journey-divider"><?php esc_html_e( 'Stored data', 'bw-lead-ai' ); ?></p>
<details class="bw-lead-ai-section">
				<summary><h2><?php esc_html_e( 'Stored journeys', 'bw-lead-ai' ); ?></h2></summary>
				<p>
					<?php
					printf(
						/* translators: 1: held count, 2: saved count. */
						esc_html__( 'Held: %1$d — these expire on their own. Saved and kept: %2$d.', 'bw-lead-ai' ),
						(int) $stats['held'],
						(int) $stats['saved']
					);
					?>
				</p>
				<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post" onsubmit="return confirm('<?php echo esc_js( __( 'Delete every stored journey, including confirmed ones? This cannot be undone.', 'bw-lead-ai' ) ); ?>');">
					<input type="hidden" name="action" value="<?php echo esc_attr( BW_Lead_AI_Handoff_Admin::PURGE_ACTION ); ?>">
					<?php wp_nonce_field( BW_Lead_AI_Handoff_Admin::PURGE_ACTION ); ?>
					<button type="submit" class="button button-link-delete"><?php esc_html_e( 'Delete all stored journeys', 'bw-lead-ai' ); ?></button>
				</form>
			</details>

			<?php $this->render_reprocess_section(); ?>
		<?php
	}

	/**
	 * The two journey policy decisions: what event saves a journey, and what may
	 * still be added to one afterwards.
	 *
	 * Both render the RESOLVED choice — explicit if one was stored, otherwise the
	 * automatic default — and the sanitizer keeps the stored value empty for as
	 * long as what is posted matches the automatic choice, so saving this tab
	 * without touching them does not freeze the automatic behaviour. See
	 * BW_Lead_AI_Settings::sanitize().
	 *
	 * The save question is ONE CHECKBOX, not a pair of options, because the two
	 * were never alternatives: a destination's confirmation saves a journey
	 * whichever way this is set — see confirm() in BW_Lead_AI_Handoff_REST — so
	 * the only real question is whether a form submitted on THIS site saves one
	 * too. Stored values are untouched: ticked is 'submission', unticked is
	 * 'confirm'. The unticked state posts nothing, so a presence marker rides
	 * alongside it; the sanitizer reads the pair.
	 */
	private function render_journey_policy_section( $settings ) {
		$opt             = esc_attr( BW_LEAD_AI_OPTION );
		$save_mode       = BW_Lead_AI_Settings::journey_save_mode( $settings );
		$update_mode     = BW_Lead_AI_Settings::journey_update_mode( $settings );
		$has_destination = BW_Lead_AI_Settings::handoff_enabled( $settings );
		$on_submission   = ( 'submission' === $save_mode );
		$hold_minutes    = (int) $settings['handoff_ttl'];

		$update_status = array(
			'snapshot' => __( 'then frozen', 'bw-lead-ai' ),
			'visit'    => __( 'updated until the visit ends', 'bw-lead-ai' ),
			'growing'  => __( 'kept growing', 'bw-lead-ai' ),
		);
		?>
		<details class="bw-lead-ai-section">
			<summary><h2><?php esc_html_e( 'When journeys are saved and updated', 'bw-lead-ai' ); ?></h2><?php
				if ( $on_submission ) {
					$save_status = __( 'saved on submission', 'bw-lead-ai' );
				} elseif ( $has_destination ) {
					$save_status = __( 'saved on the destination\'s confirmation', 'bw-lead-ai' );
				} else {
					// Unticked with no destination to confirm anything: nothing can
					// ever fire the save. Said out loud rather than reported as a
					// working mode — this is the one combination that stores nothing.
					$save_status = __( 'nothing saves a journey', 'bw-lead-ai' );
				}
				echo wp_kses_post( $this->section_status( $save_status . ' · ' . $update_status[ $update_mode ], $on_submission || $has_destination ) );
			?></summary>
			<p class="description bw-copy"><?php esc_html_e( 'Two decisions: which event makes a journey worth keeping, and what may still be added to it afterwards.', 'bw-lead-ai' ); ?></p>

			<p class="description bw-copy"><?php
				echo wp_kses(
					__( '<strong>One person is one lead, however many times they enquire.</strong> A second enquiry from the same browser joins the journey they already have instead of starting a new one — somebody using a contact form to ask three follow-up questions has not been won four times. Both settings below apply to that one journey.', 'bw-lead-ai' ),
					array( 'strong' => array() )
				);
			?></p>
			<?php
			echo wp_kses_post(
				$this->help(
					__( 'Credit goes to the <strong>first</strong> enquiry and what led to it: how they originally found the site, and the source of the visit they first enquired on. Everything after that — more browsing, more enquiries — is recorded and shown on their journey, but takes no credit, because it did not win the lead. Without that rule, a visitor who came back through an ad and sent a second message would hand that ad a lead it played no part in.', 'bw-lead-ai' )
				)
			);
			?>

			<h3 class="bw-subhead"><?php esc_html_e( 'When is a journey saved?', 'bw-lead-ai' ); ?></h3>
			<?php $this->render_journey_save_summary( $on_submission, $has_destination, $hold_minutes ); ?>

			<input type="hidden" name="<?php echo $opt; ?>[journey_save_mode_present]" value="1">
			<p>
				<label>
					<input type="checkbox" name="<?php echo $opt; ?>[journey_save_mode]" value="submission" <?php checked( true, $on_submission ); ?>>
					<strong><?php
						echo esc_html(
							$has_destination
								? __( 'Also save when a form on this site is submitted', 'bw-lead-ai' )
								: __( 'Save when a form on this site is submitted', 'bw-lead-ai' )
						);
					?></strong>
				</label>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'A Gravity Forms submission here saves the visitor&#8217;s journey, with the form&#8217;s name on it. Off by default only when a cross-domain destination is configured, since the form lives over there. Leave it as it is and it follows that rule on its own; tick or untick it and your choice sticks.', 'bw-lead-ai' )
					)
				);
				?>
			</p>

			<h3 class="bw-subhead"><?php esc_html_e( 'After a journey is saved, when does it update?', 'bw-lead-ai' ); ?></h3>
			<fieldset>
				<p>
					<label>
						<input type="radio" name="<?php echo $opt; ?>[journey_update_mode]" value="snapshot" <?php checked( $update_mode, 'snapshot' ); ?>>
						<strong><?php esc_html_e( 'Never — keep it exactly as it was saved', 'bw-lead-ai' ); ?></strong>
					</label>
					<span class="description" style="display:block;margin:2px 0 0 24px;max-width:760px;"><?php esc_html_e( 'The journey is a snapshot of how they enquired. Nothing is added afterwards — not even the page the form sends them to. Writes nothing per pageview: the browser is not asked to report at all, so this is the cheapest option. It still records a second enquiry from the same person, because that is a different question from whether their browsing keeps being rewritten — you get one journey with both enquiries on it, and the pages between them are the part this option does not keep.', 'bw-lead-ai' ); ?></span>
				</p>
				<p>
					<label>
						<input type="radio" name="<?php echo $opt; ?>[journey_update_mode]" value="visit" <?php checked( $update_mode, 'visit' ); ?>>
						<strong><?php esc_html_e( 'Until their visit ends', 'bw-lead-ai' ); ?></strong>
					</label>
					<span class="description" style="display:block;margin:2px 0 0 24px;max-width:760px;"><?php esc_html_e( 'The pages right after the form — a thank-you page, a redirect after the thank-you — are added to the journey. It freezes once the visitor goes quiet for 30 minutes, the same boundary that ends a visit everywhere else in this plugin. Writes for the rest of that one visit only: every page after the submission reports, then nothing more, ever. A handful of small updates and done.', 'bw-lead-ai' ); ?></span>
				</p>
				<p>
					<label>
						<input type="radio" name="<?php echo $opt; ?>[journey_update_mode]" value="growing" <?php checked( $update_mode, 'growing' ); ?>>
						<strong><?php esc_html_e( 'Every time they come back', 'bw-lead-ai' ); ?></strong>
					</label>
					<span class="description" style="display:block;margin:2px 0 0 24px;max-width:760px;"><?php esc_html_e( 'The journey keeps growing as the same person returns, so you see what they did after enquiring as well as before. Writes on every visit, for as long as the journey is kept. During a visit the browser reports as they leave each page, at most once a minute, and not at all when nothing has changed — so five pages read is about five small updates, not one a second.', 'bw-lead-ai' ); ?></span>
				</p>
			</fieldset>
			<p class="description" style="max-width:760px;"><?php esc_html_e( 'All three set a long-lived first-party cookie so the site can recognise the same browser again — that is what lets a second enquiry join the journey it belongs to instead of counting as a second lead. Whether an update is accepted is enforced on the server; the browser only reports.', 'bw-lead-ai' ); ?></p>
		</details>
		<?php
	}

	/**
	 * What actually happens to a journey, in the order it happens, for the
	 * settings this site is actually on.
	 *
	 * Built from the two LIVE conditions rather than from the setting's name,
	 * because those are what decide it: a page holding a Gravity Form only holds
	 * a journey when this site saves on submission (handoff.js mints no token
	 * otherwise), and a destination link only holds one when a destination is
	 * configured. A site with no destination must not read about destinations —
	 * being told about machinery it does not have is most of what made this tab
	 * confusing.
	 *
	 * @param bool $on_submission   Does a form submitted here save a journey?
	 * @param bool $has_destination Is cross-domain handoff live?
	 * @param int  $hold_minutes    The real hold time, from handoff_ttl.
	 */
	private function render_journey_save_summary( $on_submission, $has_destination, $hold_minutes ) {
		if ( ! $on_submission && ! $has_destination ) {
			// The one combination that stores nothing. Better said plainly here
			// than discovered by an empty journeys list a month later.
			?>
			<div class="notice notice-warning inline" style="margin:0 0 12px;max-width:760px;">
				<p><?php esc_html_e( 'Nothing saves a journey on this site at the moment. Tick the box below, or set up a destination on the Cross-domain tab.', 'bw-lead-ai' ); ?></p>
			</div>
			<?php
			return;
		}

		if ( $on_submission && $has_destination ) {
			$held = sprintf(
				/* translators: %d is a number of minutes. */
				__( 'A journey is held for %d minutes when a visitor opens a page with a Gravity Form on it, or clicks a link to your destination site.', 'bw-lead-ai' ),
				$hold_minutes
			);
			$saved = __( 'It is saved when that form is submitted, or when the destination site reports its own form was submitted.', 'bw-lead-ai' );
		} elseif ( $on_submission ) {
			$held = sprintf(
				/* translators: %d is a number of minutes. */
				__( 'A journey is held for %d minutes when a visitor opens a page with a Gravity Form on it.', 'bw-lead-ai' ),
				$hold_minutes
			);
			$saved = __( 'It is saved when they submit that form.', 'bw-lead-ai' );
		} else {
			$held = sprintf(
				/* translators: %d is a number of minutes. */
				__( 'A journey is held for %d minutes when a visitor clicks a link to your destination site.', 'bw-lead-ai' ),
				$hold_minutes
			);
			$saved = __( 'It is saved when the destination site reports its form was submitted.', 'bw-lead-ai' );
		}
		?>
		<p class="description bw-copy" style="max-width:760px;">
			<?php echo esc_html( $held ); ?>
			<?php echo esc_html( $saved ); ?>
			<?php esc_html_e( 'Held journeys nobody submits expire on their own.', 'bw-lead-ai' ); ?>
		</p>
		<?php
	}

	/**
	 * "Reprocess journeys" — apply today's settings and today's processing to the
	 * history that was captured under yesterday's.
	 *
	 * Deliberately an action the site owner takes, never something that happens on
	 * upgrade: a stored report quietly rewriting itself between two viewings is how
	 * you teach someone not to trust it. So the job of this panel is to make the
	 * decision an informed one — say what has improved since they last did it, say
	 * when that was, and say plainly that it rewrites what is stored.
	 */
	private function render_reprocess_section() {
		$pending  = BW_Lead_AI_Reprocess::pending_changes();
		$last     = BW_Lead_AI_Reprocess::last_run();
		$scopes   = array(
			0   => __( 'All journeys', 'bw-lead-ai' ),
			30  => __( 'Last 30 days', 'bw-lead-ai' ),
			90  => __( 'Last 90 days', 'bw-lead-ai' ),
			365 => __( 'Last 365 days', 'bw-lead-ai' ),
		);
		?>
		<details class="bw-lead-ai-section">
			<summary><h2><?php esc_html_e( 'Reprocess journeys', 'bw-lead-ai' ); ?></h2><?php
				echo wp_kses_post(
					$this->section_status(
						$pending
							? sprintf(
								/* translators: %d: number of processing improvements not yet applied. */
								_n( '%d improvement available', '%d improvements available', count( $pending ), 'bw-lead-ai' ),
								count( $pending )
							)
							: __( 'up to date', 'bw-lead-ai' ),
						$pending ? null : true
					)
				);
			?></summary>

			<p class="description bw-copy">
				<?php esc_html_e( 'Work out again where each stored journey came from, using your settings and this version of the plugin as they are now.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'A journey records the channel it was given on the day it was captured. Fixing a channel mapping, adding a referrer rule or listing one of your own domains changes what happens next &mdash; it does not reach back. This does: it re-derives channel, source and medium on journeys already stored, so a report you look at tomorrow answers with the rules you have today. The values captured on the day are kept alongside the new ones, and running it a second time changes nothing.', 'bw-lead-ai' )
					)
				);
				?>
			</p>

			<?php if ( $pending ) : ?>
				<div class="notice notice-warning inline" style="margin:12px 0;max-width:820px;">
					<p><strong><?php esc_html_e( 'Processing improvements not yet applied to your stored journeys', 'bw-lead-ai' ); ?></strong></p>
					<ul style="list-style:disc;margin:0 0 4px 20px;">
						<?php foreach ( $pending as $change ) : ?>
							<li><?php echo esc_html( $change['summary'] ); ?></li>
						<?php endforeach; ?>
					</ul>
					<p class="description"><?php esc_html_e( 'Your stored journeys were captured before these, so they still carry the old answer.', 'bw-lead-ai' ); ?></p>
				</div>
			<?php else : ?>
				<p class="description bw-copy">
					<?php esc_html_e( 'No processing improvements are waiting. Running it still picks up any changes you have made to channel mappings, referrer rules or your own hostnames.', 'bw-lead-ai' ); ?>
				</p>
			<?php endif; ?>

			<p>
				<?php
				if ( $last ) {
					$when = '' !== $last['time']
						? mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), get_date_from_gmt( $last['time'] ) )
						: __( 'an unknown date', 'bw-lead-ai' );
					echo esc_html(
						sprintf(
							/* translators: 1: date and time of the last run, 2: number of journeys changed. */
							__( 'Last reprocessed %1$s — %2$d journeys changed.', 'bw-lead-ai' ),
							$when,
							(int) $last['count']
						)
					);
				} else {
					esc_html_e( 'Never reprocessed on this site.', 'bw-lead-ai' );
				}
				?>
			</p>

			<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post" onsubmit="return confirm('<?php echo esc_js( __( 'This rewrites the channel, source and medium stored against every journey in the range you picked, using your current settings. What was captured originally is kept alongside. Continue?', 'bw-lead-ai' ) ); ?>');">
				<input type="hidden" name="action" value="<?php echo esc_attr( BW_Lead_AI_Handoff_Admin::REPROCESS_ACTION ); ?>">
				<?php wp_nonce_field( BW_Lead_AI_Handoff_Admin::REPROCESS_ACTION ); ?>
				<label for="bw-lead-ai-reprocess-days"><?php esc_html_e( 'Reprocess:', 'bw-lead-ai' ); ?></label>
				<select id="bw-lead-ai-reprocess-days" name="bw_reprocess_days">
					<?php foreach ( $scopes as $days => $label ) : ?>
						<option value="<?php echo esc_attr( $days ); ?>"><?php echo esc_html( $label ); ?></option>
					<?php endforeach; ?>
				</select>
				<button type="submit" class="button button-secondary"><?php esc_html_e( 'Reprocess journeys', 'bw-lead-ai' ); ?></button>
			</form>
		</details>
		<?php
	}

	/**
	 * Form Field Mapping — which form field each data point writes itself into, for
	 * form plugins that have no merge tags of their own.
	 *
	 * One row per entry in BW_Lead_AI_Merge_Tags::available_tags(), which is the same
	 * register the Gravity Forms merge-tag dropdown reads. That is the whole point of
	 * the rewrite: this screen used to offer six targets while the plugin exposed
	 * twenty-odd tags, and the six were a hand-kept list that could not have known
	 * about a site's own custom dimensions in the first place.
	 *
	 * LAYOUT. Twenty-five rows of wp-admin's `form-table` — one tall row each, a wide
	 * label column, a description under every field — is several screens of scrolling
	 * to find the two rows a site actually uses. So: the register's groups become the
	 * page's collapsible sections, the same `<details>` idiom as the mapping sections
	 * on Tracking Rules, each carrying a count of what is set inside it; and inside a
	 * section each data point is ONE line of a `widefat` table — what it holds, its
	 * merge tag, how to find the field, which field. A section opens if it contains a
	 * mapping, so the screen loads showing what this site has configured and folds
	 * away the twenty rows it has not.
	 */
	private function render_targets_tab() {
		$settings = BW_Lead_AI_Settings::get();
		$targets  = is_array( $settings['field_targets'] ) ? $settings['field_targets'] : array();
		$tags     = BW_Lead_AI_Merge_Tags::available_tags( $settings );
		$groups   = BW_Lead_AI_Merge_Tags::tag_groups();

		// A mapping stored for something this site no longer has — a custom dimension
		// since removed from Tracking Parameter Definitions, say — still gets a row.
		// Dropping it from the screen would leave a value that is live in the page but
		// invisible here, and no way to clear it.
		$orphans = array();
		foreach ( $targets as $key => $row ) {
			if ( isset( $tags[ $key ] ) ) {
				continue;
			}
			if ( empty( $row['attr'] ) && empty( $row['val'] ) ) {
				continue;
			}
			$orphans[ $key ] = array(
				'label'     => $key,
				'group'     => 'orphan',
				'merge_tag' => false,
			);
		}
		if ( $orphans ) {
			$tags             = array_merge( $tags, $orphans );
			$groups['orphan'] = __( 'Not defined on this site', 'bw-lead-ai' );
		}

		// Group the register without reordering it: tag_groups() is the reading order
		// and available_tags() emits in it, so first-seen order is already correct.
		$by_group = array();
		foreach ( $tags as $key => $meta ) {
			$by_group[ $meta['group'] ][ $key ] = $meta;
		}

		$mapped_total = 0;
		foreach ( $tags as $key => $meta ) {
			if ( ! empty( $targets[ $key ]['val'] ) ) {
				$mapped_total++;
			}
		}
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>

			<?php // Hand-rolled pass-through rather than render_passthrough(), because this
			// tab owns the one nested setting. It must honour the same retired list. ?>
			<?php foreach ( array_keys( BW_Lead_AI_Settings::defaults() ) as $hidden_key ) : ?>
				<?php if ( in_array( $hidden_key, array( 'field_targets' ), true ) || in_array( $hidden_key, self::RETIRED_SETTINGS, true ) ) continue; ?>
				<?php $val = $settings[ $hidden_key ]; ?>
				<?php if ( is_array( $val ) ) continue; ?>
				<input type="hidden" name="<?php echo esc_attr( BW_LEAD_AI_OPTION ); ?>[<?php echo esc_attr( $hidden_key ); ?>]" value="<?php echo esc_attr( $val ); ?>">
			<?php endforeach; ?>

			<h2><?php esc_html_e( 'Form Field Mapping', 'bw-lead-ai' ); ?></h2>
			<div class="notice notice-info inline" style="margin: 12px 0;max-width:1000px;">
				<p><strong><?php esc_html_e( 'Using Gravity Forms?', 'bw-lead-ai' ); ?></strong>
				<?php esc_html_e( 'You do not need this screen. Put the merge tag from the second column into a hidden field instead.', 'bw-lead-ai' ); ?></p>
			</div>
			<div class="description bw-copy">
				<?php esc_html_e( 'For form plugins with no merge tags of their own: name a field and the plugin fills it as the form submits.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'Match a field by its <code>id</code>, <code>class</code>, <code>name</code>, or a CSS selector, then give the value. A row with only one half filled in does nothing.', 'bw-lead-ai' )
					)
				);
				?>
			</div>
			<p class="description bw-copy">
				<?php
				echo esc_html(
					$mapped_total
						? sprintf(
							/* translators: %d: how many data points are mapped to a form field. */
							_n( '%d data point is mapped to a field.', '%d data points are mapped to fields.', $mapped_total, 'bw-lead-ai' ),
							$mapped_total
						)
						: __( 'Nothing is mapped yet.', 'bw-lead-ai' )
				);
				?>
			</p>

			<?php
			$opened = false;
			foreach ( $groups as $group => $heading ) :
				if ( empty( $by_group[ $group ] ) ) {
					continue;
				}
				$rows   = $by_group[ $group ];
				$mapped = 0;
				foreach ( $rows as $key => $meta ) {
					if ( ! empty( $targets[ $key ]['val'] ) ) {
						$mapped++;
					}
				}
				// Open where there is something to see. Failing that the first section
				// opens, so the screen never loads as a stack of closed headings with no
				// example of what a row looks like.
				$open   = ( $mapped > 0 ) || ( ! $opened && 0 === $mapped_total );
				$opened = $opened || $open;
				?>
				<details class="bw-lead-ai-section"<?php echo $open ? ' open' : ''; ?>>
					<summary><h2><?php echo esc_html( $heading ); ?></h2><?php
						echo wp_kses_post(
							$this->section_status(
								$mapped
									? sprintf(
										/* translators: 1: how many rows in this section are mapped, 2: how many rows it has. */
										__( '%1$d of %2$d mapped', 'bw-lead-ai' ),
										$mapped,
										count( $rows )
									)
									: sprintf(
										/* translators: %d: how many data points this section offers. */
										_n( '%d data point', '%d data points', count( $rows ), 'bw-lead-ai' ),
										count( $rows )
									),
								$mapped ? true : null
							)
						);
					?></summary>
					<?php if ( 'orphan' === $group ) : ?>
						<p class="description"><?php esc_html_e( 'These are mapped to a field but not defined on this site, so nothing fills them. Clear the field to remove the mapping.', 'bw-lead-ai' ); ?></p>
					<?php endif; ?>
					<table class="widefat striped bw-target-table">
						<thead>
							<tr>
								<th scope="col" class="bw-target-what"><?php esc_html_e( 'Data point', 'bw-lead-ai' ); ?></th>
								<th scope="col" class="bw-target-tag"><?php esc_html_e( 'Merge tag', 'bw-lead-ai' ); ?></th>
								<th scope="col" class="bw-target-attr"><?php esc_html_e( 'Find the field by', 'bw-lead-ai' ); ?></th>
								<th scope="col" class="bw-target-val"><?php esc_html_e( 'Field', 'bw-lead-ai' ); ?></th>
							</tr>
						</thead>
						<tbody>
						<?php foreach ( $rows as $key => $meta ) : ?>
							<?php
							$row  = isset( $targets[ $key ] ) && is_array( $targets[ $key ] ) ? $targets[ $key ] : array();
							$attr = isset( $row['attr'] ) ? (string) $row['attr'] : '';
							$val  = isset( $row['val'] ) ? (string) $row['val'] : '';
							$name = BW_LEAD_AI_OPTION . '[field_targets][' . $key . ']';
							?>
							<tr<?php echo '' !== $val ? ' class="bw-target-set"' : ''; ?>>
								<td class="bw-target-what"><?php echo esc_html( $meta['label'] ); ?></td>
								<td class="bw-target-tag"><?php
									if ( ! empty( $meta['merge_tag'] ) ) {
										echo '<code>{bw:' . esc_html( $key ) . '}</code>';
									} else {
										echo '<span class="bw-target-notag">' . esc_html__( 'no merge tag', 'bw-lead-ai' ) . '</span>';
									}
								?></td>
								<td class="bw-target-attr">
									<label class="screen-reader-text" for="bw-target-attr-<?php echo esc_attr( $key ); ?>"><?php
										/* translators: %s: a data point name, e.g. "Source". */
										echo esc_html( sprintf( __( 'Find the field for %s by', 'bw-lead-ai' ), $meta['label'] ) );
									?></label>
									<select id="bw-target-attr-<?php echo esc_attr( $key ); ?>" name="<?php echo esc_attr( $name . '[attr]' ); ?>">
										<option value=""><?php esc_html_e( '(not mapped)', 'bw-lead-ai' ); ?></option>
										<?php
										foreach ( array(
											'id'       => __( 'Field ID', 'bw-lead-ai' ),
											'class'    => __( 'CSS class', 'bw-lead-ai' ),
											'name'     => __( 'Field name', 'bw-lead-ai' ),
											'selector' => __( 'CSS selector', 'bw-lead-ai' ),
										) as $ak => $al ) {
											printf(
												'<option value="%1$s"%2$s>%3$s</option>',
												esc_attr( $ak ),
												selected( $attr, $ak, false ),
												esc_html( $al )
											);
										}
										?>
									</select>
								</td>
								<td class="bw-target-val">
									<label class="screen-reader-text" for="bw-target-val-<?php echo esc_attr( $key ); ?>"><?php
										/* translators: %s: a data point name, e.g. "Source". */
										echo esc_html( sprintf( __( 'Field for %s', 'bw-lead-ai' ), $meta['label'] ) );
									?></label>
									<input type="text" id="bw-target-val-<?php echo esc_attr( $key ); ?>" name="<?php echo esc_attr( $name . '[val]' ); ?>" value="<?php echo esc_attr( $val ); ?>" class="bw-target-input">
								</td>
							</tr>
						<?php endforeach; ?>
						</tbody>
					</table>
				</details>
			<?php endforeach; ?>

			<?php submit_button(); ?>
		</form>
		<?php
	}

	/**
	 * The fields one tracking link is made of, in the order they are asked for.
	 *
	 * One list, read by the builder, by every saved item and by the JS template —
	 * so a seventh parameter is one entry here rather than four places to keep in
	 * step. Labels drop the "UTM " prefix the old form repeated on every row: the
	 * panel says what these are, and the placeholder carries the real parameter
	 * name for anyone who needs to see it.
	 */
	private static function utm_fields() {
		return array(
			'link_to_uri'  => array( __( 'Linked-to URL', 'bw-lead-ai' ), 'https://example.com/landing' ),
			'utm_source'   => array( __( 'Source', 'bw-lead-ai' ), 'utm_source' ),
			'utm_medium'   => array( __( 'Medium', 'bw-lead-ai' ), 'utm_medium' ),
			'utm_campaign' => array( __( 'Campaign', 'bw-lead-ai' ), 'utm_campaign' ),
			'utm_term'     => array( __( 'Term', 'bw-lead-ai' ), 'utm_term' ),
			'utm_content'  => array( __( 'Content', 'bw-lead-ai' ), 'utm_content' ),
		);
	}

	/** Everything a stored item has, with the gaps filled. */
	private static function utm_item_defaults( $item ) {
		return wp_parse_args(
			is_array( $item ) ? $item : array(),
			array(
				'link_to_uri'  => '',
				'utm_source'   => '',
				'utm_medium'   => '',
				'utm_campaign' => '',
				'utm_term'     => '',
				'utm_content'  => '',
				'note'         => '',
			)
		);
	}

	/**
	 * The one line a collapsed link shows.
	 *
	 * The campaign is what a person looking for a link actually remembers, so it
	 * leads; source / medium disambiguates the four links one campaign usually
	 * has. A link with neither is named by where it points, because "Untitled" a
	 * dozen times over is the same as no list at all.
	 *
	 * Mirrored by summaryOf() in utm-builder.js so an edit updates the closed row
	 * without a reload. Two implementations of one sentence is a real cost; the
	 * alternative is a page load per keystroke.
	 */
	private static function utm_summary( $item ) {
		$item = self::utm_item_defaults( $item );
		$name = trim( (string) $item['utm_campaign'] );

		$pair = array_filter( array( trim( (string) $item['utm_source'] ), trim( (string) $item['utm_medium'] ) ) );
		$pair = implode( ' / ', $pair );

		if ( '' === $name ) {
			$name = $pair;
			$pair = '';
		}
		if ( '' === $name ) {
			$path = wp_parse_url( (string) $item['link_to_uri'], PHP_URL_PATH );
			$name = $path ? $path : __( 'Untitled link', 'bw-lead-ai' );
		}
		return array( 'name' => $name, 'detail' => $pair );
	}

	/**
	 * One saved link: a summary line you can read at a glance, and the fields
	 * behind it.
	 *
	 * A `<details>` rather than a table row with a JS toggle, for two reasons. It
	 * collapses natively, so somebody with JavaScript off can still open a link
	 * and edit it — which the old all-expanded form allowed and a JS-only toggle
	 * would have taken away. And it is the same control every collapsible section
	 * on the settings screens already uses, so it looks like the rest of the
	 * plugin rather than like a new idea.
	 *
	 * **The fields are hidden, never absent.** They post whether the row is open
	 * or shut; a row that only rendered its inputs when expanded would drop every
	 * link the user had not clicked on the next save.
	 *
	 * @param string $index Row index, or `__INDEX__` for the JS template.
	 */
	private static function utm_item_html( $index, $item ) {
		$item    = self::utm_item_defaults( $item );
		$summary = self::utm_summary( $item );
		$opt     = BW_LEAD_AI_UTM_OPTION;
		$name    = function ( $key ) use ( $opt, $index ) {
			return esc_attr( $opt . '[' . $index . '][' . $key . ']' );
		};

		ob_start();
		?>
		<details class="bw-lead-ai-section bw-lead-ai-utm-item">
			<summary>
				<span class="bw-utm-name" data-utm-name><?php echo esc_html( $summary['name'] ); ?></span>
				<span class="bw-utm-detail" data-utm-detail><?php echo esc_html( $summary['detail'] ); ?></span>
				<span class="bw-utm-actions">
					<button type="button" class="button button-small bw-lead-ai-copy" data-target="data-tracked-uri"><?php esc_html_e( 'Copy link', 'bw-lead-ai' ); ?></button>
				</span>
			</summary>
			<div class="bw-utm-body">
				<div class="bw-utm-grid">
					<?php foreach ( self::utm_fields() as $key => $spec ) : ?>
						<p class="bw-utm-field">
							<label><?php echo esc_html( $spec[0] ); ?></label>
							<input type="text" name="<?php echo $name( $key ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the closure. ?>"
								value="<?php echo esc_attr( $item[ $key ] ); ?>"
								placeholder="<?php echo esc_attr( $spec[1] ); ?>" class="bw-lead-ai-utm-input">
						</p>
					<?php endforeach; ?>
				</div>
				<p class="bw-utm-field bw-utm-field--wide">
					<label><?php esc_html_e( 'Note', 'bw-lead-ai' ); ?></label>
					<textarea name="<?php echo $name( 'note' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the closure. ?>" rows="2"><?php echo esc_textarea( $item['note'] ); ?></textarea>
				</p>
				<?php self::utm_readouts(); ?>
				<p>
					<button type="button" class="button bw-lead-ai-utm-open"><?php esc_html_e( 'Open', 'bw-lead-ai' ); ?></button>
					<button type="button" class="button bw-lead-ai-utm-remove"><?php esc_html_e( 'Remove', 'bw-lead-ai' ); ?></button>
				</p>
			</div>
		</details>
		<?php
		return (string) ob_get_clean();
	}

	/** The two read-only outputs, shared by the builder and every saved item. */
	private static function utm_readouts() {
		?>
		<p class="bw-utm-field bw-utm-field--wide">
			<label><?php esc_html_e( 'Tracked URL', 'bw-lead-ai' ); ?></label>
			<span class="bw-utm-readout">
				<input type="text" data-tracked-uri readonly>
				<button type="button" class="button bw-lead-ai-copy" data-target="data-tracked-uri"><?php esc_html_e( 'Copy', 'bw-lead-ai' ); ?></button>
			</span>
		</p>
		<p class="bw-utm-field bw-utm-field--wide">
			<label><?php esc_html_e( 'Parameters only', 'bw-lead-ai' ); ?></label>
			<span class="bw-utm-readout">
				<input type="text" data-parameters readonly>
				<button type="button" class="button bw-lead-ai-copy" data-target="data-parameters"><?php esc_html_e( 'Copy', 'bw-lead-ai' ); ?></button>
			</span>
		</p>
		<?php
	}

	/**
	 * The UTM builder: one panel to compose a link, then a list of the ones you
	 * have.
	 *
	 * **Why it is shaped this way.** Every saved link used to render as a
	 * ten-row form table, all of them expanded, all of the time — so a person
	 * with twenty campaign links scrolled past two hundred rows to reach the
	 * button that adds the twenty-first. The page was a form repeated N times
	 * rather than a list of things you had made.
	 *
	 * Now it is a compose panel and a list. Building a link happens in one place
	 * at the top; adding it collapses it into a single readable line with the
	 * copy button right there, which is what somebody is on this page for.
	 *
	 * **The builder's inputs deliberately carry no `name`.** The sanitizer keeps
	 * every item it is given, so a named-but-unadded builder would append a blank
	 * link on every save. They are scratch space; pressing Add materialises a real
	 * item with real names. Submitting with the builder still filled adds it
	 * first — see the submit handler in utm-builder.js — because losing what
	 * somebody just typed to a button they did not know to press is not a
	 * lesson worth teaching.
	 */
	private function render_utm_tab() {
		wp_enqueue_script( 'bw-lead-ai-utm-builder' );
		wp_localize_script(
			'bw-lead-ai-utm-builder',
			'bwLeadAIUtm',
			array(
				'home'  => home_url(),
				'rest'  => rest_url( BW_Lead_AI_REST::NAMESPACE_V1 . '/links' ),
				'nonce' => wp_create_nonce( 'wp_rest' ),
				'i18n'  => array(
					'untitled' => __( 'Untitled link', 'bw-lead-ai' ),
					'needsUrl' => __( 'Add a linked-to URL first.', 'bw-lead-ai' ),
					'copied'   => __( 'Copied', 'bw-lead-ai' ),
				),
			)
		);

		$options = get_option( BW_LEAD_AI_UTM_OPTION, array() );
		if ( ! is_array( $options ) ) {
			$options = array();
		}
		?>
		<form action="options.php" method="post" class="bw-utm-page">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP . '_utm' ); ?>

			<h2><?php esc_html_e( 'UTM Tracking Links', 'bw-lead-ai' ); ?></h2>
			<p class="description bw-copy"><?php esc_html_e( 'Build outbound campaign URLs to copy into your ad platforms. Links you build are kept here so you can find them again.', 'bw-lead-ai' ); ?></p>

			<div class="bw-utm-builder">
				<h3><?php esc_html_e( 'Build a link', 'bw-lead-ai' ); ?></h3>
				<div class="bw-utm-grid">
					<?php foreach ( self::utm_fields() as $key => $spec ) : ?>
						<p class="bw-utm-field">
							<label for="bw-utm-new-<?php echo esc_attr( $key ); ?>"><?php echo esc_html( $spec[0] ); ?></label>
							<input type="text" id="bw-utm-new-<?php echo esc_attr( $key ); ?>"
								data-utm-field="<?php echo esc_attr( $key ); ?>"
								value="<?php echo esc_attr( 'link_to_uri' === $key ? home_url() : '' ); ?>"
								placeholder="<?php echo esc_attr( $spec[1] ); ?>" class="bw-lead-ai-utm-input">
						</p>
					<?php endforeach; ?>
				</div>
				<p class="bw-utm-field bw-utm-field--wide">
					<label for="bw-utm-new-note"><?php esc_html_e( 'Note', 'bw-lead-ai' ); ?></label>
					<textarea id="bw-utm-new-note" data-utm-field="note" rows="2" placeholder="<?php esc_attr_e( 'What this link is for — only you see this.', 'bw-lead-ai' ); ?>"></textarea>
				</p>
				<?php self::utm_readouts(); ?>
				<p>
					<button type="button" class="button button-primary bw-lead-ai-utm-add"><?php esc_html_e( 'Add to list', 'bw-lead-ai' ); ?></button>
					<span class="description bw-utm-hint"><?php esc_html_e( 'Added links appear below. Nothing is stored until you press Save.', 'bw-lead-ai' ); ?></span>
				</p>
			</div>

			<h3 class="bw-utm-list-head">
				<?php esc_html_e( 'Your links', 'bw-lead-ai' ); ?>
				<span class="bw-utm-count" data-utm-count><?php echo (int) count( $options ); ?></span>
			</h3>
			<p class="description bw-utm-empty"<?php echo $options ? ' hidden' : ''; ?>>
				<?php esc_html_e( 'No links yet. Build one above and it will be listed here.', 'bw-lead-ai' ); ?>
			</p>

			<div class="bw-lead-ai-utm-items">
				<?php
				foreach ( $options as $index => $item ) {
					echo self::utm_item_html( (string) (int) $index, $item ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built escaped; see utm_item_html().
				}
				?>
			</div>

			<?php
			// The shape of a new row, rendered once by PHP so the JS never carries
			// a second copy of this markup — the old builder did, with its labels
			// hardcoded in English and untranslatable.
			?>
			<template id="bw-utm-item-template"><?php
				echo self::utm_item_html( '__INDEX__', array( 'link_to_uri' => home_url() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built escaped; see utm_item_html().
			?></template>

			<?php submit_button(); ?>
		</form>
		<?php
	}

	/**
	 * Preset traffic scenarios for the Test tab's simulated-link harness.
	 *
	 * Built partly from this site's own settings — the click-ID table, non-UTM
	 * parameter aliases and custom dimensions all come from live config, so the
	 * generated links match what this install would actually recognise rather than
	 * a hardcoded ideal.
	 *
	 * Scenario shape:
	 *   group         Display grouping.
	 *   label          Scenario name.
	 *   note           One line on what it exercises.
	 *   params         Query parameters to append to the landing page.
	 *   referrer       Simulated referring URL, '' for none.
	 *   preview_only   True when the scenario depends on a referrer. A link cannot
	 *                  forge a referrer, so those rows preview the resolution but
	 *                  offer no "Open" button — opening one would just be a direct
	 *                  visit and would quietly log the wrong thing.
	 */
	/**
	 * The two switches that belong to no mapping section.
	 *
	 * They lived at the bottom of the mapping tab, where a reader scanning six
	 * sections of `label : value` rules met a checkbox and had to work out that it
	 * was not a seventh rule. On their own tab the mapping tab is six sections that
	 * are all the same kind of thing, and these two are findable by name.
	 *
	 * Not a collapsed <details> like the mapping sections: a tab whose whole
	 * content is two switches has nothing to fold away, and folding them would mean
	 * every visit to this tab starts with a click that reveals what the tab is for.
	 */
	/**
	 * Other Settings — the switches that belong to no mapping section, plus the
	 * settings option itself as a thing you can move, restore and throw away.
	 *
	 * The portability controls live here rather than on Tracking Rules because
	 * they are not a mapping rule; they act ON the mapping rules, which is a
	 * different kind of thing and belongs on the tab for things that are not
	 * mapping rules.
	 */
	private function render_other_tab() {
		$settings = BW_Lead_AI_Settings::get();
		$this->render_other_results();
		?>
		<form action="options.php" method="post">
			<?php settings_fields( BW_Lead_AI_Settings::OPTION_GROUP ); ?>
			<?php $this->render_passthrough( $settings, array( 'debug', 'capture_ga_client_id' ) ); ?>

			<p class="description bw-copy"><?php  ?></p>

			<table class="form-table" role="presentation">
				<tr>
					<th scope="row"><?php esc_html_e( 'Capture Google Client ID', 'bw-lead-ai' ); ?></th>
					<td>
						<label><input type="checkbox" name="<?php echo esc_attr( BW_LEAD_AI_OPTION ); ?>[capture_ga_client_id]" value="1" <?php checked( 1, (int) $settings['capture_ga_client_id'] ); ?>> <?php esc_html_e( 'Capture the GA client ID and enable the {bw:ga_client_id} merge tag.', 'bw-lead-ai' ); ?></label>
						<p class="description"><?php
							echo wp_kses(
								__( 'The GA4 <strong>client ID</strong> is the anonymous per-browser ID in the <code>_ga</code> cookie, exposed as <code>{bw:ga_client_id}</code>. Join a lead back to its GA4 session in reporting or BigQuery.', 'bw-lead-ai' ),
								array( 'code' => array(), 'strong' => array() )
							);
							?></p>
						<p class="description"><?php
							echo wp_kses(
								__( 'It is read when a form is filled, never stored. If GA is blocked the cookie is missing and the tag comes out empty &mdash; your attribution is unaffected.', 'bw-lead-ai' ),
								array( 'code' => array() )
							);
							?></p>
					</td>
				</tr>
				<tr>
					<th scope="row"><?php esc_html_e( 'Debug Mode', 'bw-lead-ai' ); ?></th>
					<td><label><input type="checkbox" name="<?php echo esc_attr( BW_LEAD_AI_OPTION ); ?>[debug]" value="1" <?php checked( 1, (int) $settings['debug'] ); ?>> <?php esc_html_e( 'Log capture activity to the browser console and expose window.BWLeadAI.', 'bw-lead-ai' ); ?></label></td>
				</tr>
			</table>

			<?php submit_button(); ?>
		</form>
		<?php
		$this->render_settings_portability();
	}

	/**
	 * The result of the last export / import / reset, said once.
	 *
	 * The one message that is not from a fixed list — why an import was rejected — is
	 * read out of a short-lived transient rather than out of the URL. A reason worth
	 * printing is a reason worth printing accurately, and the query string is the
	 * wrong place to keep a sentence.
	 */
	private function render_other_results() {
		$results = array(
			'imported'          => array( 'success', __( 'Settings imported. Only the mapping rules the file carried were touched; everything specific to this site is as it was.', 'bw-lead-ai' ) ),
			'import_discarded'  => array( 'success', __( 'Import discarded. Nothing was changed.', 'bw-lead-ai' ) ),
			'import_expired'    => array( 'error', __( 'That import had expired or was already applied. Nothing was changed — paste or upload the file again.', 'bw-lead-ai' ) ),
			'import_failed'     => array( 'error', __( 'That import could not be read, and nothing was changed.', 'bw-lead-ai' ) ),
			'reset'             => array( 'success', __( 'Every setting is back to the shipped defaults. Your stored journeys, leads, saved UTM links and dismissed data-quality findings were not touched.', 'bw-lead-ai' ) ),
			'reset_unconfirmed' => array( 'error', __( 'Nothing was reset. The confirmation box has to contain the word RESET.', 'bw-lead-ai' ) ),
		);

		$error = get_transient( self::import_error_key() );
		if ( is_string( $error ) && '' !== $error ) {
			delete_transient( self::import_error_key() );
			echo '<div class="notice notice-error is-dismissible"><p>' . esc_html( $error ) . '</p></div>';
			unset( $results['import_failed'] );
		}

		self::result_notice( 'bw_settings', $results );
	}

	/**
	 * Export, import and factory reset — the settings option as a portable file.
	 *
	 * Outside the options.php form above, not inside it. These are three separate
	 * state-changing actions posting to admin-post.php with their own nonces, and a
	 * form inside a form is invalid HTML that browsers resolve by silently dropping
	 * one of them.
	 *
	 * What travels and what does not is decided in ONE place —
	 * BW_Lead_AI_Settings::PORTABLE_SETTINGS — and this screen lists it from there
	 * rather than restating it, so the copy cannot describe an export the code does
	 * not produce.
	 */
	private function render_settings_portability() {
		$this->render_import_preview();

		$portable = array();
		foreach ( BW_Lead_AI_Settings::PORTABLE_SETTINGS as $key ) {
			$portable[] = BW_Lead_AI_Settings::portable_label( $key );
		}
		?>
		<hr class="bw-portability-rule">

		<h2><?php esc_html_e( 'Export and import settings', 'bw-lead-ai' ); ?></h2>
		<p class="description bw-copy">
			<?php esc_html_e( 'Your mapping rules, as a file you can keep or move to another site.', 'bw-lead-ai' ); ?>
			<?php
			echo wp_kses_post(
				$this->help(
					__( 'These rules name no single site. <code>{this-domain}</code> expands to wherever it runs, so <code>internal : *.{this-domain}</code> still means your own subdomains after a move.', 'bw-lead-ai' )
				)
			);
			?>
		</p>

		<table class="form-table" role="presentation">
			<tr>
				<th scope="row"><?php esc_html_e( 'Export', 'bw-lead-ai' ); ?></th>
				<td>
					<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
						<input type="hidden" name="action" value="<?php echo esc_attr( self::SETTINGS_EXPORT_ACTION ); ?>">
						<?php wp_nonce_field( self::SETTINGS_EXPORT_ACTION ); ?>
						<button type="submit" class="button"><?php esc_html_e( 'Download settings (.json)', 'bw-lead-ai' ); ?></button>
					</form>
					<p class="description">
						<?php
						echo esc_html(
							sprintf(
								/* translators: %s: a comma-separated list of setting names. */
								__( 'Carries: %s.', 'bw-lead-ai' ),
								implode( ', ', $portable )
							)
						);
						?>
					</p>
					<p class="description">
						<?php esc_html_e( 'Left out: anything that names this site, and every switch that collects or sends personal data — so a pasted file cannot turn those on. Nothing in the file is secret.', 'bw-lead-ai' ); ?>
					</p>
				</td>
			</tr>
			<tr>
				<th scope="row"><?php esc_html_e( 'Import', 'bw-lead-ai' ); ?></th>
				<td>
					<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post" enctype="multipart/form-data">
						<input type="hidden" name="action" value="<?php echo esc_attr( self::SETTINGS_IMPORT_ACTION ); ?>">
						<?php wp_nonce_field( self::SETTINGS_IMPORT_ACTION ); ?>
						<p>
							<label for="bw-lead-ai-import-file"><?php esc_html_e( 'Choose an export file:', 'bw-lead-ai' ); ?></label>
							<input type="file" id="bw-lead-ai-import-file" name="bw_import_file" accept=".json,application/json">
						</p>
						<p>
							<label for="bw-lead-ai-import-json"><?php esc_html_e( 'Or paste its contents:', 'bw-lead-ai' ); ?></label><br>
							<textarea id="bw-lead-ai-import-json" name="bw_import_json" rows="5" class="large-text code" spellcheck="false" placeholder="{&quot;format&quot;: &quot;bw-lead-ai-settings&quot;, ...}"></textarea>
						</p>
						<button type="submit" class="button"><?php esc_html_e( 'Check this import', 'bw-lead-ai' ); ?></button>
					</form>
					<p class="description">
						<?php esc_html_e( 'Nothing is saved yet. You are shown exactly which rules would change, line by line, before anything is written.', 'bw-lead-ai' ); ?>
					</p>
				</td>
			</tr>
		</table>

		<details class="bw-lead-ai-section">
			<summary>
				<h2><?php esc_html_e( 'Factory reset', 'bw-lead-ai' ); ?></h2>
				<?php echo wp_kses_post( $this->section_status( __( 'restores every setting', 'bw-lead-ai' ) ) ); ?>
			</summary>

			<p class="description bw-copy">
				<?php  ?>
			</p>
			<p class="description bw-copy bw-warn">
				<?php esc_html_e( 'This clears every tab of this settings screen: your tracking rules, your interaction tracking, your cross-domain configuration, your journey storage policy and your form field mapping. It cannot be undone — export first if there is anything here you would want back.', 'bw-lead-ai' ); ?>
			</p>
			<p class="description bw-copy">
				<?php esc_html_e( 'It does NOT delete anything you have collected. Your journeys, leads, saved UTM links and dismissed data-quality findings all live outside these settings and survive untouched.', 'bw-lead-ai' ); ?>
			</p>

			<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
				<input type="hidden" name="action" value="<?php echo esc_attr( self::SETTINGS_RESET_ACTION ); ?>">
				<?php wp_nonce_field( self::SETTINGS_RESET_ACTION ); ?>
				<p>
					<label for="bw-lead-ai-reset-confirm">
						<?php
						echo esc_html(
							sprintf(
								/* translators: %s: the word that has to be typed, e.g. RESET. */
								__( 'Type %s to confirm:', 'bw-lead-ai' ),
								self::SETTINGS_RESET_WORD
							)
						);
						?>
					</label>
					<input type="text" id="bw-lead-ai-reset-confirm" name="bw_reset_confirm" class="regular-text" autocomplete="off" spellcheck="false" value="">
				</p>
				<button type="submit" class="button button-secondary"><?php esc_html_e( 'Reset all settings to defaults', 'bw-lead-ai' ); ?></button>
			</form>
		</details>
		<?php
	}

	/**
	 * What a parked import would change, before it changes anything.
	 *
	 * A diff rather than a summary. "11 settings would be imported" is not something
	 * anybody can consent to; "this rule is removed and these four are added" is. The
	 * lines shown are the SANITIZED result, so what is on screen is what would be
	 * stored — a rule the sanitizer would strip cannot appear here as an addition and
	 * then quietly fail to arrive.
	 */
	private function render_import_preview() {
		if ( 'preview' !== self::reports_pill_param( 'bw_import', array( 'preview' ), '' ) ) {
			return;
		}

		$parked = get_transient( self::import_key() );
		if ( ! is_array( $parked ) || empty( $parked['settings'] ) ) {
			echo '<div class="notice notice-error"><p>'
				. esc_html__( 'That import has expired or was already applied. Nothing was changed — paste or upload the file again.', 'bw-lead-ai' )
				. '</p></div>';
			return;
		}

		$preview = BW_Lead_AI_Settings::preview_import( $parked['settings'] );
		$changes = $preview['changes'];
		$meta    = isset( $parked['meta'] ) ? $parked['meta'] : array();

		echo '<div class="notice notice-warning bw-import-preview">';
		echo '<h2>' . esc_html__( 'Ready to import — nothing has been saved yet', 'bw-lead-ai' ) . '</h2>';

		if ( ! empty( $meta['site'] ) || ! empty( $meta['version'] ) ) {
			echo '<p class="description">' . esc_html(
				sprintf(
					/* translators: 1: the site the export came from, 2: the plugin version that wrote it, 3: when. */
					__( 'Exported from %1$s by version %2$s on %3$s.', 'bw-lead-ai' ),
					! empty( $meta['site'] ) ? $meta['site'] : __( 'an unnamed site', 'bw-lead-ai' ),
					! empty( $meta['version'] ) ? $meta['version'] : __( 'an unknown version', 'bw-lead-ai' ),
					! empty( $meta['exported'] ) ? $meta['exported'] : __( 'an unknown date', 'bw-lead-ai' )
				)
			) . '</p>';
		}

		if ( empty( $changes ) ) {
			echo '<p>' . esc_html__( 'Nothing would change — this site already has exactly these rules.', 'bw-lead-ai' ) . '</p>';
		} else {
			echo '<p>' . esc_html(
				sprintf(
					/* translators: %s: how many settings would change. */
					_n( '%s setting would change:', '%s settings would change:', count( $changes ), 'bw-lead-ai' ),
					number_format_i18n( count( $changes ) )
				)
			) . '</p>';
			echo '<table class="widefat striped bw-import-diff"><thead><tr>';
			echo '<th>' . esc_html__( 'Setting', 'bw-lead-ai' ) . '</th>';
			echo '<th>' . esc_html__( 'What changes', 'bw-lead-ai' ) . '</th>';
			echo '</tr></thead><tbody>';
			foreach ( $changes as $key => $pair ) {
				echo '<tr><td><strong>' . esc_html( BW_Lead_AI_Settings::portable_label( $key ) ) . '</strong></td><td>';
				$this->render_import_diff( $pair[0], $pair[1] );
				echo '</td></tr>';
			}
			echo '</tbody></table>';
		}

		if ( ! empty( $parked['ignored'] ) ) {
			// Named, not swallowed. A file with a key this plugin does not import is a
			// file that will not do what its author expected, and the only way anybody
			// finds that out is by being told.
			echo '<p class="description">' . esc_html(
				sprintf(
					/* translators: %s: comma-separated list of setting keys. */
					__( 'Ignored, because they are not settings this plugin imports: %s', 'bw-lead-ai' ),
					implode( ', ', $parked['ignored'] )
				)
			) . '</p>';
		}

		echo '<form action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" method="post" class="bw-import-actions">';
		echo '<input type="hidden" name="action" value="' . esc_attr( self::SETTINGS_IMPORT_APPLY_ACTION ) . '">';
		wp_nonce_field( self::SETTINGS_IMPORT_APPLY_ACTION );
		echo '<button type="submit" name="state" value="apply" class="button button-primary">'
			. esc_html__( 'Import these settings', 'bw-lead-ai' ) . '</button> ';
		echo '<button type="submit" name="state" value="discard" class="button">'
			. esc_html__( 'Discard', 'bw-lead-ai' ) . '</button>';
		echo '</form>';
		echo '</div>';
	}

	/**
	 * One setting's change, as the lines that go and the lines that arrive.
	 *
	 * Line-level rather than character-level because every portable setting is a list
	 * of rules, one per line, and "this rule leaves, these three arrive" is the unit
	 * somebody actually decides about. Capped, because a first-ever import replaces
	 * every line and a reader does not need forty of them to understand that.
	 */
	private function render_import_diff( $before, $after ) {
		$split = function ( $text ) {
			return array_values( array_filter( array_map( 'trim', preg_split( '/\R/', (string) $text ) ), 'strlen' ) );
		};
		$old   = $split( $before );
		$new   = $split( $after );
		$gone  = array_values( array_diff( $old, $new ) );
		$added = array_values( array_diff( $new, $old ) );

		if ( empty( $gone ) && empty( $added ) ) {
			// Same lines, different order — which matters: the first matching rule
			// wins for referrer classification and for channels.
			echo '<span class="bw-report-detail">' . esc_html__( 'The same rules, in a different order. Order decides which rule wins.', 'bw-lead-ai' ) . '</span>';
			return;
		}

		$cap = 6;
		echo '<ul class="bw-import-lines">';
		foreach ( array_slice( $gone, 0, $cap ) as $line ) {
			echo '<li class="is-gone"><span aria-hidden="true">&minus;</span> <code>' . esc_html( $line ) . '</code></li>';
		}
		if ( count( $gone ) > $cap ) {
			echo '<li class="is-more">' . esc_html(
				sprintf(
					/* translators: %s: a number of further lines. */
					_n( '…and %s more line removed', '…and %s more lines removed', count( $gone ) - $cap, 'bw-lead-ai' ),
					number_format_i18n( count( $gone ) - $cap )
				)
			) . '</li>';
		}
		foreach ( array_slice( $added, 0, $cap ) as $line ) {
			echo '<li class="is-added"><span aria-hidden="true">+</span> <code>' . esc_html( $line ) . '</code></li>';
		}
		if ( count( $added ) > $cap ) {
			echo '<li class="is-more">' . esc_html(
				sprintf(
					/* translators: %s: a number of further lines. */
					_n( '…and %s more line added', '…and %s more lines added', count( $added ) - $cap, 'bw-lead-ai' ),
					number_format_i18n( count( $added ) - $cap )
				)
			) . '</li>';
		}
		echo '</ul>';
	}

	private function test_scenarios() {
		$settings = BW_Lead_AI_Settings::get();
		$aliases  = BW_Lead_AI_Settings::parse_parameter_aliases( $settings['parameter_aliases'] );

		$group_full     = __( 'Fully tagged campaigns', 'bw-lead-ai' );
		$group_click    = __( 'Auto-tagging — click ID only', 'bw-lead-ai' );
		$group_minimal  = __( 'Minimal or untagged', 'bw-lead-ai' );
		$group_referrer = __( 'Arriving from another site', 'bw-lead-ai' );
		$group_edge     = __( 'Edge cases', 'bw-lead-ai' );

		$out = array();

		// --- fully tagged -------------------------------------------------
		$out[] = array(
			'group'  => $group_full,
			'label'  => __( 'Google Ads — full UTM tagging', 'bw-lead-ai' ),
			'note'   => __( 'A properly tagged paid click with every dimension present.', 'bw-lead-ai' ),
			'params' => array(
				'utm_source'   => 'google',
				'utm_medium'   => 'cpc',
				'utm_campaign' => 'spring-sale',
				'utm_term'     => 'blue widgets',
				'utm_content'  => 'ad-variant-a',
				'utm_adgroup'  => 'widgets-exact',
			),
		);
		$out[] = array(
			'group'  => $group_full,
			'label'  => __( 'Facebook Ads — full UTM tagging', 'bw-lead-ai' ),
			'note'   => __( 'Paid social tagged explicitly rather than relying on fbclid.', 'bw-lead-ai' ),
			'params' => array(
				'utm_source'   => 'facebook',
				'utm_medium'   => 'cpc',
				'utm_campaign' => 'retargeting-q3',
				'utm_content'  => 'carousel-b',
			),
		);
		$out[] = array(
			'group'  => $group_full,
			'label'  => __( 'Email newsletter', 'bw-lead-ai' ),
			'note'   => __( 'Should land in the Email channel, not Untracked.', 'bw-lead-ai' ),
			'params' => array(
				'utm_source'   => 'newsletter',
				'utm_medium'   => 'email',
				'utm_campaign' => 'july-roundup',
			),
		);

		// --- one row per configured click-ID source/medium pair -----------
		$seen_pairs = array();
		foreach ( BW_Lead_AI_Settings::parse_click_ids( $settings['click_ids'] ) as $row ) {
			$pair = $row['source'] . '/' . $row['medium'];
			if ( isset( $seen_pairs[ $pair ] ) ) {
				continue;
			}
			$seen_pairs[ $pair ] = true;
			$out[]               = array(
				'group'  => $group_click,
				'label'  => sprintf(
					/* translators: 1: click-ID parameter name, 2: source/medium pair. */
					__( '%1$s only → %2$s', 'bw-lead-ai' ),
					$row['param'],
					$pair
				),
				'note'   => __( 'No UTMs at all — the source has to be inferred from the click ID.', 'bw-lead-ai' ),
				'params' => array( $row['param'] => 'bwtest-' . $row['param'] ),
			);
		}

		// --- minimal ------------------------------------------------------
		$out[] = array(
			'group'  => $group_minimal,
			'label'  => __( 'Source only — utm_source=google', 'bw-lead-ai' ),
			'note'   => __( 'Source with no medium. Exercises step 2 of the cascade.', 'bw-lead-ai' ),
			'params' => array( 'utm_source' => 'google' ),
		);
		$out[] = array(
			'group'  => $group_minimal,
			'label'  => __( 'Completely untagged URL', 'bw-lead-ai' ),
			'note'   => __( 'Only what the browser reveals. Opened from here it resolves as Untracked, because your own admin is a same-site referrer.', 'bw-lead-ai' ),
			'params' => array(),
		);

		// --- referrer-driven (preview only) -------------------------------
		$referrers = array(
			array( 'https://www.google.com/search?q=blue+widgets', __( 'Google organic search', 'bw-lead-ai' ) ),
			array( 'https://www.bing.com/search?q=blue+widgets', __( 'Bing organic search', 'bw-lead-ai' ) ),
			array( 'https://www.facebook.com/', __( 'Facebook — organic, unpaid', 'bw-lead-ai' ) ),
			array( 'https://www.linkedin.com/feed/', __( 'LinkedIn — organic', 'bw-lead-ai' ) ),
			array( 'https://blog.example.net/post/', __( 'Unknown site — plain referral', 'bw-lead-ai' ) ),
		);
		foreach ( $referrers as $ref ) {
			$out[] = array(
				'group'        => $group_referrer,
				'label'        => $ref[1],
				'note'         => sprintf(
					/* translators: %s is a referring URL. */
					__( 'Untagged visit whose referrer is %s.', 'bw-lead-ai' ),
					$ref[0]
				),
				'params'       => array(),
				'referrer'     => $ref[0],
				'preview_only' => true,
			);
		}

		// --- edge cases ---------------------------------------------------
		$out[] = array(
			'group'  => $group_edge,
			'label'  => __( 'UTM and gclid together', 'bw-lead-ai' ),
			'note'   => __( 'Both present at once — the UTM values must win over click-ID inference.', 'bw-lead-ai' ),
			'params' => array(
				'utm_source' => 'facebook',
				'utm_medium' => 'social',
				'gclid'      => 'bwtest-conflict',
			),
		);

		// A non-UTM alias pair, if this site has any configured.
		$alt_source = '';
		$alt_medium = '';
		foreach ( $aliases['standard']['source'] as $alias ) {
			if ( 0 !== strpos( $alias, 'utm_' ) ) {
				$alt_source = $alias;
				break;
			}
		}
		foreach ( $aliases['standard']['medium'] as $alias ) {
			if ( 0 !== strpos( $alias, 'utm_' ) ) {
				$alt_medium = $alias;
				break;
			}
		}
		if ( '' !== $alt_source && '' !== $alt_medium ) {
			$out[] = array(
				'group'  => $group_edge,
				'label'  => sprintf(
					/* translators: 1: source alias, 2: medium alias. */
					__( 'This site\'s own aliases — %1$s / %2$s', 'bw-lead-ai' ),
					$alt_source,
					$alt_medium
				),
				'note'   => __( 'Uses your non-UTM parameter aliases instead of utm_source / utm_medium.', 'bw-lead-ai' ),
				'params' => array( $alt_source => 'partner-site', $alt_medium => 'referral' ),
			);
		}

		// One row covering every configured custom dimension at once.
		if ( ! empty( $aliases['custom'] ) ) {
			$custom_params = array();
			$custom_keys   = array();
			foreach ( $aliases['custom'] as $key => $param_list ) {
				if ( empty( $param_list ) ) {
					continue;
				}
				$custom_params[ $param_list[0] ] = 'test-' . $key;
				$custom_keys[]                   = $key;
			}
			if ( ! empty( $custom_params ) ) {
				$custom_params['utm_source'] = 'google';
				$custom_params['utm_medium'] = 'cpc';
				$out[]                       = array(
					'group'  => $group_edge,
					'label'  => sprintf(
						/* translators: %s is a comma-separated list of custom dimension keys. */
						__( 'Custom dimensions — %s', 'bw-lead-ai' ),
						implode( ', ', $custom_keys )
					),
					'note'   => __( 'A tagged visit that also carries every custom dimension you have defined.', 'bw-lead-ai' ),
					'params' => $custom_params,
				);
			}
		}

		// Normalise the optional keys so the JS never has to guess.
		foreach ( $out as $index => $scenario ) {
			$out[ $index ]['referrer']     = isset( $scenario['referrer'] ) ? $scenario['referrer'] : '';
			$out[ $index ]['preview_only'] = ! empty( $scenario['preview_only'] );
		}

		return $out;
	}

	private function render_test_tab() {
		wp_enqueue_script( 'bw-lead-ai-capture' );
		wp_enqueue_script( 'bw-lead-ai-admin-test' );
		wp_localize_script(
			'bw-lead-ai-admin-test',
			'bwLeadAITest',
			array(
				'origin'    => untrailingslashit( home_url() ),
				'scenarios' => $this->test_scenarios(),
			)
		);
		?>
		<h2><?php esc_html_e( 'Live Capture State', 'bw-lead-ai' ); ?></h2>
		<p class="description"><?php esc_html_e( 'Shows the tracking state stored in your browser right now.', 'bw-lead-ai' ); ?></p>

		<details class="bw-lead-ai-section">
			<summary><h2><?php esc_html_e( 'Simulated test links', 'bw-lead-ai' ); ?></h2></summary>
			<div class="description bw-copy">
				<?php esc_html_e( 'Pick a landing page, open any link in a new tab, then come back here to see what got logged.', 'bw-lead-ai' ); ?>
				<?php
				echo wp_kses_post(
					$this->help(
						__( 'The <strong>resolves to</strong> column is computed live against <em>this site&#8217;s</em> settings, so it doubles as a check of your configuration. Nothing is saved to the UTM Builder.', 'bw-lead-ai' )
					)
				);
				?>
			</div>
			<p>
				<label for="bw-lead-ai-simlinks-path"><strong><?php esc_html_e( 'Landing page', 'bw-lead-ai' ); ?></strong></label><br>
				<code class="bw-lead-ai-simlinks-origin"><?php echo esc_html( untrailingslashit( home_url() ) ); ?></code>
				<input type="text" id="bw-lead-ai-simlinks-path" value="/" placeholder="/landing-page/" class="regular-text">
			</p>
			<div id="bw-lead-ai-simlinks-out">(<?php esc_html_e( 'loading', 'bw-lead-ai' ); ?>)</div>
		</details>

		<div class="bw-lead-ai-test">
			<div class="bw-lead-ai-test-controls">
				<h3><?php esc_html_e( 'URL Simulator', 'bw-lead-ai' ); ?></h3>
				<p class="description"><?php esc_html_e( 'Type any URL to see how the plugin would classify it — without navigating or storing anything.', 'bw-lead-ai' ); ?></p>
				<p>
					<input type="text" id="bw-lead-ai-sim-url" class="large-text" placeholder="https://yoursite.com/landing?utm_source=google&amp;utm_medium=cpc&amp;utm_campaign=spring-sale">
				</p>
				<p>
					<button type="button" class="button button-primary" id="bw-lead-ai-sim-run"><?php esc_html_e( 'Simulate', 'bw-lead-ai' ); ?></button>
					<button type="button" class="button" id="bw-lead-ai-sim-open"><?php esc_html_e( 'Open in new tab', 'bw-lead-ai' ); ?></button>
				</p>
				<div id="bw-lead-ai-sim-result"></div>
			</div>

			<div class="bw-lead-ai-test-panels">
				<h3><?php esc_html_e( 'Resolved merge tags (what forms would submit)', 'bw-lead-ai' ); ?></h3>
				<div id="bw-lead-ai-resolved" class="bw-lead-ai-panel">(loading)</div>

				<h3><?php esc_html_e( 'Summary counters', 'bw-lead-ai' ); ?></h3>
				<div id="bw-lead-ai-summary" class="bw-lead-ai-panel">(loading)</div>

				<details>
					<summary><?php esc_html_e( 'Visit history (JSON)', 'bw-lead-ai' ); ?></summary>
					<pre id="bw-lead-ai-visits" class="bw-lead-ai-pre">(loading)</pre>
				</details>

				<details>
					<summary><?php esc_html_e( 'Raw localStorage', 'bw-lead-ai' ); ?></summary>
					<pre id="bw-lead-ai-raw" class="bw-lead-ai-pre">(loading)</pre>
				</details>

				<p style="margin-top: 16px;">
					<button type="button" class="button" id="bw-lead-ai-refresh"><?php esc_html_e( 'Refresh state', 'bw-lead-ai' ); ?></button>
					<button type="button" class="button button-link-delete" id="bw-lead-ai-clear"><?php esc_html_e( 'Clear all tracking storage', 'bw-lead-ai' ); ?></button>
				</p>
			</div>
		</div>
		<?php
	}

	private function render_help_tab() {
		?>
		<h2><?php esc_html_e( 'Which merge tag should I use?', 'bw-lead-ai' ); ?></h2>
		<p><?php  ?></p>
		<table class="widefat striped" style="max-width: 800px;">
			<thead>
				<tr>
					<th><?php esc_html_e( 'I want to...', 'bw-lead-ai' ); ?></th>
					<th><?php esc_html_e( 'Use this merge tag', 'bw-lead-ai' ); ?></th>
				</tr>
			</thead>
			<tbody>
				<tr>
					<td><?php esc_html_e( 'Send one "Lead Source" field to Salesforce / HubSpot', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:source_medium}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'See which campaign generated the lead', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:campaign}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'See which keyword triggered the ad', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:term}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'Know what page they first landed on', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:first_page}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'Know what page they submitted the form on', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:submit_page}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'Compare the first vs. last source for multi-touch', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:first_source}</code> + <code>{bw:source}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'Dump the full attribution history into a notes field', 'bw-lead-ai' ); ?></td>
					<td><code>{bw:summary}</code></td>
				</tr>
				<tr>
					<td><?php esc_html_e( 'Track a custom parameter (e.g. match type)', 'bw-lead-ai' ); ?></td>
					<td><?php esc_html_e( 'Add a row in Tracking Parameter Definitions with a non-standard label — it becomes {bw:your_key}.', 'bw-lead-ai' ); ?></td>
				</tr>
			</tbody>
		</table>

		<h2 style="margin-top: 24px;"><?php esc_html_e( 'All available merge tags', 'bw-lead-ai' ); ?></h2>
		<p><?php esc_html_e( 'Drop a Hidden field into your Gravity Form and set its default value to any tag below.', 'bw-lead-ai' ); ?></p>
		<table class="widefat striped" style="max-width: 800px;">
			<thead>
				<tr>
					<th><?php esc_html_e( 'Merge tag', 'bw-lead-ai' ); ?></th>
					<th><?php esc_html_e( 'Description', 'bw-lead-ai' ); ?></th>
				</tr>
			</thead>
			<tbody>
				<tr><td><code>{bw:source}</code></td><td><?php esc_html_e( 'Last visit source (e.g. google, facebook)', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:medium}</code></td><td><?php esc_html_e( 'Last visit medium (e.g. cpc, organic, social)', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:source_medium}</code></td><td><?php esc_html_e( 'Combined "source / medium"', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:channel}</code></td><td><?php esc_html_e( 'Friendly channel label for the last visit, resolved from Channel Mappings (e.g. "Google Ads", "Email", "Untracked"). Falls back to raw source / medium if no rule matches.', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:campaign}</code></td><td><?php esc_html_e( 'Last visit campaign name', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:term}</code></td><td><?php esc_html_e( 'Last visit keyword / term', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:content}</code></td><td><?php esc_html_e( 'Last visit ad content / creative', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:adgroup}</code></td><td><?php esc_html_e( 'Last visit ad group', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:first_page}</code></td><td><?php esc_html_e( 'First-ever landing page URL', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:last_page}</code></td><td><?php esc_html_e( 'Most recent landing page URL', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:submit_page}</code></td><td><?php esc_html_e( 'Page URL where the form was submitted (resolved live)', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:first_source}</code></td><td><?php esc_html_e( 'First-ever visit source', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:first_medium}</code></td><td><?php esc_html_e( 'First-ever visit medium', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:first_channel}</code></td><td><?php esc_html_e( 'Friendly channel label for the first visit.', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:visits}</code></td><td><?php esc_html_e( 'Total visit count', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:pages}</code></td><td><?php esc_html_e( 'Total page view count', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:tagged_visits}</code></td><td><?php esc_html_e( 'Number of visits that had UTM/click-ID tags', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:summary}</code></td><td><?php esc_html_e( 'Full attribution summary (dates, all sources, visit history)', 'bw-lead-ai' ); ?></td></tr>
				<tr><td><code>{bw:summary_detailed}</code></td><td><?php esc_html_e( 'Like {bw:summary} but journey expands to list every page visited within each touchpoint, including form submissions', 'bw-lead-ai' ); ?></td></tr>
				<?php
				// Everything above is a fixed built-in with a description worth writing
				// by hand. Everything this site has ON TOP of those — its custom
				// dimensions, the GA client ID once that capture is on, the interaction
				// tags once an event type is enabled — comes from the register, so a tag
				// that exists in the Gravity Forms dropdown and on Form Field Mapping
				// cannot be missing from the one screen that explains what it is.
				$settings = BW_Lead_AI_Settings::get();
				$aliases  = BW_Lead_AI_Settings::parse_parameter_aliases( $settings['parameter_aliases'] );
				foreach ( BW_Lead_AI_Merge_Tags::available_tags( $settings ) as $tag => $meta ) {
					if ( empty( $meta['merge_tag'] ) || in_array( $tag, BW_Lead_AI_Merge_Tags::TAGS, true ) ) {
						continue;
					}
					$description = isset( $aliases['custom'][ $tag ] )
						? esc_html__( 'Custom dimension, populated from URL parameters:', 'bw-lead-ai' )
							. ' <code>' . esc_html( implode( ', ', $aliases['custom'][ $tag ] ) ) . '</code>'
						: esc_html( $meta['label'] );
					echo '<tr><td><code>{bw:' . esc_html( $tag ) . '}</code></td><td>'
						. wp_kses( $description, array( 'code' => array() ) ) . '</td></tr>';
				}
				?>
			</tbody>
		</table>

		<h2 style="margin-top: 24px;"><?php esc_html_e( 'Setup example: Salesforce', 'bw-lead-ai' ); ?></h2>
		<ol>
			<li><?php esc_html_e( 'Add a Hidden field to your form, set default value to {bw:source_medium}', 'bw-lead-ai' ); ?></li>
			<li><?php esc_html_e( 'Add another Hidden field, set default value to {bw:summary}', 'bw-lead-ai' ); ?></li>
			<li><?php esc_html_e( 'Map both to your Salesforce fields in the GF Salesforce add-on.', 'bw-lead-ai' ); ?></li>
		</ol>

		<h2 style="margin-top: 24px;"><?php esc_html_e( 'Non-Gravity-Forms plugins', 'bw-lead-ai' ); ?></h2>
		<p><?php esc_html_e( 'Use Settings → Form Field Mapping to name the field each data point writes into — by id, class, name, or CSS selector.', 'bw-lead-ai' ); ?></p>

		<h2 style="margin-top: 24px;"><?php esc_html_e( 'How attribution works', 'bw-lead-ai' ); ?></h2>
		<p><?php  ?></p>
		<ul class="ul-disc" style="margin-left: 2em;">
			<li><strong><?php esc_html_e( 'First touch', 'bw-lead-ai' ); ?></strong> — <?php esc_html_e( 'the very first interaction that brought the visitor to your site ({bw:first_source}, {bw:first_medium}, {bw:first_page}). Never overwritten.', 'bw-lead-ai' ); ?></li>
			<li><strong><?php esc_html_e( 'Last touch', 'bw-lead-ai' ); ?></strong> — <?php esc_html_e( 'the most recent campaign click ({bw:source}, {bw:medium}, etc.). Updated whenever the visitor returns with new UTM parameters or a click ID.', 'bw-lead-ai' ); ?></li>
			<li><strong><?php esc_html_e( 'Full journey', 'bw-lead-ai' ); ?></strong> — <?php esc_html_e( 'all touchpoints stored in the visit history ({bw:summary}). Useful for multi-touch attribution reporting.', 'bw-lead-ai' ); ?></li>
		</ul>
		<?php
	}
}
