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

/**
 * Aggregation for the Reports dashboard.
 *
 * Six blocks come out of here — KPI strip, weekly volume by channel, channel
 * performance, what the credited click is hiding, per-form engagement, and data
 * quality — and every one of them is computed from a SINGLE pass over the range's
 * rows. The channel blocks credit ONE end of each journey, chosen by build()'s
 * $attribution argument: 'last' (default) is the channel the lead enquired
 * through, 'first' the channel that first found them. The
 * obvious shape (a query per block, or worse a query per journey) turns one page
 * load into hundreds of round trips the moment a site has a few thousand leads,
 * and each block wants the same rows anyway.
 *
 * WHAT a row is is the second argument, $dimension: the same journeys read as
 * channels, as the groups those channels belong to, or as the raw source/medium
 * tags underneath them. It is one aggregate at one granularity, not five reports —
 * dimension_value() is the single place that knows how to place a journey, and
 * every block asks it rather than reading a field. That is what keeps the cards,
 * the chart, the table and the transitions from ever disagreeing about what they
 * are counting, and it is why adding a sixth dimension is one switch case.
 *
 * Nothing here renders. `build()` returns a plain array and a screen decides what
 * to draw with it, so the arithmetic can be read, tested and re-run against a real
 * dataset without a browser in the loop.
 *
 * **Missing is not zero.** Journey payloads are sparse by nature: which datapoints
 * a site stores is a setting, and imported history carries whatever its summary
 * happened to say. So every average here divides by the number of journeys that
 * actually carried the field, never by the number of journeys. Counting an absent
 * page count as 0 drags every engagement figure toward the floor and makes a
 * channel that produces genuinely engaged leads read as a bad one — which is
 * precisely the judgement this dashboard exists to inform.
 *
 * **Nothing is inferred that the data cannot support.** No conversion rates (the
 * plugin only ever sees journeys that converted, so it has no denominator), and a
 * field whose coverage is too thin to rank is reported as a data-quality finding
 * rather than dressed up as a chart.
 */
class BW_Lead_AI_Reports {

	/**
	 * The fewest weeks the chart will draw on an unbounded range.
	 *
	 * Enough that a new site sees a chart rather than a blank space where one
	 * should be. See weekly() for why it applies only to "all time".
	 */
	const MIN_CHART_WEEKS = 8;

	/**
	 * How many channels the weekly chart stacks before it stops.
	 *
	 * Five plus "Other" is the point where a stacked bar is still readable; past
	 * that the bands are thinner than the gridlines and nobody can tell them apart.
	 */
	const CHART_CHANNELS = 5;

	/**
	 * The bucket everything outside the charted channels lands in.
	 *
	 * Deliberately an untranslated literal — it is a data key that the renderer
	 * matches on, not a label. Translating it here would mean the key changed with
	 * the site's language.
	 */
	const OTHER = 'Other';

	/**
	 * Below this many journeys the dashboard says so instead of drawing trends.
	 *
	 * This ships to sites with four journeys as readily as to sites with four
	 * hundred, and a seventeen-week chart drawn from four points invites a
	 * conclusion the data cannot carry.
	 */
	const SPARSE_LEADS = 10;

	/** Campaign coverage under this share means the field is broken, not merely empty. */
	const CAMPAIGN_COVERAGE_FLOOR = 5;

	/**
	 * Real mediums that are legitimately short, so the malformed check spares them.
	 *
	 * `ai` is on this list because this plugin generates it — the default referrer
	 * rules classify AI assistants under an `ai` medium. Omitting it meant the
	 * check flagged every ChatGPT and Copilot lead as malformed, which is the
	 * failure mode a data-quality report can least afford: a warning that is wrong
	 * teaches the reader to ignore the ones that are right. Any medium this plugin
	 * can itself produce belongs here.
	 */
	const SHORT_MEDIUMS = array( 'cpc', 'ppc', 'seo', 'sms', 'qr', 'ad', 'ai' );

	/**
	 * Ceiling on how many weekly buckets `build()` will enumerate.
	 *
	 * "All time" starts at the oldest journey, and one backdated or malformed
	 * timestamp would otherwise ask this to build tens of thousands of empty weeks.
	 * Ten years is far past the point where a weekly bar chart is the right shape.
	 */
	const MAX_WEEKS = 520;

	/**
	 * Where dismissed data-quality findings are kept.
	 *
	 * Its own option rather than a key in the settings option. A dismissal is a
	 * statement about THIS site's traffic at a moment in time, not configuration:
	 * exporting it to another site would carry over a judgement about numbers that
	 * site has never had, and putting it inside the settings option would let a
	 * factory reset silently un-hide six warnings — or, worse, let a settings save
	 * from an unrelated tab wipe it. Autoloaded off: nothing on the front end reads
	 * it. See quality_dismissals().
	 */
	const QUALITY_DISMISS_OPTION = 'bw_lead_ai_quality_dismissed';

	/**
	 * The preset windows the dashboard offers.
	 *
	 * Presets only, no custom date picker: each of these answers a question someone
	 * actually asks, and a picker adds two inputs that are mostly used to rebuild
	 * one of these five by hand.
	 *
	 * @return array Range key => translated label.
	 */
	public static function ranges() {
		return array(
			'7'    => __( 'Last 7 days', 'bw-lead-ai' ),
			'30'   => __( 'Last 30 days', 'bw-lead-ai' ),
			'90'   => __( 'Last 90 days', 'bw-lead-ai' ),
			'year' => __( 'This year', 'bw-lead-ai' ),
			'all'  => __( 'All time', 'bw-lead-ai' ),
		);
	}

	/**
	 * The dimension the whole dashboard can be read at.
	 *
	 * One list, in the order the switch offers them, so the aggregate and the screen
	 * cannot disagree about what a valid dimension is: the screen whitelists a query
	 * parameter against these keys and this class validates its own argument against
	 * the same array.
	 *
	 * Channel groups first because they are the default. A channel is already a
	 * friendly name for a source/medium pair, and a group is a friendly name for a
	 * set of channels — so the default view is the one that answers "where do my
	 * leads come from" in the fewest rows, and the three raw dimensions below it are
	 * there for the moment somebody needs to see the tags underneath the labels.
	 *
	 * @return array Dimension key => translated label.
	 */
	public static function dimensions() {
		return array(
			'groups'        => __( 'Channel Groups', 'bw-lead-ai' ),
			'channels'      => __( 'Channels', 'bw-lead-ai' ),
			'source_medium' => __( 'Source / Medium', 'bw-lead-ai' ),
			'source'        => __( 'Source', 'bw-lead-ai' ),
			'medium'        => __( 'Medium', 'bw-lead-ai' ),
		);
	}

	/**
	 * Everything the dashboard needs for one range, in one array.
	 *
	 * Two windows are fetched in a single query — the range itself and the equal
	 * length immediately before it — because every KPI on the strip is a number
	 * with a direction, and a bare number without one is a fact rather than a
	 * signal. They are split apart in PHP rather than by a second query.
	 *
	 * Two of the returned keys are lists that also carry one named entry, which is
	 * worth knowing before iterating them:
	 *
	 *   `weekly`      numeric entries are the week rows; `weekly['channels']` is the
	 *                 stack order (top buckets, plus `Other` when anything fell
	 *                 outside them).
	 *   `transitions` numeric entries are the first→last moves; `['undercredited']`
	 *                 is the single bucket most often losing the credit, or null.
	 *
	 * Two keys carry rows. `rows` is the table at the requested dimension and is
	 * what a screen draws. `channels` is always per-channel whatever was asked for,
	 * because the AI export is per-channel by design and the screen needs the
	 * channel names to tell a collapsed bucket's label from a value somebody could
	 * usefully search for. They are the same rows when the dimension is 'channels'.
	 *
	 * @param string $range_key   One of the keys from ranges(); anything else is
	 *                            treated as the 30-day default.
	 * @param string $attribution 'last' (default) credits each lead to the channel
	 *                            it enquired through; 'first' credits the channel
	 *                            that first found it. Anything else is 'last'.
	 * @param string $dimension   One of the keys from dimensions(): what a row IS.
	 *                            'channels' (default, so every existing caller keeps
	 *                            the aggregate it always got) reads the friendly
	 *                            channel label; the others read the group it belongs
	 *                            to, or the raw source/medium underneath it. Anything
	 *                            else is 'channels'.
	 * @return array
	 */
	public static function build( $range_key, $attribution = 'last', $dimension = 'channels' ) {
		$mode    = ( 'first' === $attribution ) ? 'first' : 'last';
		$dim     = isset( self::dimensions()[ (string) $dimension ] ) ? (string) $dimension : 'channels';
		$range   = self::range( $range_key );
		$compare = self::compare_range( $range );

		// Only the source/medium dimensions read those fields, and only they pay for
		// the nested decode that finding them can cost. See needs_journey().
		$with_source = in_array( $dim, array( 'source', 'medium', 'source_medium' ), true );

		// One query, covering the range AND its comparison window, decoded once.
		$rows    = self::fetch( ( null === $compare ) ? $range['from'] : $compare['from'], $range['to'], $with_source );
		$current = array();
		$earlier = array();
		foreach ( $rows as $row ) {
			// String comparison is safe on 'Y-m-d H:i:s' and saves a strtotime per
			// row: the format sorts lexicographically in the same order it sorts
			// chronologically.
			if ( null === $range['from'] || $row['ts'] >= $range['from'] ) {
				$current[] = $row;
			} else {
				$earlier[] = $row;
			}
		}

		$leads      = count( $current );
		$leads_prev = ( null === $compare ) ? null : count( $earlier );
		$coverage   = self::coverage( $current, $leads );

		// The rules and the memo the dimension needs, resolved once and threaded
		// through every block, so a page cannot end up with its chart bucketed one
		// way and its table another.
		$groups = ( 'groups' === $dim )
			? BW_Lead_AI_Settings::parse_channel_groups( BW_Lead_AI_Settings::get()['channel_groups'] )
			: null;
		$memo     = array();
		$channels = self::dimension_counts( $current, $mode, $dim, $groups, $memo );

		// Per-channel rows are always built; the dimension's own rows are a second
		// pass only when the dimension is not already channels.
		$channel_rows   = self::channel_rows( $current, $leads, $mode );
		$dimension_rows = ( 'channels' === $dim )
			? $channel_rows
			: self::channel_rows( $current, $leads, $mode, $dim, $groups );

		// Top bucket over the named ones only. A journey that never said where it
		// came from cannot win a ranking of where leads come from — it is
		// reported as a data-quality finding instead, which is the honest place for it.
		$top_channel = '';
		$top_leads   = 0;
		foreach ( $channels as $name => $count ) {
			if ( $count > $top_leads ) {
				$top_channel = (string) $name;
				$top_leads   = $count;
			}
		}

		// Evaluated ONCE, and both readings of it come out of the same array below:
		// the whole register for the Data Quality screen, and the findings-only list
		// the AI package has always been handed. Two evaluations would be two chances
		// to disagree about whether something is wrong.
		$quality_checks = self::quality( $current, $leads, $coverage );

		$first_visit      = self::first_visit_share( $current );
		$first_visit_prev = ( null === $compare ) ? null : self::first_visit_share( $earlier );
		$people           = self::people( $current );

		// A comparison window that reaches back before this site had ANY journeys
		// is not a slow quarter, it is a period that did not exist. Reporting
		// "+106%" against it invents a growth story out of the day tracking was
		// switched on — the most confidently wrong number this page could print.
		// Deltas are withheld in that case rather than shown with a caveat: a
		// number on a card gets believed, a footnote does not.
		$partial_compare = false;
		if ( null !== $compare ) {
			$earliest = self::earliest_journey();
			$partial_compare = ( null === $earliest || $earliest > $compare['from'] );
		}

		return array(
			'range'       => $range,
			'compare'     => $compare,
			// Which end of the journey the channel blocks credited, echoed back so
			// a renderer (or a test) reading the array knows what it is looking at
			// without re-deriving it from the request.
			'attribution' => $mode,
			// And what a row IS, for the same reason: every heading, column header
			// and noun on the screen is chosen from this, so a block cannot end up
			// saying "channels" over a table of mediums.
			'dimension'   => $dim,
			'totals'  => array(
				'leads'      => $leads,
				'leads_prev' => $leads_prev,
				// Null rather than 0 when there is nothing to compare against:
				// "up 100% from zero" is not a fact about performance, and "all
				// time" has no preceding window at all.
				'leads_delta_pct'        => ( $partial_compare || null === $leads_prev || 0 === $leads_prev )
					? null
					: (int) round( ( ( $leads - $leads_prev ) / $leads_prev ) * 100 ),
				// Why a delta is missing, so the renderer can say so instead of
				// leaving a card looking half-drawn.
				'compare_partial'        => $partial_compare,
				'top_channel'            => $top_channel,
				'top_channel_leads'      => $top_leads,
				'top_channel_share_pct'  => self::pct( $top_leads, $leads ),
				'first_visit_pct'        => $first_visit,
				'first_visit_pct_prev'   => $partial_compare ? null : $first_visit_prev,
				'repeat_people'          => $people['repeat'],
				'people'                 => $people['total'],
			),
			'weekly'      => self::weekly( $current, $range, $channels, $mode, $dim, $groups, $memo ),
			// The per-CHANNEL rows, always, whatever dimension the page asked for.
			// Two readers need them regardless: the AI export, which is a
			// self-contained artifact and is always per-channel, and the screen,
			// which can only decide whether a collapsed bucket's name is a value
			// anybody could search for by knowing the channel names it collapsed.
			// It also keeps the invariant testable from one aggregate: every
			// bucket's leads are the sum of the channels' leads inside it.
			'channels'    => $channel_rows,
			// The rows at the dimension the page is showing — the same rows when
			// that dimension IS channels, so the common case costs one pass.
			'rows'        => $dimension_rows,
			'transitions' => self::transitions( $current, $mode, $dim, $groups, $memo ),
			'forms'       => self::forms( $current ),
			// Findings only, biggest first — the shape the AI package reads.
			'quality'     => self::quality_findings( $quality_checks ),
			// EVERY check, whether it fired or not, in register order — the shape the
			// Data Quality screen reads, because "this was looked at and it is clean"
			// is a thing only the full register can say.
			'quality_checks' => $quality_checks,
			'coverage'    => $coverage,
			'sparse'      => $leads < self::SPARSE_LEADS,
		);
	}

	// --- the window ---------------------------------------------------------

	/**
	 * Resolve a range key into concrete GMT bounds.
	 *
	 * Bounds are GMT because the columns are, but "this year" is a calendar
	 * statement rather than an elapsed-time one, so it starts at 1 January in the
	 * site's own timezone — which is a different moment from 1 January UTC, by up
	 * to a day's worth of leads.
	 */
	private static function range( $range_key ) {
		$ranges = self::ranges();
		$key    = (string) $range_key;
		if ( ! isset( $ranges[ $key ] ) ) {
			$key = '30';
		}
		$to_ts = time();
		$to    = gmdate( 'Y-m-d H:i:s', $to_ts );

		if ( 'all' === $key ) {
			return array(
				'key'   => 'all',
				'label' => $ranges['all'],
				'from'  => null,
				'to'    => $to,
				'days'  => null,
			);
		}

		if ( 'year' === $key ) {
			$from_ts = (int) strtotime( get_gmt_from_date( current_time( 'Y' ) . '-01-01 00:00:00' ) . ' UTC' );
			$days    = max( 1, (int) ceil( ( $to_ts - $from_ts ) / DAY_IN_SECONDS ) );
		} else {
			$days    = (int) $key;
			$from_ts = $to_ts - ( $days * DAY_IN_SECONDS );
		}

		return array(
			'key'   => $key,
			'label' => $ranges[ $key ],
			'from'  => gmdate( 'Y-m-d H:i:s', $from_ts ),
			'to'    => $to,
			'days'  => $days,
		);
	}

	/**
	 * The equal-length window ending exactly where the range begins.
	 *
	 * Null for "all time" — there is nothing before all time, and inventing an
	 * empty window there would print a meaningless +100% on every KPI.
	 */
	private static function compare_range( $range ) {
		if ( null === $range['from'] || null === $range['days'] ) {
			return null;
		}
		$from_ts = (int) strtotime( $range['from'] . ' UTC' );

		return array(
			'key'   => $range['key'],
			'label' => ( 'year' === $range['key'] )
				? __( 'Previous period', 'bw-lead-ai' )
				: sprintf(
					/* translators: %d: number of days in the comparison window. */
					_n( 'Previous %d day', 'Previous %d days', $range['days'], 'bw-lead-ai' ),
					$range['days']
				),
			'from'  => gmdate( 'Y-m-d H:i:s', $from_ts - ( $range['days'] * DAY_IN_SECONDS ) ),
			'to'    => $range['from'],
			'days'  => $range['days'],
		);
	}

	// --- the one query ------------------------------------------------------

	/**
	 * Every saved journey in the window, decoded once.
	 *
	 * Ordered by nothing on purpose: each block below groups the rows its own way,
	 * so paying MySQL for a sort nobody reads is waste. Expiry is filtered here for
	 * the same reason every other read in the store filters it — a record past its
	 * retention is gone whether or not the prune cron has caught up with it yet.
	 *
	 * @param string|null $from_gmt    Inclusive lower bound, or null for no lower bound.
	 * @param string      $to_gmt      Inclusive upper bound.
	 * @param bool        $with_source Whether source/medium at both ends are needed;
	 *                                 see needs_journey() for why that is a question.
	 * @return array List of resolved journeys.
	 */
	private static function fetch( $from_gmt, $to_gmt, $with_source = false ) {
		if ( ! class_exists( 'BW_Lead_AI_Handoff_Store' ) || ! BW_Lead_AI_Handoff_Store::table_exists() ) {
			return array();
		}

		global $wpdb;
		$table = BW_Lead_AI_Handoff_Store::table_name();

		$where  = array( 'status = %s', 'expires_at > %s', 'COALESCE( confirmed_at, created_at ) <= %s' );
		$params = array( BW_Lead_AI_Handoff_Store::STATUS_SAVED, current_time( 'mysql', true ), (string) $to_gmt );

		if ( null !== $from_gmt ) {
			$where[]  = 'COALESCE( confirmed_at, created_at ) >= %s';
			$params[] = (string) $from_gmt;
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is built from $wpdb->prefix; the WHERE is a fixed list of placeholders and every value is prepared.
				"SELECT lead_id, context, payload, conversions, COALESCE( confirmed_at, created_at ) AS ts
				   FROM {$table}
				  WHERE " . implode( ' AND ', $where ),
				$params
			),
			ARRAY_A
		);

		$out = array();
		foreach ( (array) $rows as $row ) {
			$payload = json_decode( (string) $row['payload'], true );
			$journey = self::resolve( is_array( $payload ) ? $payload : array(), $with_source, self::conversion( $row ) );

			$journey['ts']       = (string) $row['ts'];
			$journey['local_ts'] = self::local_ts( $journey['ts'] );
			$journey['lead_id']  = ( null === $row['lead_id'] ) ? null : (int) $row['lead_id'];
			$journey['form']     = trim( (string) $row['context'] );

			$out[] = $journey;
		}

		return $out;
	}

	/**
	 * Flatten one payload to just the fields the dashboard reads.
	 *
	 * Only these are kept: holding 434 full payloads — each carrying a nested
	 * journey blob with every page view — costs an order of magnitude more memory
	 * than the dozen scalars any block actually looks at.
	 *
	 * The nested `journey` is consulted only for fields the flat keys did not
	 * carry. Which flat datapoints a site STORES is a setting, and the default
	 * stores `journey` plus a handful of others — so a site capturing on its own
	 * domain would report empty channels and zero engagement everywhere if this
	 * read the flat keys alone. On imported history the flat keys are all present
	 * and the second decode never happens.
	 */
	/**
	 * The conversion this row is a lead FOR, or null on a journey stored before
	 * conversions were recorded.
	 *
	 * **The first one, not the newest, and that is the attribution model rather
	 * than a convenience.** What a report of this kind is asked to answer is what
	 * WON the lead — which is everything up to and including the enquiry that
	 * turned a visitor into one. A second enquiry from the same person is not a
	 * second acquisition; somebody using the contact form to ask a follow-up
	 * question has not been won twice, and crediting the channel that happened to
	 * bring them back for it would inflate exactly the paid channels this plugin
	 * exists to keep honest.
	 *
	 * So: first click is what originally brought them to the site, last click is
	 * the source of the session they first enquired in, and every enquiry after
	 * that is recorded and shown on the journey but takes no credit — the same
	 * standing as a pageview. It is also why one person is one journey: a fresh
	 * journey per enquiry would hand out fresh credit each time.
	 *
	 * The row's lead date agrees, being `confirmed_at` — the moment it was first
	 * saved.
	 */
	private static function conversion( $row ) {
		if ( empty( $row['conversions'] ) ) {
			return null;
		}
		$list = json_decode( (string) $row['conversions'], true );
		if ( ! is_array( $list ) || empty( $list[0] ) || ! is_array( $list[0] ) ) {
			return null;
		}
		return $list[0];
	}

	/**
	 * @param array      $payload     The stored payload, decoded.
	 * @param bool       $with_source Whether source/medium at both ends are needed.
	 * @param array|null $conversion  The at-conversion snapshot, where the record
	 *                                has one; see the last-click note below.
	 */
	private static function resolve( $payload, $with_source = false, $conversion = null ) {
		$out = array(
			'channel'       => self::text( $payload, 'channel' ),
			'first_channel' => self::text( $payload, 'first_channel' ),
			'source'        => self::text( $payload, 'source' ),
			'medium'        => self::text( $payload, 'medium' ),
			'first_source'  => self::text( $payload, 'first_source' ),
			'first_medium'  => self::text( $payload, 'first_medium' ),
			'campaign'      => self::text( $payload, 'campaign' ),
			'term'          => self::text( $payload, 'term' ),
			'first_page'    => self::text( $payload, 'first_page' ),
			'visits'        => self::number( $payload, 'visits' ),
			'pages'         => self::number( $payload, 'pages' ),
			// The summary as originally captured. A reclassification pass rewrites
			// channels and keeps the old values under `*_original`, but never
			// touches the summary text — so this is the raw record either way, and
			// the `_original` preference only matters if that ever changes.
			'summary'       => isset( $payload['summary_original'] )
				? (string) $payload['summary_original']
				: self::text( $payload, 'summary' ),
		);

		/*
		 * Last click means the last touch BEFORE the conversion — and the flat
		 * keys above are not that. They are whatever the BROWSER last reported,
		 * and a journey does not stop when the form is sent: in `visit` and
		 * `growing` modes the beacon keeps overwriting them, so a visitor who
		 * enquired off a social post and came back a fortnight later through a
		 * paid ad had that ad credited with a conversion that had already
		 * happened. Paid channels absorbing credit they did not earn is the exact
		 * failure this plugin exists to prevent, and the better the beacon works
		 * the worse it gets.
		 *
		 * So where the record froze its attribution at the moment of the
		 * submission, that reading wins over both the flat keys and the nested
		 * backfill below. A field the snapshot left empty was not known then
		 * either, so it falls through rather than blanking what we do have.
		 *
		 * First click is untouched on purpose: the first visit cannot change.
		 */
		if ( is_array( $conversion ) ) {
			foreach ( array( 'channel', 'source', 'medium', 'campaign', 'term' ) as $key ) {
				if ( isset( $conversion[ $key ] ) && '' !== (string) $conversion[ $key ] ) {
					$out[ $key ] = (string) $conversion[ $key ];
				}
			}
		}

		if ( empty( $payload['journey'] ) || ! self::needs_journey( $out, $with_source ) ) {
			return $out;
		}
		$journey = json_decode( (string) $payload['journey'], true );
		if ( ! is_array( $journey ) ) {
			return $out;
		}

		$first  = ( isset( $journey['first'] ) && is_array( $journey['first'] ) ) ? $journey['first'] : array();
		$last   = ( isset( $journey['last'] ) && is_array( $journey['last'] ) ) ? $journey['last'] : array();
		$counts = ( isset( $journey['counts'] ) && is_array( $journey['counts'] ) ) ? $journey['counts'] : array();

		if ( '' === $out['channel'] ) {
			$out['channel'] = self::text( $last, 'channel' );
		}
		if ( '' === $out['source'] ) {
			$out['source'] = self::text( $last, 'source' );
		}
		if ( '' === $out['medium'] ) {
			$out['medium'] = self::text( $last, 'medium' );
		}
		if ( '' === $out['first_source'] ) {
			$out['first_source'] = self::text( $first, 'source' );
		}
		if ( '' === $out['first_medium'] ) {
			$out['first_medium'] = self::text( $first, 'medium' );
		}
		if ( '' === $out['campaign'] ) {
			$out['campaign'] = self::text( $last, 'campaign' );
		}
		if ( '' === $out['term'] ) {
			$out['term'] = self::text( $last, 'term' );
		}
		if ( '' === $out['first_channel'] ) {
			$out['first_channel'] = self::text( $first, 'channel' );
		}
		if ( '' === $out['first_page'] ) {
			$out['first_page'] = self::text( $first, 'page' );
		}
		if ( null === $out['visits'] ) {
			$out['visits'] = self::number( $counts, 'visits' );
		}
		if ( null === $out['pages'] ) {
			$out['pages'] = self::number( $counts, 'pages' );
		}

		return $out;
	}

	/**
	 * Whether it is worth paying for the nested decode.
	 *
	 * Only the backbone fields are tested, not every field. Campaign and term are
	 * legitimately absent on most journeys on most sites, so testing those would
	 * mean unpacking a page-by-page history for nearly every row to discover it
	 * holds the same nothing the flat keys did. These four are the ones a site that
	 * stores `journey` and little else will be missing, and they are the ones the
	 * nested structure reliably carries.
	 *
	 * Source and medium are tested only when the caller says it is reading them.
	 * `first_source` and `first_medium` are not on the list of flat datapoints a
	 * site can choose to store at all, so testing them unconditionally would force
	 * the nested decode on EVERY row of EVERY report — a cost the channel views,
	 * which never look at them, would pay for nothing. The dimension the page is
	 * showing decides, and only the source/medium views pay.
	 *
	 * @param array $resolved    The flat fields read so far.
	 * @param bool  $with_source Whether source/medium at both ends are being read.
	 */
	private static function needs_journey( $resolved, $with_source = false ) {
		if ( $with_source && (
			'' === $resolved['source']
			|| '' === $resolved['medium']
			|| '' === $resolved['first_source']
			|| '' === $resolved['first_medium']
		) ) {
			return true;
		}

		return '' === $resolved['channel']
			|| '' === $resolved['first_channel']
			|| null === $resolved['visits']
			|| null === $resolved['pages'];
	}

	// --- blocks -------------------------------------------------------------

	/**
	 * Leads per week, split by channel, with no gaps.
	 *
	 * Weeks rather than days: at the volumes this reports on, daily buckets are
	 * noise with a trend hidden somewhere inside them. Empty weeks are emitted
	 * anyway — a bar chart that silently omits a dead week reads as continuous
	 * growth, which is the opposite of what happened.
	 *
	 * Every week carries the same `by_channel` keys in the same order, seeded at
	 * zero, so a stacked bar can be drawn straight from it without the renderer
	 * having to reconcile which channels exist in which week.
	 */
	private static function weekly( $rows, $range, $channel_counts, $mode, $dim = 'channels', $groups = null, &$memo = null ) {
		$charted = array_slice( array_keys( $channel_counts ), 0, self::CHART_CHANNELS );
		$lookup  = array_flip( $charted );
		if ( ! is_array( $memo ) ) {
			$memo = array();
		}

		// "Other" earns its place only if something is actually outside the top
		// buckets — including journeys with no channel at all, which still
		// happened and still belong in the week's total.
		$has_other = false;
		foreach ( $rows as $row ) {
			$channel = self::dimension_value( $row, $mode, $dim, $groups, $memo );
			if ( '' === $channel || ! isset( $lookup[ $channel ] ) ) {
				$has_other = true;
				break;
			}
		}
		$stack = $has_other ? array_merge( $charted, array( self::OTHER ) ) : $charted;

		// Where the chart starts: the range's own beginning, except for "all time",
		// which starts at the oldest journey there is.
		$bounded     = ( null !== $range['from'] );
		$start_local = $bounded ? self::local_ts( $range['from'] ) : null;
		if ( null === $start_local ) {
			foreach ( $rows as $row ) {
				if ( null === $start_local || $row['local_ts'] < $start_local ) {
					$start_local = $row['local_ts'];
				}
			}
		}
		if ( null === $start_local ) {
			return array( 'channels' => $stack );
		}

		$end_local = self::local_ts( $range['to'] );

		/*
		 * On "all time", reach back far enough to be a chart.
		 *
		 * A site whose first lead arrived this week spans ONE week, and one bar is
		 * a number rather than a trend — so the chart withheld itself and the top
		 * of the dashboard was simply empty, which reads as broken rather than as
		 * new. Padding the span shows the same single bar with room around it,
		 * which says "this is the beginning" instead of saying nothing.
		 *
		 * Only when the range has no stated start. Extending a BOUNDED range would
		 * draw weeks the rest of the page is not counting, and a chart that
		 * disagrees with the table under it is worse than no chart.
		 */
		if ( ! $bounded ) {
			$floor = $end_local - ( ( self::MIN_CHART_WEEKS - 1 ) * WEEK_IN_SECONDS );
			if ( $start_local > $floor ) {
				$start_local = $floor;
			}
		}
		$seed      = array_fill_keys( $stack, 0 );
		$weeks     = array();
		$cursor    = self::week_start( $start_local );
		$guard     = 0;

		while ( $cursor <= $end_local && $guard < self::MAX_WEEKS ) {
			$weeks[ gmdate( 'o-\WW', $cursor ) ] = array(
				'week'  => gmdate( 'o-\WW', $cursor ),
				// Formatted from a wall-clock-as-UTC timestamp, so date_i18n is told
				// this is already GMT — otherwise it would add the site's offset a
				// second time and drift the label by a day.
				'label'      => date_i18n( 'j M', $cursor, true ),
				'total'      => 0,
				'by_channel' => $seed,
			);
			$cursor += WEEK_IN_SECONDS;
			$guard++;
		}

		foreach ( $rows as $row ) {
			$key = gmdate( 'o-\WW', $row['local_ts'] );
			if ( ! isset( $weeks[ $key ] ) ) {
				continue; // Outside the enumerated span — only reachable via the guard.
			}
			$credited = self::dimension_value( $row, $mode, $dim, $groups, $memo );
			$channel  = ( '' !== $credited && isset( $lookup[ $credited ] ) ) ? $credited : self::OTHER;
			$weeks[ $key ]['total']++;
			if ( isset( $weeks[ $key ]['by_channel'][ $channel ] ) ) {
				$weeks[ $key ]['by_channel'][ $channel ]++;
			}
		}

		$out             = array_values( $weeks );
		$out['channels'] = $stack;
		return $out;
	}

	/**
	 * One row per credited channel: volume, and the engagement that volume hides.
	 *
	 * Volume alone is a trap — it rewards whatever is biggest rather than whatever
	 * is best. The engagement columns are what let someone see that a small channel
	 * sends people who read five pages before enquiring while a large one sends
	 * people who read one, which is a materially different lead and a materially
	 * different budget decision.
	 *
	 * `share_pct` is a share of ALL leads in the range, not of the classified ones,
	 * so the shares deliberately fall short of 100% by exactly the proportion of
	 * journeys that never said where they came from. That gap is named in Data
	 * Quality rather than quietly redistributed across the channels that did.
	 *
	 * Two things carry the OTHER end of the journey, because a single-ended table
	 * is the lie each attribution mode tells:
	 *
	 * `assists` is the row channel's uncredited role at the other end. Under
	 * last-click it counts the journeys this channel FOUND that converted through
	 * something else — the current "Assisted" column. Under first-click it counts
	 * the journeys this channel CLOSED that something else found. Same shape, same
	 * key, mirrored meaning: always the credit this mode is not giving.
	 *
	 * `breakdown` lists the row's leads whose journey had a second act, for the
	 * expandable subrows: every `switch` entry names a differing other-end channel,
	 * then one `same` entry for returners who came back through the same channel
	 * (loyalty, not triviality), then one `unknown` entry for journeys that never
	 * recorded the other end. Single-visit journeys — ends matching because there
	 * was only one touch — are deliberately NOT a line: they are the row's
	 * remainder, and a line restating the bulk is clutter, not information.
	 * `pct` is the share OF THIS ROW, not of the range.
	 *
	 * @param array      $rows   Resolved journeys in the window.
	 * @param int        $total  Leads in the window, for `share_pct`.
	 * @param string     $mode   'first' or 'last' — which end is credited.
	 * @param string     $dim    A key from dimensions(). 'channels' is the friendly
	 *                           channel label; anything else reads exactly the same
	 *                           journeys at another granularity, so every column —
	 *                           leads, share, median pages, came-back, assists — is
	 *                           RECOMPUTED over that bucket's own leads rather than
	 *                           summed or averaged from per-channel rows. Medians in
	 *                           particular cannot be summed, so this is the only
	 *                           correct way to produce them.
	 * @param array|null $groups Channel-group rules from
	 *                           BW_Lead_AI_Settings::parse_channel_groups(), needed
	 *                           only when $dim is 'groups'.
	 */
	private static function channel_rows( $rows, $total, $mode, $dim = 'channels', $groups = null ) {
		$other_mode = ( 'first' === $mode ) ? 'last' : 'first';
		$acc        = array();
		$cross      = array();
		$memo       = array();

		foreach ( $rows as $row ) {
			// The dimension is applied to BOTH ends before anything is counted,
			// which is what makes a collapsed bucket's numbers honest rather than
			// merely aggregated. A journey that arrived via Google Ads and enquired
			// via Bing Ads has switched channel but not group — and a journey that
			// arrived via Google Ads and enquired via Google Organic has switched
			// channel but not source. Mapped first, the two ends compare equal, so
			// neither is counted as a cross-bucket assist and neither appears as a
			// switch subrow. Summing per-channel assists into buckets instead would
			// count those journeys twice and inflate the column.
			$channel = self::dimension_value( $row, $mode, $dim, $groups, $memo );
			$other   = self::dimension_value( $row, $other_mode, $dim, $groups, $memo );

			// The uncredited role: the touch at the end this mode does not pay.
			// It is what makes the credited column readable as a budget number
			// rather than as the whole truth.
			if ( '' !== $other && '' !== $channel && $other !== $channel ) {
				$cross[ $other ] = isset( $cross[ $other ] ) ? $cross[ $other ] + 1 : 1;
			}

			// Only the ROW's own end is bucketed when it is unplaceable, and only
			// for the dimensions that bucket at all — this is the table whose rows
			// have to add up to the lead count. The other end stays raw, so a
			// journey with nothing recorded at the far end reports "no first click
			// recorded" rather than a fabricated return trip through an unknown.
			if ( '' === $channel ) {
				$channel = self::unknown_label( $dim );
			}
			if ( '' === $channel ) {
				continue;
			}
			if ( ! isset( $acc[ $channel ] ) ) {
				$acc[ $channel ] = array( 'leads' => 0, 'pages' => array(), 'visits_n' => 0, 'multi' => 0, 'ends' => array() );
			}
			$acc[ $channel ]['leads']++;
			if ( null !== $row['pages'] ) {
				$acc[ $channel ]['pages'][] = (int) $row['pages'];
			}
			if ( null !== $row['visits'] ) {
				$acc[ $channel ]['visits_n']++;
				if ( $row['visits'] > 1 ) {
					$acc[ $channel ]['multi']++;
				}
			}

			// Bucket this journey's other end for the breakdown. NUL-prefixed keys
			// for the special buckets so a real channel named "same" cannot
			// collide with them — the same trick transitions() uses for its pairs.
			// Matching ends split on the visit count: with one visit the ends are
			// the same because nothing else could have happened; only a returner
			// has genuinely chosen the same channel twice. An unknown visit count
			// cannot claim a return trip, so it lands with the single visits.
			if ( $other === $channel ) {
				$end = ( null !== $row['visits'] && $row['visits'] > 1 ) ? "\x00same" : "\x00single";
			} elseif ( '' === $other ) {
				$end = "\x00unknown";
			} else {
				$end = $other;
			}
			$acc[ $channel ]['ends'][ $end ] = isset( $acc[ $channel ]['ends'][ $end ] ) ? $acc[ $channel ]['ends'][ $end ] + 1 : 1;
		}

		$out = array();
		foreach ( $acc as $channel => $a ) {
			$out[] = array(
				'channel'   => (string) $channel,
				'leads'     => $a['leads'],
				'share_pct' => self::pct( $a['leads'], $total ),
				'avg_pages' => self::median( $a['pages'] ),
				'multi_pct' => self::pct( $a['multi'], $a['visits_n'] ),
				'assists'   => isset( $cross[ $channel ] ) ? $cross[ $channel ] : 0,
				'breakdown' => self::breakdown( $a['ends'], $a['leads'] ),
			);
		}

		// A channel with no other-end appearances still deserves a row if it was
		// credited with anything; a channel that ONLY ever appears at the other end
		// has no row here at all, and shows up in `transitions` instead. That is
		// the right split — this table is about what this mode credits.
		usort( $out, array( __CLASS__, 'by_leads_desc' ) );
		return $out;
	}

	/**
	 * One channel label mapped to its group, memoised across a whole pass.
	 *
	 * A range holds a few dozen distinct channel labels and potentially tens of
	 * thousands of journeys, each of which asks about two ends. Walking the rule
	 * list 2n times to answer a few dozen distinct questions is the kind of cost
	 * that only shows up on the site with the most data — the one least able to
	 * absorb it — so the answers are cached per call.
	 *
	 * @param string $channel Channel label as stored.
	 * @param array  $groups  Rules from BW_Lead_AI_Settings::parse_channel_groups().
	 * @param array  $memo    Passed by reference; the cache for this pass.
	 * @return string
	 */
	private static function grouped( $channel, $groups, &$memo ) {
		$channel = (string) $channel;
		if ( '' === $channel ) {
			return '';
		}
		if ( ! isset( $memo[ $channel ] ) ) {
			$memo[ $channel ] = BW_Lead_AI_Settings::channel_group_label( $channel, $groups );
		}
		return $memo[ $channel ];
	}

	/**
	 * A row's leads partitioned by their journey's other end, subrow-ready.
	 *
	 * Differing channels first, biggest first, because they are the reason the
	 * expander exists; then the came-back-same remainder, then the unknown
	 * remainder. The single-visit bucket is counted during bucketing (it keeps
	 * `same` honest — returners only) but emits no entry: the subrows surface
	 * what happened BETWEEN the ends, and on a single visit nothing did.
	 */
	private static function breakdown( $ends, $row_leads ) {
		$switches = array();
		foreach ( $ends as $end => $count ) {
			if ( "\x00single" === $end || "\x00same" === $end || "\x00unknown" === $end ) {
				continue;
			}
			$switches[] = array(
				'kind'    => 'switch',
				'channel' => (string) $end,
				'count'   => $count,
				'pct'     => self::pct( $count, $row_leads ),
			);
		}
		usort( $switches, array( __CLASS__, 'by_count_desc' ) );

		foreach ( array( "\x00same" => 'same', "\x00unknown" => 'unknown' ) as $end => $kind ) {
			if ( isset( $ends[ $end ] ) ) {
				$switches[] = array(
					'kind'    => $kind,
					'channel' => '',
					'count'   => $ends[ $end ],
					'pct'     => self::pct( $ends[ $end ], $row_leads ),
				);
			}
		}

		return $switches;
	}

	/**
	 * First touch → converting touch, for the journeys that changed channel.
	 *
	 * This is the block a marketer cannot get from their form notifications: search
	 * finds someone, they come back weeks later and convert as Untracked, and under
	 * last-click the search that found them earns none of the credit. Defunding it
	 * on that basis is a budget decision made on a false premise.
	 *
	 * The pairs are the same whichever end is being credited — a switched journey
	 * has exactly one first and one converting touch. What the mode changes is who
	 * is losing: `undercredited` names the FIRST-touch channel under last-click
	 * (it found people someone else got paid for) and the CONVERTING channel under
	 * first-click (it closed people someone else got paid for).
	 *
	 * Both touches must be present and must differ. That condition is not
	 * defensive tidiness — this figure was once fabricated at more than four times
	 * its real size by a pass that reclassified the converting touch and left the
	 * first one holding a raw hostname, turning same-channel journeys into phantom
	 * switches. A transition is only real when both ends went through the same
	 * classification.
	 *
	 * "Differ" is judged at the dimension the page is showing, for the same reason
	 * the performance table judges it there: a journey that moved from Google Ads
	 * to Google Organic changed channel but not source, and listing it as a move
	 * on a page reading sources would count a switch the reader can see did not
	 * happen. Both ends are mapped before they are compared, never after.
	 *
	 * Unlike the table above, an unplaceable end is skipped rather than bucketed:
	 * a row here asserts that one thing found somebody and another closed them,
	 * and "unknown found them" is not that assertion.
	 */
	private static function transitions( $rows, $mode, $dim = 'channels', $groups = null, &$memo = null ) {
		$pairs  = array();
		$losers = array();
		if ( ! is_array( $memo ) ) {
			$memo = array();
		}

		foreach ( $rows as $row ) {
			$from = self::dimension_value( $row, 'first', $dim, $groups, $memo );
			$to   = self::dimension_value( $row, 'last', $dim, $groups, $memo );
			if ( '' === $from || '' === $to || $from === $to ) {
				continue;
			}
			$key           = $from . "\x00" . $to;
			$pairs[ $key ] = isset( $pairs[ $key ] ) ? $pairs[ $key ] + 1 : 1;

			$loser            = ( 'first' === $mode ) ? $to : $from;
			$losers[ $loser ] = isset( $losers[ $loser ] ) ? $losers[ $loser ] + 1 : 1;
		}

		$out = array();
		foreach ( $pairs as $key => $count ) {
			list( $from, $to ) = explode( "\x00", $key, 2 );
			$out[]             = array( 'from' => $from, 'to' => $to, 'count' => $count );
		}
		usort( $out, array( __CLASS__, 'by_count_desc' ) );

		// The plain sentence the block is built to support: one channel, named,
		// that keeps doing work someone else gets the credit for.
		$undercredited = null;
		foreach ( $losers as $channel => $count ) {
			if ( null === $undercredited || $count > $undercredited['count'] ) {
				$undercredited = array( 'channel' => (string) $channel, 'count' => $count );
			}
		}

		$out['undercredited'] = $undercredited;
		return $out;
	}

	/**
	 * Leads and engagement per form.
	 *
	 * Two forms on one site routinely pull measurably different quality of lead,
	 * and nothing else on the page would show it. A site with a single form gets a
	 * single row, which is honest and costs nothing — the screen decides whether
	 * one row is worth drawing.
	 */
	private static function forms( $rows ) {
		$acc = array();

		foreach ( $rows as $row ) {
			// A journey saved without a context still converted somewhere. Bucketing
			// it visibly beats dropping it and leaving the form totals short of the
			// lead count with no explanation.
			$form = ( '' === $row['form'] ) ? __( 'Unknown form', 'bw-lead-ai' ) : $row['form'];
			if ( ! isset( $acc[ $form ] ) ) {
				$acc[ $form ] = array( 'leads' => 0, 'visits' => array(), 'pages' => array() );
			}
			$acc[ $form ]['leads']++;
			if ( null !== $row['visits'] ) {
				$acc[ $form ]['visits'][] = (int) $row['visits'];
			}
			if ( null !== $row['pages'] ) {
				$acc[ $form ]['pages'][] = (int) $row['pages'];
			}
		}

		$out = array();
		foreach ( $acc as $form => $a ) {
			$out[] = array(
				'form'       => (string) $form,
				'leads'      => $a['leads'],
				'avg_visits' => self::median( $a['visits'] ),
				'avg_pages'  => self::median( $a['pages'] ),
			);
		}
		usort( $out, array( __CLASS__, 'by_leads_desc' ) );
		return $out;
	}

	/**
	 * The register of data-quality checks: every question this screen asks of the
	 * measurement, declared once.
	 *
	 * Declared rather than implied. Until this existed a check WAS its `if` block —
	 * it came into being by firing — so a reader could not tell "nothing is wrong"
	 * from "nothing is being looked at", and the only way to find out what the plugin
	 * watches for was to read this file. The register is what turns an empty result
	 * into a statement.
	 *
	 * `watches` is the plain-language description shown WHETHER OR NOT the check
	 * fired: what it looks for, and what it would cost if it were happening. It is
	 * written for somebody who has never seen the finding, which is the whole point —
	 * on a healthy site nobody ever will.
	 *
	 * ONE SOURCE, and the key is what makes it one: quality() calls
	 * `self::quality_<key>()` for every entry and assembles the result from what
	 * comes back. There is no second list of checks to drift from this one — not in
	 * the aggregate, not in the screen, not in the export. Adding a check is this
	 * entry plus the method it names; tests/menu-routing.php fails an entry with no
	 * detector.
	 *
	 * @return array key => array( 'label' => string, 'watches' => string )
	 */
	public static function quality_checks() {
		return array(
			'self_referral'      => array(
				'label'   => __( 'A property you own still counts as a traffic source', 'bw-lead-ai' ),
				'watches' => __( 'A host your "internal" row (Medium Mappings) claims as yours, from journeys stored before that rule existed. Reprocess relabels them Own Property.', 'bw-lead-ai' ),
			),
			'own_property_source' => array(
				'label'   => __( 'Own Property leads carry no original source', 'bw-lead-ai' ),
				'watches' => __( 'Leads that reached you from a property you own. The shared-property snippet (Cross-domain) carries the source that first found them across.', 'bw-lead-ai' ),
			),
			'no_channel'         => array(
				'label'   => __( 'Leads with no channel', 'bw-lead-ai' ),
				'watches' => __( 'Nothing recorded how these visitors arrived, so they sit outside every share on the Reports tab.', 'bw-lead-ai' ),
			),
			'no_campaign'        => array(
				'label'   => __( 'No campaign names from the ad platform', 'bw-lead-ai' ),
				'watches' => __( 'Paid visits with no campaign name. Nothing breaks down by campaign until the ad platform passes one. Organic-only sites are not flagged.', 'bw-lead-ai' ),
			),
			'unmatched_referrer' => array(
				'label'   => __( 'Referrers with no rule', 'bw-lead-ai' ),
				'watches' => __( 'Channels still showing as a bare hostname. A rule in Medium Mappings files each one in its proper channel.', 'bw-lead-ai' ),
			),
			'malformed_medium'   => array(
				'label'   => __( 'Mediums that cannot be real', 'bw-lead-ai' ),
				'watches' => __( 'A one- or two-letter medium, usually an ad template firing before its value fills in. A channel built from it matches no mapping rule.', 'bw-lead-ai' ),
			),
			'placeholder_values' => array(
				'label'   => __( 'Unresolved placeholders in the tracking tags', 'bw-lead-ai' ),
				'watches' => __( 'A tag arrived as literal text like {CampaignName}. Something upstream is sending the template, not the value.', 'bw-lead-ai' ),
			),
		);
	}

	/**
	 * Every check in the register, evaluated against this window.
	 *
	 * Returns the WHOLE register, each entry carrying what it found — `fired` false
	 * and a zero count when it found nothing. That is the shape the Data Quality
	 * screen needs to be able to say "this was looked at and it is clean", and it is
	 * the only shape from which both that screen and the findings-only list below can
	 * be derived without a second pass or a second list.
	 *
	 * Deliberately last on the page it feeds and deliberately not hidden. Most
	 * analytics tools present broken measurement as confident numbers; telling
	 * somebody their ad platform is not passing campaign names is worth more than any
	 * chart on the Reports tab, because it is a thing they can go and fix this
	 * afternoon.
	 *
	 * @return array key => finding, in register order.
	 */
	private static function quality( $rows, $total, $coverage ) {
		$out = array();

		foreach ( self::quality_checks() as $key => $meta ) {
			// The register key IS the detector name. Guarded rather than trusted so a
			// mis-typed entry degrades to "clear" on a customer's screen instead of
			// fatalling their Reports page; the test is what refuses to let that ship.
			$detect = array( __CLASS__, 'quality_' . $key );
			$hit    = is_callable( $detect ) ? call_user_func( $detect, $rows, $total, $coverage ) : null;
			$count  = ( is_array( $hit ) && isset( $hit['count'] ) ) ? (int) $hit['count'] : 0;

			$out[ $key ] = array(
				'key'     => $key,
				'label'   => $meta['label'],
				'watches' => $meta['watches'],
				'fired'   => ( $count > 0 ),
				'count'   => $count,
				'pct'     => ( $count > 0 ) ? self::pct( $count, $total ) : 0,
				'detail'  => ( is_array( $hit ) && isset( $hit['detail'] ) ) ? (string) $hit['detail'] : '',
			);
		}

		return $out;
	}

	/**
	 * Only the checks that found something, biggest first.
	 *
	 * The historical shape of `quality`, kept exactly as it was because two readers
	 * outside this file depend on it — the AI Analysis package quotes the findings
	 * verbatim, and its campaign and search-term sections look one up by key. Derived
	 * from the evaluated register rather than built alongside it, so a finding cannot
	 * appear in one and not the other.
	 *
	 * @param array $checks Output of quality().
	 * @return array A numerically indexed list.
	 */
	public static function quality_findings( $checks ) {
		$out = array();
		foreach ( (array) $checks as $check ) {
			if ( ! empty( $check['fired'] ) ) {
				$out[] = $check;
			}
		}
		usort( $out, array( __CLASS__, 'by_count_desc' ) );

		return $out;
	}

	// --- the detectors, one per register entry ------------------------------
	//
	// Public, deliberately. quality() reaches each of these by NAME, through
	// is_callable() — so "can this detector be called" has to be the same question
	// for the loop and for the test that guards the register. A private detector is
	// callable from inside quality() and invisible to tests/menu-routing.php, which
	// would leave the one failure mode that matters — a register entry whose
	// detector was renamed, reading "clear" forever — undetectable by the very
	// check written to catch it.

	/**
	 * A declared owned host still stored as a referral channel.
	 *
	 * Live derivation folds any host the `internal` row claims into the `internal`
	 * medium — labelled Own Property — as the visit arrives. So the only way an
	 * owned host can sit in the stored history AS a channel is time: the journey
	 * was stored before the rule covered that host, and nobody has reprocessed
	 * since. That is exactly the situation the reprocess button exists for, and
	 * this check is the thing on screen that notices it.
	 *
	 * Ownership is decided by BW_Lead_AI_Settings::is_owned_host() — the same
	 * matcher live capture and reprocess use — never by a registrable-domain
	 * guess. The earlier version of this check substring-matched against the
	 * approximate registrable domain, which on any shared parent domain reported a
	 * NEIGHBOUR'S site as the owner's own traffic; that reduction is the one
	 * own_hostname() refuses, and now nothing in the plugin performs it.
	 */
	public static function quality_self_referral( $rows, $total, $coverage ) {
		$owned = BW_Lead_AI_Settings::owned_hosts();
		$names = array();
		$count = 0;
		foreach ( $rows as $row ) {
			// Hostname-shaped channels only: a friendly label ("Some Co Ads") is a
			// mapping rule's work and cannot be a referring host.
			if ( '' === $row['channel'] || ! self::looks_like_hostname( $row['channel'] ) ) {
				continue;
			}
			if ( BW_Lead_AI_Settings::is_owned_host( $row['channel'], $owned ) ) {
				$count++;
				$names[ $row['channel'] ] = true;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => sprintf(
				/* translators: %s: comma-separated list of hostnames the internal row claims. */
				__( 'Credited to %s, a host your "internal" row claims as yours. Reprocess (Journey Storage) relabels these Own Property.', 'bw-lead-ai' ),
				implode( ', ', array_keys( $names ) )
			),
		);
	}

	/**
	 * Own Property leads: the journey begins on an owned property and the original
	 * source did not travel with the visitor. Fires wherever there is Own Property
	 * traffic at all, because the statement is true in both states that produce it
	 * — the shared-property code is missing from that property, or the visitor
	 * genuinely arrived there with nothing to carry — and the reader cannot tell
	 * which from the report alone. The detail says how to close the gap; once the
	 * code is installed, recovered visitors land in their true channel and this
	 * count falls on its own.
	 *
	 * Counted on the converting medium, so the figure equals the Own Property row
	 * on the Reports tab rather than introducing a second, slightly different
	 * number for the same idea. `internal` is structural — it is the row key the
	 * owned-host derivation reads — so this does not break if a site renames the
	 * Own Property channel label.
	 */
	public static function quality_own_property_source( $rows, $total, $coverage ) {
		$count = 0;
		foreach ( $rows as $row ) {
			if ( 'internal' === strtolower( $row['medium'] ) ) {
				$count++;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => __( 'These reached you from a property you own. Add the shared-property snippet (Cross-domain) to carry the original source across.', 'bw-lead-ai' ),
		);
	}

	/** Journeys that never said where they came from. */
	public static function quality_no_channel( $rows, $total, $coverage ) {
		$count = 0;
		foreach ( $rows as $row ) {
			if ( '' === $row['channel'] ) {
				$count++;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => __( 'Nothing recorded how these visitors arrived, so they sit outside every share on the Reports tab.', 'bw-lead-ai' ),
		);
	}

	/**
	 * Paid clicks arriving without a campaign name.
	 *
	 * Only worth saying when the field is essentially empty AND there is paid traffic
	 * that should have filled it. An organic-only site legitimately has no campaigns,
	 * and telling it so every week would be noise.
	 */
	public static function quality_no_campaign( $rows, $total, $coverage ) {
		if ( $coverage['campaign'] >= self::CAMPAIGN_COVERAGE_FLOOR ) {
			return null;
		}
		$count = 0;
		foreach ( $rows as $row ) {
			if ( self::looks_paid( $row ) && '' === $row['campaign'] ) {
				$count++;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => __( 'These paid visits carried no campaign name, so nothing breaks down by campaign. Check the ad platform\'s tracking template.', 'bw-lead-ai' ),
		);
	}

	/**
	 * Referrers no classification rule matched.
	 *
	 * The generalisation of a real failure: a broken hostname matcher meant every
	 * organic search visit was stored under its raw hostname for months, and the
	 * numbers looked plausible the whole time. The specific bug is fixed; the shape
	 * of it is not detectable by fixing bugs, only by asking the question every time.
	 * A channel that is still a bare hostname means no rule claimed it — sometimes
	 * correct (a genuine referring site), often a rule that needs writing.
	 */
	public static function quality_unmatched_referrer( $rows, $total, $coverage ) {
		$owned = BW_Lead_AI_Settings::owned_hosts();
		$hosts = array();
		$count = 0;
		foreach ( $rows as $row ) {
			// Owned hosts are the self-referral check's finding, not this one's —
			// decided by the same matcher, so a lead lands in exactly one of the two.
			if ( '' === $row['channel'] || BW_Lead_AI_Settings::is_owned_host( $row['channel'], $owned ) ) {
				continue;
			}
			if ( self::looks_like_hostname( $row['channel'] ) ) {
				$count++;
				$hosts[ $row['channel'] ] = isset( $hosts[ $row['channel'] ] ) ? $hosts[ $row['channel'] ] + 1 : 1;
			}
		}
		if ( 0 === $count ) {
			return null;
		}
		arsort( $hosts );

		return array(
			'count'  => $count,
			'detail' => sprintf(
				/* translators: %s: comma-separated hostnames. */
				__( 'Bare hostnames (%s) that no Medium Mappings rule matched. Add a rule so each lands in its proper channel.', 'bw-lead-ai' ),
				implode( ', ', array_slice( array_keys( $hosts ), 0, 3 ) )
			),
		);
	}

	/**
	 * Source/medium that cannot be right.
	 *
	 * A single-letter medium is what arrives when an ad platform's template fires
	 * before its value is substituted. It cannot be a real medium, and a channel
	 * built from it will never match a mapping rule.
	 */
	public static function quality_malformed_medium( $rows, $total, $coverage ) {
		$names = array();
		$count = 0;
		foreach ( $rows as $row ) {
			$medium = isset( $row['medium'] ) ? (string) $row['medium'] : '';
			if ( '' !== $medium && strlen( $medium ) <= 2 && ! in_array( strtolower( $medium ), self::SHORT_MEDIUMS, true ) ) {
				$count++;
				$label           = ( '' !== $row['channel'] ) ? $row['channel'] : $medium;
				$names[ $label ] = true;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => sprintf(
				/* translators: %s: comma-separated channel names. */
				__( 'A medium like %s cannot be real — an ad template fired before its value filled in. These match no channel rule.', 'bw-lead-ai' ),
				implode( ', ', array_slice( array_keys( $names ), 0, 3 ) )
			),
		);
	}

	/** Literal {placeholders} where a value should be. */
	public static function quality_placeholder_values( $rows, $total, $coverage ) {
		$count = 0;
		foreach ( $rows as $row ) {
			if ( self::has_placeholder( $row['summary'] ) ) {
				$count++;
			}
		}
		if ( 0 === $count ) {
			return null;
		}

		return array(
			'count'  => $count,
			'detail' => __( 'A tag arrived as a literal placeholder like {CampaignName} instead of a value, so it saved empty.', 'bw-lead-ai' ),
		);
	}

	// --- dismissals ---------------------------------------------------------

	/**
	 * How far a dismissed finding's share may grow before the dismissal lapses.
	 *
	 * Percentage POINTS, not leads, and that choice is the whole design. A count is
	 * meaningless across windows — the same problem is 78 leads on thirty days and
	 * 300 on a year, so a count-based rule would lapse every dismissal the first time
	 * somebody widened the date range. A share is the same measurement whatever the
	 * window, so growth in it is growth in the problem.
	 */
	const DISMISS_REGROWTH_POINTS = 5;

	/**
	 * Findings the owner has said they know about.
	 *
	 * Its OWN option, never a key inside the settings option, for two reasons that
	 * both matter: it is per-site operational state rather than configuration, so it
	 * must not travel in a settings export to another site (a dismissal is a
	 * statement about THIS site's traffic); and a settings save must not be able to
	 * silently clear it, nor a factory reset to quietly un-hide six warnings.
	 *
	 * @return array key => array( pct, count, range, time, user )
	 */
	public static function quality_dismissals() {
		$stored = get_option( self::QUALITY_DISMISS_OPTION, array() );
		if ( ! is_array( $stored ) ) {
			return array();
		}
		$checks = self::quality_checks();
		$out    = array();
		foreach ( $stored as $key => $record ) {
			// Bounded by the register: a key that is no longer a check is dropped on
			// read, so a renamed check cannot leave an undismissable ghost behind.
			if ( ! isset( $checks[ $key ] ) || ! is_array( $record ) ) {
				continue;
			}
			$out[ $key ] = array(
				'pct'   => isset( $record['pct'] ) ? (int) $record['pct'] : 0,
				'count' => isset( $record['count'] ) ? (int) $record['count'] : 0,
				'range' => isset( $record['range'] ) ? (string) $record['range'] : '',
				'time'  => isset( $record['time'] ) ? (string) $record['time'] : '',
				'user'  => isset( $record['user'] ) ? (int) $record['user'] : 0,
			);
		}

		return $out;
	}

	/**
	 * Record that somebody has seen a finding and accepted it as it stands.
	 *
	 * The situation is stored with the dismissal, not just the fact of it. Dismissing
	 * a warning is a statement about a state of affairs — "12% of leads, I know" —
	 * and without the number there is nothing to compare against later, so a problem
	 * that trebled would stay hidden behind a decision made about a smaller one.
	 *
	 * @param string $key       A key from quality_checks().
	 * @param int    $count     Leads affected at the moment of dismissal.
	 * @param int    $pct       Their share, in whole percent.
	 * @param string $range_key The window it was dismissed from, for the audit line.
	 * @return bool Whether the key was a real check.
	 */
	public static function quality_dismiss( $key, $count, $pct, $range_key = '' ) {
		$checks = self::quality_checks();
		if ( ! isset( $checks[ $key ] ) ) {
			return false;
		}
		$all         = self::quality_dismissals();
		$all[ $key ] = array(
			'pct'   => max( 0, min( 100, (int) $pct ) ),
			'count' => max( 0, (int) $count ),
			'range' => sanitize_key( (string) $range_key ),
			'time'  => current_time( 'mysql', true ),
			'user'  => get_current_user_id(),
		);
		update_option( self::QUALITY_DISMISS_OPTION, $all, false );

		return true;
	}

	/** Undo a dismissal. Returns whether there was one to undo. */
	public static function quality_undismiss( $key ) {
		$all = self::quality_dismissals();
		if ( ! isset( $all[ $key ] ) ) {
			return false;
		}
		unset( $all[ $key ] );
		// Undoing the last dismissal removes the row rather than storing an empty
		// array: a site that never dismissed anything and a site that dismissed one
		// thing and changed its mind should leave the same trace, which is none.
		if ( empty( $all ) ) {
			delete_option( self::QUALITY_DISMISS_OPTION );
		} else {
			update_option( self::QUALITY_DISMISS_OPTION, $all, false );
		}

		return true;
	}

	/**
	 * The evaluated register with each check's dismissal state resolved.
	 *
	 * Four states, and the fourth is the one that stops this feature lying:
	 *
	 *   clear     — the check ran and found nothing.
	 *   active    — it found something and nobody has dismissed it.
	 *   dismissed — it found something no bigger than what was dismissed.
	 *   lapsed    — it found something MEASURABLY bigger than what was dismissed, so
	 *               the dismissal no longer covers it and the finding counts as
	 *               active again, carrying both numbers so the growth is on screen.
	 *
	 * Without `lapsed`, dismissing a warning at 12% would go on hiding it at 40% and
	 * the screen would be quietly reassuring somebody about a problem that had
	 * trebled — which is the exact failure this whole tab exists to refuse.
	 *
	 * A dismissal on a check that is now clear is kept, not deleted: the same problem
	 * returning at the same size is still the thing that was dismissed, and deleting
	 * the record would make the owner dismiss it again every time it flickered.
	 *
	 * @param array $checks Output of quality(), i.e. $data['quality_checks'].
	 * @return array The same list, each entry gaining 'state' and 'dismissal'.
	 */
	public static function quality_with_dismissals( $checks ) {
		$dismissed = self::quality_dismissals();
		$out       = array();

		foreach ( (array) $checks as $key => $check ) {
			$record = isset( $dismissed[ $key ] ) ? $dismissed[ $key ] : null;

			if ( empty( $check['fired'] ) ) {
				$state = 'clear';
			} elseif ( null === $record ) {
				$state = 'active';
			} else {
				$state = ( (int) $check['pct'] - (int) $record['pct'] >= self::DISMISS_REGROWTH_POINTS )
					? 'lapsed'
					: 'dismissed';
			}

			$check['state']      = $state;
			$check['dismissal']  = $record;
			$out[ $key ]         = $check;
		}

		return $out;
	}

	/** Does this state mean the reader still has to look at it? */
	public static function quality_needs_attention( $state ) {
		return in_array( $state, array( 'active', 'lapsed' ), true );
	}

	/**
	 * How much of each field is actually there.
	 *
	 * The dashboard uses this to decide whether a block can be drawn at all: a
	 * ranking built on a tenth of the data still reads as authoritative, so a thin
	 * field is better reported as a gap than rendered as a table.
	 */
	private static function coverage( $rows, $total ) {
		$fields = array(
			'channel',
			'first_channel',
			// The raw pair under the channel labels, so the source/medium views can
			// be gated on the field they actually rank by rather than on the
			// channel's coverage, which is a different question with a different
			// answer on any site whose history was imported. Measured over the
			// fields AS READ for this aggregate: a channel-dimension build does not
			// pay for the nested decode that finds a missing first_source, so its
			// four source figures are a floor, and only the source dimensions —
			// which do pay for it — gate on them.
			'source',
			'medium',
			'first_source',
			'first_medium',
			'campaign',
			'term',
			'first_page',
			'visits',
		);
		$counts = array_fill_keys( $fields, 0 );

		foreach ( $rows as $row ) {
			foreach ( $fields as $field ) {
				if ( null !== $row[ $field ] && '' !== $row[ $field ] ) {
					$counts[ $field ]++;
				}
			}
		}

		$out = array();
		foreach ( $fields as $field ) {
			$out[ $field ] = self::pct( $counts[ $field ], $total );
		}
		return $out;
	}

	// --- shared measures ----------------------------------------------------

	/**
	 * One end of one journey, as the bucket the chosen dimension puts it in.
	 *
	 * THE generic step. Every block that groups journeys — the counts behind the
	 * KPI card, the weekly stack, the performance table, the transitions — asks
	 * this and nothing else, so a dimension is added by teaching this one switch
	 * about it rather than by threading a new special case through five blocks.
	 * Groups stopped being a mode here and became one dimension among five.
	 *
	 * Returns '' when the journey recorded nothing this dimension can use. That is
	 * the distinction the rest of the file is built on: '' means unplaceable, and
	 * each caller decides what unplaceable means for it — dropped from a ranking it
	 * cannot win, bucketed visibly in a table whose rows have to reconcile, refused
	 * as one end of a transition that would otherwise be invented out of a blank.
	 *
	 * No fallback from one end to the other on purpose. A journey that recorded
	 * only its converting touch has an unknown first touch, not a first touch equal
	 * to its last — crediting it anyway would fabricate exactly the same-bucket
	 * certainty this page exists to question. Journeys missing the mode's end go
	 * unclassified, and the coverage gate says so.
	 *
	 * @param array      $row    A resolved journey.
	 * @param string     $end    'first' or 'last' — which touch to read.
	 * @param string     $dim    A key from dimensions().
	 * @param array|null $groups Channel-group rules, only consulted for 'groups'.
	 * @param array      $memo   Passed by reference; the grouping cache for this pass.
	 * @return string
	 */
	private static function dimension_value( $row, $end, $dim, $groups, &$memo ) {
		$first = ( 'first' === $end );

		switch ( $dim ) {
			case 'groups':
				$channel = $first ? $row['first_channel'] : $row['channel'];
				return ( '' === $channel ) ? '' : self::grouped( $channel, $groups, $memo );

			case 'source':
				return $first ? $row['first_source'] : $row['source'];

			case 'medium':
				return $first ? $row['first_medium'] : $row['medium'];

			case 'source_medium':
				$source = $first ? $row['first_source'] : $row['source'];
				$medium = $first ? $row['first_medium'] : $row['medium'];
				if ( '' === $source && '' === $medium ) {
					return '';
				}
				// One half missing is not the same as both missing: the journey did
				// record something, and a row that named only the half it has would
				// be indistinguishable from a journey that genuinely arrived with
				// that value. Each half says for itself whether it is known.
				return ( ( '' === $source ) ? self::unknown_label( 'source' ) : $source )
					. BW_Lead_AI_Settings::SOURCE_MEDIUM_SEPARATOR
					. ( ( '' === $medium ) ? self::unknown_label( 'medium' ) : $medium );

			default:
				return $first ? $row['first_channel'] : $row['channel'];
		}
	}

	/**
	 * What an unplaceable journey is called in a table whose rows must reconcile.
	 *
	 * The same choice `forms()` makes for a journey saved without a form name:
	 * bucketing it visibly beats dropping it and leaving the table short of the
	 * lead count with no explanation. It is a label this plugin invented, not a
	 * value anybody stored, so the screen does not link it to a search that would
	 * find nothing — exactly as it treats "Unknown form".
	 *
	 * Empty for channels and groups, which keep the older and equally deliberate
	 * convention: a journey that never said which channel brought it is excluded
	 * from the channel table and reported on the Data Quality tab instead. Changing that
	 * would let "unknown" win a ranking of where leads come from.
	 *
	 * Public because the screen needs the same answer: it decides whether a row's
	 * name is offered as a link into the journeys list, and a label this plugin
	 * invented would link to a search that finds nothing.
	 *
	 * @param string $dim A key from dimensions().
	 * @return string The bucket label, or '' when this dimension excludes instead.
	 */
	public static function unknown_label( $dim ) {
		switch ( $dim ) {
			case 'source':
				return __( 'Unknown source', 'bw-lead-ai' );
			case 'medium':
				return __( 'Unknown medium', 'bw-lead-ai' );
			case 'source_medium':
				return __( 'Unknown source / medium', 'bw-lead-ai' );
			default:
				return '';
		}
	}

	/** Leads per credited bucket, biggest first, unplaceable journeys excluded. */
	private static function dimension_counts( $rows, $mode, $dim, $groups, &$memo ) {
		$counts = array();
		foreach ( $rows as $row ) {
			$value = self::dimension_value( $row, $mode, $dim, $groups, $memo );
			if ( '' === $value ) {
				continue;
			}
			$counts[ $value ] = isset( $counts[ $value ] ) ? $counts[ $value ] + 1 : 1;
		}
		arsort( $counts );
		return $counts;
	}

	/**
	 * Share of leads who enquired on their first visit.
	 *
	 * Measured only across journeys whose visit count is known. A journey with no
	 * visit count is not a journey with many visits — it is a journey we cannot
	 * say anything about, and putting it in the denominator understates the figure
	 * by exactly the size of the coverage gap.
	 */
	private static function first_visit_share( $rows ) {
		$known = 0;
		$first = 0;
		foreach ( $rows as $row ) {
			if ( null === $row['visits'] ) {
				continue;
			}
			$known++;
			if ( $row['visits'] <= 1 ) {
				$first++;
			}
		}
		return self::pct( $first, $known );
	}

	/**
	 * How many distinct people are behind these journeys, and how many came back.
	 *
	 * Counted on `lead_id` rather than on the identity blob: identity resolution is
	 * where "same person" is actually decided, and re-deciding it here from names
	 * and emails would produce a second, quietly different answer. A journey with
	 * no lead_id was never resolved to anyone, so it counts as its own person —
	 * merging them all into one anonymous bucket would report a hundred unknown
	 * visitors as a single very busy one.
	 */
	private static function people( $rows ) {
		$seen      = array();
		$anonymous = 0;
		foreach ( $rows as $row ) {
			if ( null === $row['lead_id'] ) {
				$anonymous++;
				continue;
			}
			$seen[ $row['lead_id'] ] = isset( $seen[ $row['lead_id'] ] ) ? $seen[ $row['lead_id'] ] + 1 : 1;
		}
		$repeat = 0;
		foreach ( $seen as $count ) {
			if ( $count > 1 ) {
				$repeat++;
			}
		}
		return array( 'total' => count( $seen ) + $anonymous, 'repeat' => $repeat );
	}

	// --- small helpers ------------------------------------------------------

	/**
	 * Does this journey look like it came from paid advertising?
	 *
	 * `Ads` is matched case-sensitively: the channel rules label paid traffic
	 * "Google Ads", "Facebook Ads" and so on, while a lowercase match would also
	 * claim any hostname or page path containing the letters — "downloads",
	 * "roadside", and every ad-blocker referrer.
	 */
	private static function looks_paid( $row ) {
		$medium = strtolower( $row['medium'] );
		foreach ( array( 'cpc', 'ppc', 'paid' ) as $marker ) {
			if ( '' !== $medium && false !== strpos( $medium, $marker ) ) {
				return true;
			}
		}
		return false !== strpos( $row['channel'], 'Ads' );
	}

	/**
	 * A tag line in the summary carrying a literal `{placeholder}` instead of a value.
	 *
	 * Matched against the tag keys rather than against any brace anywhere, because
	 * a page title or query string can perfectly legitimately contain one.
	 */
	/**
	 * Does this channel name look like a bare hostname rather than a label?
	 *
	 * Deliberately conservative: a dot, no spaces, and a plausible TLD. "Google
	 * Organic" and "Google Ads" have spaces; "google / d" has spaces too. Anything
	 * that survives is something a classification rule did not claim.
	 */
	private static function looks_like_hostname( $channel ) {
		$channel = trim( (string) $channel );
		if ( '' === $channel || false !== strpos( $channel, ' ' ) || false === strpos( $channel, '.' ) ) {
			return false;
		}
		return 1 === preg_match( '/^[a-z0-9.\-]+\.[a-z]{2,}$/i', $channel );
	}

	private static function has_placeholder( $summary ) {
		if ( '' === $summary || false === strpos( $summary, '{' ) ) {
			return false;
		}
		return 1 === preg_match( '/(?:source|medium|campaign|term|content|adgroup)\s*:\s*\{[^}\r\n]*\}/i', $summary );
	}

	/**
	 * A GMT timestamp expressed as LOCAL wall-clock read as if it were UTC.
	 *
	 * Weeks are labelled in the site's own timezone but the columns are GMT, so the
	 * wall-clock date has to be derived rather than assumed — get_date_from_gmt()
	 * does that including any DST shift. Reading the result back as UTC is what
	 * lets everything downstream use gmdate() and stay offset-free.
	 */
	private static function local_ts( $gmt ) {
		return (int) strtotime( get_date_from_gmt( (string) $gmt ) . ' UTC' );
	}

	/** Midnight on the Monday of the ISO week containing this wall-clock timestamp. */
	private static function week_start( $ts ) {
		$monday = $ts - ( ( (int) gmdate( 'N', $ts ) - 1 ) * DAY_IN_SECONDS );
		return (int) strtotime( gmdate( 'Y-m-d', $monday ) . ' UTC' );
	}

	/** A trimmed string from an array, or '' when the key is absent or not scalar. */
	private static function text( $arr, $key ) {
		if ( ! isset( $arr[ $key ] ) || ! is_scalar( $arr[ $key ] ) ) {
			return '';
		}
		return trim( (string) $arr[ $key ] );
	}

	/** An int from an array, or null when the key is absent — which is not the same as zero. */
	private static function number( $arr, $key ) {
		if ( ! isset( $arr[ $key ] ) || ! is_scalar( $arr[ $key ] ) || ! is_numeric( $arr[ $key ] ) ) {
			return null;
		}
		return (int) $arr[ $key ];
	}

	/** Whole-number percentage, and 0 rather than a division by zero on an empty range. */
	/**
	 * When this site's first stored journey happened, GMT, or null if none.
	 * Deliberately not restricted to the current range — the question is whether
	 * data existed at all, not whether it is in view.
	 */
	private static function earliest_journey() {
		global $wpdb;
		$table = BW_Lead_AI_Handoff_Store::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
		$value = $wpdb->get_var( $wpdb->prepare( "SELECT MIN(created_at) FROM {$table} WHERE status = %s", BW_Lead_AI_Handoff_Store::STATUS_SAVED ) );
		return $value ? (string) $value : null;
	}

	/**
	 * The typical value, not the average one.
	 *
	 * A mean is the wrong statistic for engagement and it actively misled here: one
	 * visitor who read 164 pages pulled a channel's mean to 5.9 and made it look
	 * like the site's highest-intent channel. Its median is 2.0 — the same as
	 * organic search. The question a marketer is asking is "what kind of person does this
	 * channel send me", and that is answered by the middle of the distribution, not
	 * by its centre of mass. Long tails are the norm in web analytics, so the mean
	 * would have been wrong on most sites, not just this one.
	 */
	private static function median( $values ) {
		$values = array_values( array_filter( $values, 'is_numeric' ) );
		$count  = count( $values );
		if ( 0 === $count ) {
			return 0.0;
		}
		sort( $values, SORT_NUMERIC );
		$mid = (int) floor( $count / 2 );
		$med = ( 0 === $count % 2 ) ? ( ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2 ) : $values[ $mid ];
		return round( (float) $med, 1 );
	}

	private static function pct( $part, $whole ) {
		return ( $whole > 0 ) ? (int) round( ( $part / $whole ) * 100 ) : 0;
	}

	/** Mean to one decimal place over the journeys that actually carried the field. */
	private static function avg( $sum, $count ) {
		return ( $count > 0 ) ? round( $sum / $count, 1 ) : 0.0;
	}

	/**
	 * Sort helpers. Both break ties on the name so the same data always produces
	 * the same order — an unstable table looks like it changed when it did not.
	 */
	private static function by_leads_desc( $a, $b ) {
		if ( $a['leads'] === $b['leads'] ) {
			return strcasecmp( isset( $a['channel'] ) ? $a['channel'] : $a['form'], isset( $b['channel'] ) ? $b['channel'] : $b['form'] );
		}
		return ( $a['leads'] < $b['leads'] ) ? 1 : -1;
	}

	private static function by_count_desc( $a, $b ) {
		if ( $a['count'] === $b['count'] ) {
			// Three callers, three name fields: transition pairs carry from/to,
			// quality findings a key, breakdown entries a channel.
			$a_name = isset( $a['from'] ) ? $a['from'] . $a['to'] : ( isset( $a['key'] ) ? $a['key'] : $a['channel'] );
			$b_name = isset( $b['from'] ) ? $b['from'] . $b['to'] : ( isset( $b['key'] ) ? $b['key'] : $b['channel'] );
			return strcasecmp( $a_name, $b_name );
		}
		return ( $a['count'] < $b['count'] ) ? 1 : -1;
	}
}
