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

/**
 * Re-derive how a stored journey was attributed, using the site's settings as they
 * are NOW.
 *
 * A journey's channel, source and medium are frozen at capture time — they are
 * whatever the settings said, and whatever the plugin's own processing could work
 * out, on the day that visitor arrived. That is the right default: a report should
 * not silently rewrite itself underneath the person reading it. But it means a site
 * owner who fixes a channel mapping, adds a referrer rule, or upgrades to a release
 * that classifies something the old one could not, fixes the future and nothing
 * else. Their history keeps the old answer forever, and the dataset changes its
 * mind about what Google is halfway through.
 *
 * This is the deliberate, informed correction of that. It is never automatic: it
 * runs only when someone presses the button, and it says up front which processing
 * improvements they have not yet applied so the decision is an informed one.
 *
 * **Nothing is lost.** The values captured on the day are preserved alongside the
 * new ones as `*_original`, written once and never overwritten, so a second pass
 * cannot erase the first-captured truth and the correction stays auditable.
 *
 * **Idempotent by construction.** Every step is a pure function of the stored triple
 * plus the current settings, so running it twice changes nothing the second time.
 * That is what makes it safe to press again when in doubt.
 */
class BW_Lead_AI_Reprocess {

	/** Where the last run is recorded. See record_run(). */
	const LAST_RUN_OPTION = 'bw_lead_ai_last_reprocess';

	/** Rows pulled per query, so peak memory does not scale with table size. */
	const BATCH = 200;

	/**
	 * How long a visitor may be idle before their next arrival is a new visit.
	 *
	 * The same thirty minutes `SESSION_GAP_MS` uses in assets/js/capture.js, and it has
	 * to stay the same: this exists so that a journey stored last year and a journey
	 * stored this morning are counted by one definition. Change one, change both.
	 */
	const SESSION_GAP_MS = 1800000;

	/**
	 * Every shipped change to how a journey's channel / source / medium is derived,
	 * oldest first.
	 *
	 * **THE RULE: any change to how attribution is derived MUST get an entry here.**
	 * Not "should" — must. This list is the only thing that can answer the question a
	 * site owner actually has in front of this screen: *would reprocessing my history
	 * improve it, or am I about to rewrite 400 records for nothing?* A processing fix
	 * that ships without an entry is invisible to them — the button still works, but
	 * they have no way to know it is now worth pressing, so they don't, and the fix
	 * reaches new leads only. A changelog entry is not a substitute: nobody reads a
	 * changelog while looking at their own data.
	 *
	 * Entries are release-scoped, not exhaustive-detail: one plain-English sentence
	 * describing what the visitor-facing outcome was, with no jargon and no version
	 * numbers in the prose. The version key does the ordering.
	 */
	const CHANGES = array(
		array(
			'version' => '1.2.1',
			'summary' => 'Search and social referrers were never recognised — a visit from Google was stored as the bare hostname "www.google.com / referral" instead of Google Organic.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'AI assistants — ChatGPT, Gemini, Claude, Perplexity and Copilot — are recognised as their own channel instead of sitting unnamed in the referral bucket.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'Webmail is recognised as email — a click from Gmail or the Gmail app was previously counted as organic search.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'Hostnames you own are no longer counted as a traffic source, so whatever actually found the visitor keeps the credit.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'A visit is now a real session — pageviews within half an hour of each other are one visit, and a page read twice counts once — so visit and page figures step DOWN on stored journeys. That is a correction to how they were measured, not a drop in performance.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'Two honest names replace "Direct": arrivals that carried no source information are labelled "Untracked", and arrivals via a property you own are split out as "Own Property" — the group you can actually fix, by putting the shared-property code on that property.',
		),
		array(
			'version' => '1.8.0',
			'summary' => 'Which hostnames count as yours is now a line you can see and edit — the "internal" row under Medium Mappings — instead of a separate list plus a hidden rule about subdomains. Sites keep exactly what they had, but editing that row now changes how past visits are read, so a reprocess is what applies a correction you make there.',
		),
		array(
			'version' => '1.8.0',
			'summary' => '"{this-domain}" now works in Channel Mappings too, not only in Medium Mappings — so a rule like "Own Property (Landing pages) : get.{this-domain}/internal" can name one of your own properties separately from the rest. Before this, that rule was compared as the literal text and never matched anything.',
		),
		array(
			'version' => '1.8.5',
			'summary' => 'Last-click credit stays with the visit the enquiry was actually made on. A journey carries on recording after someone enquires, so a visitor who came back weeks later through an ad handed that ad the credit for an enquiry it played no part in — and paid channels were the ones collecting it. Reprocessing works out when each stored enquiry happened and gives the credit back to whatever brought them in at the time. Journeys where that moment cannot be established safely are left exactly as they are.',
		),
	);

	/**
	 * The last reprocess run, or null if it has never been run here.
	 *
	 * @return array|null version, time (GMT mysql datetime), count.
	 */
	public static function last_run() {
		$stored = get_option( self::LAST_RUN_OPTION );
		if ( ! is_array( $stored ) || empty( $stored['version'] ) ) {
			return null;
		}
		return array(
			'version' => (string) $stored['version'],
			'time'    => isset( $stored['time'] ) ? (string) $stored['time'] : '',
			'count'   => isset( $stored['count'] ) ? (int) $stored['count'] : 0,
		);
	}

	/**
	 * Record that a run happened, stamped with the version that ran it.
	 *
	 * The version is what pending_changes() compares against, so it is the plugin's
	 * own version rather than the newest entry in CHANGES — a release that ships no
	 * processing change still moves the watermark forward, which is correct: the site
	 * has been reprocessed by that build and there is nothing outstanding.
	 */
	public static function record_run( $changed ) {
		update_option(
			self::LAST_RUN_OPTION,
			array(
				'version' => BW_LEAD_AI_VERSION,
				'time'    => current_time( 'mysql', true ),
				'count'   => (int) $changed,
			),
			false
		);
	}

	/**
	 * Processing improvements this site has not yet applied to its history.
	 *
	 * Never run means every one of them is outstanding — a site that has been
	 * capturing since before any of these fixes has the whole list ahead of it.
	 *
	 * @return array Subset of CHANGES, oldest first.
	 */
	public static function pending_changes() {
		$last = self::last_run();
		if ( null === $last ) {
			return self::CHANGES;
		}
		$out = array();
		foreach ( self::CHANGES as $change ) {
			if ( version_compare( $change['version'], $last['version'], '>' ) ) {
				$out[] = $change;
			}
		}
		return $out;
	}

	/**
	 * Reprocess stored journeys.
	 *
	 * @param int  $days   0 for everything; otherwise only journeys created within
	 *                     that many days.
	 * @param bool $commit False for a dry run — identical arithmetic, no writes. The
	 *                     dry run is the whole reason this is safe to offer: it is
	 *                     exactly the run that would happen, so the counts it reports
	 *                     are the counts you get.
	 * @return array examined, changed, moves ( "From  ->  To" => count ).
	 */
	public static function run( $days = 0, $commit = true ) {
		$summary = array( 'examined' => 0, 'changed' => 0, 'moves' => array() );

		if ( ! BW_Lead_AI_Handoff_Store::table_exists() ) {
			return $summary;
		}

		global $wpdb;
		$table    = BW_Lead_AI_Handoff_Store::table_name();
		$settings = BW_Lead_AI_Settings::get();
		$rules    = BW_Lead_AI_Settings::classification_rules( $settings );
		$channels = BW_Lead_AI_Settings::parse_channels( $settings['channels'] );
		$own      = BW_Lead_AI_Settings::owned_hosts( $settings );
		$sep      = BW_Lead_AI_Settings::SOURCE_MEDIUM_SEPARATOR;

		// Which mediums a referrer rule can produce. Used to tell an arrival that
		// carried tracking parameters from one that was merely classified — see
		// looks_tagged().
		$classified = self::classifier_mediums( $rules );

		// Stored timestamps are GMT, so the cutoff has to be too.
		$days   = max( 0, (int) $days );
		$cutoff = $days ? gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) ) : '';

		// Walked in id batches rather than read whole. A journey payload carries a
		// nested blob with every page view in it, so "SELECT payload FROM …" with no
		// bound is a memory ceiling that a busy site eventually walks into — and the
		// failure mode is a white screen halfway through a partially-applied rewrite.
		$last_id = 0;
		while ( true ) {
			$where  = 'WHERE status = %s AND id > %d';
			$params = array( BW_Lead_AI_Handoff_Store::STATUS_SAVED, $last_id );
			if ( '' !== $cutoff ) {
				$where   .= ' AND created_at >= %s';
				$params[] = $cutoff;
			}
			$params[] = self::BATCH;

			$rows = $wpdb->get_results(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix; every value is prepared.
				$wpdb->prepare( "SELECT id, payload, conversions, context, confirmed_at FROM {$table} {$where} ORDER BY id ASC LIMIT %d", $params ),
				ARRAY_A
			);
			if ( empty( $rows ) ) {
				break;
			}

			foreach ( $rows as $row ) {
				$last_id = (int) $row['id'];

				$payload = json_decode( (string) $row['payload'], true );
				if ( ! is_array( $payload ) ) {
					continue;
				}
				$summary['examined']++;

				// Change detection by re-encoding both sides with the same encoder.
				// Comparing against the stored string would report a change every time
				// the database happened to hold different escaping than wp_json_encode
				// produces, and every row would rewrite itself forever.
				$before = wp_json_encode( $payload );

				$move = self::apply_triple( $payload, '', $rules, $channels, $own, $sep, true );
				self::apply_triple( $payload, 'first_', $rules, $channels, $own, $sep, true );

				self::apply_to_journey_blob( $payload, $rules, $channels, $own, $sep );

				// After the triples, never before: the merge decision reads each visit's
				// source, and the source it should read is the corrected one. A visit
				// arriving from a hostname the site owns resolves to `(direct)` in the
				// step above, and comparing it before that would treat one visitor
				// walking back from their own landing page as a second acquisition.
				self::apply_visit_bounding( $payload, $classified );

				$after = wp_json_encode( $payload );

				// Last of all, because it reads the visits this pass has just
				// corrected and merged — anchoring to the visit list as it was
				// would file the conversion against a visit that no longer exists.
				$conversion = self::derive_conversion( $row, $payload );

				if ( $after === $before && null === $conversion ) {
					continue;
				}

				$summary['changed']++;
				if ( $move && $move['from'] !== $move['to'] ) {
					$key                      = $move['from'] . '  ->  ' . $move['to'];
					$summary['moves'][ $key ] = isset( $summary['moves'][ $key ] ) ? $summary['moves'][ $key ] + 1 : 1;
				}

				// A recovered conversion moves credit on its own — from whatever
				// the visitor came back through later to whatever actually brought
				// them in. It belongs in the same tally, or the panel reports a
				// run that changed nothing while the report underneath it moves.
				$shift = self::conversion_move( $payload, $conversion );
				if ( $shift ) {
					$key                      = $shift['from'] . '  ->  ' . $shift['to'];
					$summary['moves'][ $key ] = isset( $summary['moves'][ $key ] ) ? $summary['moves'][ $key ] + 1 : 1;
				}

				if ( ! $commit ) {
					continue;
				}

				$data    = array();
				$formats = array();
				if ( $after !== $before ) {
					// Stamped only on rows that actually moved, and only once the
					// change is known. Stamping every row would make the first run
					// rewrite the entire table to record that nothing happened to
					// most of it.
					$payload['reprocessed'] = BW_LEAD_AI_VERSION;

					$data['payload'] = wp_json_encode( $payload );
					$formats[]       = '%s';
				}
				if ( null !== $conversion ) {
					$data['conversions'] = wp_json_encode( array( $conversion ) );
					$formats[]           = '%s';
				}

				$wpdb->update( $table, $data, array( 'id' => (int) $row['id'] ), $formats, array( '%d' ) );
			}

			if ( count( $rows ) < self::BATCH ) {
				break;
			}
		}

		arsort( $summary['moves'] );
		return $summary;
	}

	// --- recovering the moment they converted -----------------------------

	/**
	 * Work out where in a stored journey the submission happened, for records
	 * saved before the plugin wrote that down.
	 *
	 * **Why anything needs recovering.** A journey used to be frozen at the
	 * submission, so "the last touch" and "the last touch before the conversion"
	 * were the same sentence. Once a journey could keep growing afterwards they
	 * stopped being the same sentence, and the credited channel started drifting
	 * onto whatever brought the visitor back next — a visit that cannot have
	 * caused a conversion that had already happened.
	 *
	 * **Two clocks, and only one of them is ours.** `confirmed_at` is the
	 * server's, in GMT; every timestamp inside the journey is the visitor's
	 * browser. They are not comparable, so this does not try to place the
	 * conversion precisely. It asks only the question it can answer safely —
	 * *which visits are clearly AFTER it* — and allows a full session gap of
	 * slack in the visitor's favour before calling a visit late. A device an
	 * hour out changes nothing; a visit a fortnight later is unambiguous, and
	 * that is the case this exists for.
	 *
	 * **It declines rather than guesses.** No confirmation time, no journey, no
	 * visits, or every visit apparently after the conversion — all return null
	 * and the record is left exactly as it is. A wrong conversion would freeze
	 * the wrong channel permanently, which is worse than the drift it is fixing.
	 *
	 * **A live record is never overwritten.** A conversion recorded at the time
	 * is a fact; this is a reconstruction. That is also what keeps a second
	 * reprocess a no-op.
	 *
	 * @param array $row     id, conversions, context, confirmed_at.
	 * @param array $payload The payload, already corrected by this pass.
	 * @return array|null A conversion record, or null to leave the row alone.
	 */
	private static function derive_conversion( $row, $payload ) {
		if ( ! empty( $row['conversions'] ) ) {
			$existing = json_decode( (string) $row['conversions'], true );
			if ( is_array( $existing ) && ! empty( $existing ) ) {
				return null;
			}
		}
		if ( empty( $row['confirmed_at'] ) || empty( $payload['journey'] ) ) {
			return null;
		}

		$at = strtotime( (string) $row['confirmed_at'] . ' +00:00' );
		if ( ! $at ) {
			return null;
		}

		$journey = json_decode( (string) $payload['journey'], true );
		if ( ! is_array( $journey ) || empty( $journey['visits'] ) || ! is_array( $journey['visits'] ) ) {
			return null;
		}

		$limit = ( $at * 1000 ) + self::SESSION_GAP_MS;
		$visit = null;
		foreach ( $journey['visits'] as $candidate ) {
			$ts = ( is_array( $candidate ) && isset( $candidate['ts'] ) ) ? (int) $candidate['ts'] : 0;
			if ( $ts <= 0 || $ts > $limit ) {
				continue;
			}
			if ( null === $visit || $ts > (int) $visit['ts'] ) {
				$visit = $candidate;
			}
		}
		if ( null === $visit ) {
			return null;
		}

		// The last thing on record inside that visit and not clearly after the
		// conversion — a page or an interaction, whichever came last. Bounded to
		// the visit so the submission renders inside it.
		$after_ts = 0;
		foreach ( array( 'views', 'events' ) as $bag ) {
			if ( empty( $journey[ $bag ] ) || ! is_array( $journey[ $bag ] ) ) {
				continue;
			}
			foreach ( $journey[ $bag ] as $entry ) {
				$ts = ( is_array( $entry ) && isset( $entry['ts'] ) ) ? (int) $entry['ts'] : 0;
				if ( $ts >= (int) $visit['ts'] && $ts <= $limit && $ts > $after_ts ) {
					$after_ts = $ts;
				}
			}
		}

		$out = array(
			'ts'       => (string) $row['confirmed_at'],
			'visit_ts' => (int) $visit['ts'],
			'after_ts' => $after_ts,
			'context'  => (string) $row['context'],
			// Deliberately blank. Which path reported this is not recoverable, and
			// the journey report says "reported by the destination" only when it
			// knows that to be true.
			'via'      => '',
		);
		foreach ( array( 'channel', 'source', 'medium', 'campaign', 'term' ) as $key ) {
			$out[ $key ] = isset( $visit[ $key ] ) && ! is_array( $visit[ $key ] ) ? substr( (string) $visit[ $key ], 0, 200 ) : '';
		}
		return $out;
	}

	/**
	 * The credit shift a recovered conversion causes, or null if it causes none.
	 *
	 * Compares against the reading the report gives today — the flat datapoint if
	 * the site stores one, else the journey's own last visit — which is exactly
	 * what BW_Lead_AI_Reports::resolve() does, so the tally matches what the
	 * reader will see change.
	 */
	private static function conversion_move( $payload, $conversion ) {
		if ( null === $conversion || '' === (string) $conversion['channel'] ) {
			return null;
		}

		$now = isset( $payload['channel'] ) ? (string) $payload['channel'] : '';
		if ( '' === $now && ! empty( $payload['journey'] ) ) {
			$journey = json_decode( (string) $payload['journey'], true );
			if ( is_array( $journey ) && isset( $journey['last']['channel'] ) ) {
				$now = (string) $journey['last']['channel'];
			}
		}
		if ( '' === $now || $now === (string) $conversion['channel'] ) {
			return null;
		}
		return array( 'from' => $now, 'to' => (string) $conversion['channel'] );
	}

	// --- the correction itself -------------------------------------------

	/**
	 * Correct one channel/source/medium triple in place.
	 *
	 * @param array  $bag            Payload or journey touch, by reference.
	 * @param string $prefix         '' for the last touch, 'first_' for the first.
	 * @param bool   $keep_originals Whether to preserve the captured values alongside.
	 * @return array|null from/to channel labels when something changed.
	 */
	private static function apply_triple( &$bag, $prefix, $rules, $channels, $own, $sep, $keep_originals ) {
		$chan_key = $prefix . 'channel';
		$src_key  = $prefix . 'source';
		$med_key  = $prefix . 'medium';

		$old = array(
			'channel' => isset( $bag[ $chan_key ] ) ? (string) $bag[ $chan_key ] : '',
			'source'  => isset( $bag[ $src_key ] ) ? (string) $bag[ $src_key ] : '',
			'medium'  => isset( $bag[ $med_key ] ) ? (string) $bag[ $med_key ] : '',
		);

		// An earlier pass folded own-property arrivals to (direct)/(none), which
		// erases the very evidence the "Own Property" label needs. The captured
		// host survives in the write-once originals, so feed THAT to derive() when
		// the current value is the folded placeholder. Without this, the rows that
		// motivated the label are the only ones that can never receive it.
		$seed = $old;
		if ( '(direct)' === $seed['source'] || '' === $seed['source'] ) {
			$orig_src  = isset( $bag[ $src_key . '_original' ] ) ? (string) $bag[ $src_key . '_original' ] : '';
			$orig_chan = isset( $bag[ $chan_key . '_original' ] ) ? (string) $bag[ $chan_key . '_original' ] : '';
			$candidate = ( '' !== $orig_src ) ? $orig_src : $orig_chan;
			if ( '' !== $candidate && self::is_self_referral( $candidate, $own ) ) {
				$seed['source'] = $candidate;
				$seed['medium'] = 'referral';
			}
		}

		$new = self::derive( $seed['channel'], $seed['source'], $seed['medium'], $rules, $channels, $own, $sep );

		if ( $new === $old ) {
			return null;
		}

		if ( $keep_originals ) {
			// Written once, ever. Reprocessing twice must not quietly replace the
			// value captured on the day with the value the first pass computed —
			// that would turn an auditable correction into an unrecoverable one.
			$originals = array(
				$chan_key => $old['channel'],
				$src_key  => $old['source'],
				$med_key  => $old['medium'],
			);
			foreach ( $originals as $key => $value ) {
				if ( ! array_key_exists( $key . '_original', $bag ) ) {
					$bag[ $key . '_original' ] = $value;
				}
			}
		}

		$bag[ $chan_key ] = $new['channel'];
		$bag[ $src_key ]  = $new['source'];
		$bag[ $med_key ]  = $new['medium'];

		return array( 'from' => $old['channel'], 'to' => $new['channel'] );
	}

	/**
	 * Apply the same correction inside the nested journey blob.
	 *
	 * A journey's attribution lives in three places at once, and correcting only the
	 * flat payload keys leaves the record contradicting itself on screen. The
	 * single-journey report opens with the flat channel ("Enquired via Direct"), draws
	 * its "How they found us" cards from the blob's first/last touches, and headlines
	 * every visit in the timeline with that visit's own channel. Fix one of the three
	 * and a site owner reads "Direct" at the top and the name of their own subdomain
	 * six lines further down — which is not a smaller version of the bug, it is the
	 * bug plus a reason to distrust the fix. Worse, the report decides whether to show
	 * one card or two by comparing the blob's two channels, so a stale blob keeps
	 * insisting on a channel switch that has just been corrected away.
	 *
	 * Originals are preserved exactly once, wherever the value is not already kept
	 * somewhere else: the blob's first/last are a second copy of the payload's two
	 * triples, so the payload's `*_original` keys already hold them, while each visit
	 * is an observation of its own and keeps its own.
	 */
	private static function apply_to_journey_blob( &$payload, $rules, $channels, $own, $sep ) {
		if ( empty( $payload['journey'] ) ) {
			return;
		}
		$journey = json_decode( (string) $payload['journey'], true );
		if ( ! is_array( $journey ) ) {
			return;
		}

		$before = wp_json_encode( $journey );

		foreach ( array( 'first', 'last' ) as $touch ) {
			if ( isset( $journey[ $touch ] ) && is_array( $journey[ $touch ] ) ) {
				self::apply_triple( $journey[ $touch ], '', $rules, $channels, $own, $sep, false );
			}
		}

		if ( isset( $journey['visits'] ) && is_array( $journey['visits'] ) ) {
			foreach ( $journey['visits'] as $i => $visit ) {
				if ( is_array( $visit ) ) {
					self::apply_triple( $journey['visits'][ $i ], '', $rules, $channels, $own, $sep, true );
				}
			}
		}

		$after = wp_json_encode( $journey );

		if ( $after !== $before ) {
			$payload['journey'] = $after;
		}
	}

	// --- visit bounding ---------------------------------------------------

	/**
	 * Count a stored journey's visits and pages the way capture counts them now.
	 *
	 * Two definitions changed underneath the stored data, and history that keeps the
	 * old ones is worse than useless — it is a chart where the line steps down on the
	 * day of an upgrade and every reader concludes their marketing stopped working.
	 *
	 * **A visit is a session.** It used to begin whenever a browser session appeared
	 * that had not been counted, so a second tab was a second visit and a browser
	 * restart was another. Here that is undone by merging consecutive stored visits
	 * less than half an hour apart. Measured on a real dataset: multi-visit journeys
	 * stored 2.7 visits where 1.8 sessions had happened.
	 *
	 * **A page read twice is one page read.** Recomputed as the number of distinct
	 * URLs in each merged visit, added up. A fifth of every view stored on that same
	 * dataset repeated a URL already in the journey.
	 *
	 * **The exception is preserved**, because it is the one case where a genuinely new
	 * acquisition lands mid-session: an arrival carrying tracking parameters for a
	 * different source or campaign — a retargeting click, an email link, a second ad —
	 * still starts its own visit. That mirrors `isNewCampaign()` in capture.js.
	 *
	 * **What is not touched:** the raw `summary` text, which stays exactly as captured
	 * and remains the audit of what the day recorded; `taggedVisits`, which counts
	 * arrivals rather than sessions and so is unaffected by grouping them; and the
	 * `views` list itself, because the timeline is a record of what happened. Only the
	 * COUNTERS change, and the values they replace are preserved once as
	 * `visits_original` and `pages_original`.
	 */
	private static function apply_visit_bounding( &$payload, $classified ) {
		if ( empty( $payload['journey'] ) ) {
			return;
		}
		$journey = json_decode( (string) $payload['journey'], true );
		if ( ! is_array( $journey ) || empty( $journey['visits'] ) || ! is_array( $journey['visits'] ) ) {
			return;
		}

		$visits = self::sorted_visits( $journey['visits'] );
		if ( empty( $visits ) ) {
			return;
		}

		$heads = self::group_visits( $visits, $classified );
		$views = ( isset( $journey['views'] ) && is_array( $journey['views'] ) ) ? $journey['views'] : array();

		$before_journey = wp_json_encode( $journey );
		$journey['visits'] = $heads;

		self::set_count( $payload, $journey, 'visits', count( $heads ) );
		self::set_count( $payload, $journey, 'pages', self::unique_pages( $heads, $views ) );

		$after_journey = wp_json_encode( $journey );
		if ( $after_journey !== $before_journey ) {
			$payload['journey'] = $after_journey;
		}
	}

	/**
	 * Stored visits, oldest first, with anything that is not a visit dropped.
	 *
	 * Sorted with the original position as a tie-break rather than by `usort()` alone:
	 * this plugin supports PHP 7.4, where `usort()` is not guaranteed stable, and
	 * visits sharing a millisecond are not hypothetical — a since-fixed import bug
	 * produced exactly that. An unstable sort there would shuffle a journey's visits on
	 * one run and shuffle them back on the next, and this has to be a function that
	 * returns the same answer every time it is asked.
	 */
	private static function sorted_visits( $visits ) {
		$indexed = array();
		foreach ( $visits as $visit ) {
			if ( is_array( $visit ) ) {
				$indexed[] = array( count( $indexed ), $visit );
			}
		}
		usort(
			$indexed,
			function ( $a, $b ) {
				$a_ts = isset( $a[1]['ts'] ) ? (int) $a[1]['ts'] : 0;
				$b_ts = isset( $b[1]['ts'] ) ? (int) $b[1]['ts'] : 0;
				if ( $a_ts === $b_ts ) {
					return $a[0] - $b[0];
				}
				return ( $a_ts < $b_ts ) ? -1 : 1;
			}
		);

		$out = array();
		foreach ( $indexed as $pair ) {
			$out[] = $pair[1];
		}
		return $out;
	}

	/**
	 * Collapse a sorted visit list to one entry per session.
	 *
	 * The surviving entry is the FIRST of its group, so a session is attributed to the
	 * arrival that began it. That is not a tidiness preference: under the new capture
	 * the later entries were never recorded at all, so keeping one of those would be
	 * inventing an attribution that the live plugin would not have produced.
	 *
	 * The gap is measured from the PREVIOUS visit rather than from the group's first,
	 * which is what makes a long chain of short hops one session — the same way a
	 * pageview extends a live visit rather than the visit expiring on a fixed clock.
	 * The campaign comparison is against the group's first, because that is the one the
	 * capture script would be holding in `bw_lai_last`.
	 */
	private static function group_visits( $visits, $classified ) {
		$heads = array();
		$head  = null;
		$prev  = null;

		foreach ( $visits as $visit ) {
			if ( null === $head || self::starts_new_visit( $visit, $prev, $head, $classified ) ) {
				$head    = $visit;
				$heads[] = $visit;
			}
			$prev = $visit;
		}
		return $heads;
	}

	/** Does this stored visit begin a session, or continue the one before it? */
	private static function starts_new_visit( $visit, $prev, $head, $classified ) {
		$ts      = isset( $visit['ts'] ) ? (int) $visit['ts'] : 0;
		$prev_ts = isset( $prev['ts'] ) ? (int) $prev['ts'] : 0;

		// No usable timestamp on either side means there is no evidence these belong
		// together. Merging on a guess would delete an observation; keeping it costs a
		// row that was already there.
		if ( $ts <= 0 || $prev_ts <= 0 ) {
			return true;
		}
		if ( ( $ts - $prev_ts ) >= self::SESSION_GAP_MS ) {
			return true;
		}

		return self::looks_tagged( $visit, $classified )
			&& (
				self::visit_field( $visit, 'source' ) !== self::visit_field( $head, 'source' )
				|| self::visit_field( $visit, 'campaign' ) !== self::visit_field( $head, 'campaign' )
			);
	}

	/**
	 * Mediums a referrer rule can produce, as a lookup.
	 *
	 * `referral` is always in it: the capture script falls back to that medium for any
	 * referring host no rule claimed, whether or not the site lists it.
	 */
	private static function classifier_mediums( $rules ) {
		$out = array( 'referral' => true );
		foreach ( $rules as $class ) {
			$medium = strtolower( trim( (string) $class['medium'] ) );
			if ( '' !== $medium ) {
				$out[ $medium ] = true;
			}
		}
		return $out;
	}

	/**
	 * Did this visit arrive carrying tracking parameters?
	 *
	 * The stored blob does not keep the capture script's `tagged` flag, so it has to be
	 * recovered — and it matters, because the mid-session exception is *only* for
	 * tagged arrivals. Reading the flag as "the source differs" instead would be a bad
	 * approximation in the worst possible direction: an untagged mid-session pageview
	 * resolves to no source at all, so on a real dataset 27 of the 32 differing pairs
	 * under half an hour were exactly the new-tab case this is meant to merge, and a
	 * naive source comparison would have refused to merge every one of them.
	 *
	 * The test: a campaign, term or content value can only have come from the URL. So
	 * can any medium a referrer rule could not have produced — `cpc`, or an ad
	 * platform's truncated `g`. What a rule CAN produce (`organic`, `social`, `ai`,
	 * `email`, `referral`) was inferred from the referring host, which is not tagging;
	 * and an empty or `(none)` medium is direct, which is the opposite of tagging.
	 */
	private static function looks_tagged( $visit, $classified ) {
		foreach ( array( 'campaign', 'term', 'content' ) as $key ) {
			$value = self::visit_field( $visit, $key );
			if ( '' !== $value && '(not set)' !== $value ) {
				return true;
			}
		}

		$medium = self::visit_field( $visit, 'medium' );
		if ( '' === $medium || '(none)' === $medium || '(not set)' === $medium ) {
			return false;
		}
		return ! isset( $classified[ $medium ] );
	}

	/** One visit field, normalised for comparison. */
	private static function visit_field( $visit, $key ) {
		return strtolower( trim( (string) ( isset( $visit[ $key ] ) ? $visit[ $key ] : '' ) ) );
	}

	/**
	 * Distinct URLs per merged visit, added up across the journey.
	 *
	 * Per-visit rather than per-journey, so it means the same thing the live counter
	 * now means: reading a page again next week IS another page read, reading it again
	 * two minutes later is not.
	 *
	 * Three judgement calls, recorded here rather than left for someone to rediscover:
	 *
	 *  - **A view earlier than the first visit belongs to it.** The excursion waypoint
	 *    the capture script writes when a visitor returns from a property the site owns
	 *    is deliberately stamped a second early so it sorts ahead of the page they came
	 *    back to. Bucketing strictly by "the latest visit at or before this view" would
	 *    drop it out of the journey entirely.
	 *  - **URLs are compared as stored**, which is protocol + host + path with no query
	 *    string, exactly as the capture script compares them. Two arrivals on one page
	 *    under different UTMs are one page, because they are one page.
	 *  - **Returns null when there are no views to count**, which leaves the stored
	 *    figure alone. A journey whose views were never collected would otherwise have
	 *    a real page count replaced with zero — losing data in the name of correcting
	 *    it. The stored view list is also capped at first 25 + last 25, so a journey
	 *    longer than that yields a floor rather than an exact count; a floor is still
	 *    far closer than the inflated figure it replaces, and the original is preserved
	 *    either way.
	 *
	 * @return int|null Distinct pages, or null to leave the stored count untouched.
	 */
	private static function unique_pages( $heads, $views ) {
		if ( empty( $views ) ) {
			return null;
		}

		$starts = array();
		foreach ( $heads as $head ) {
			$starts[] = isset( $head['ts'] ) ? (int) $head['ts'] : 0;
		}

		$seen  = array();
		$total = 0;
		foreach ( $views as $view ) {
			if ( ! is_array( $view ) ) {
				continue;
			}
			$page = isset( $view['page'] ) ? trim( (string) $view['page'] ) : '';
			if ( '' === $page ) {
				continue;
			}
			$key = self::visit_index( isset( $view['ts'] ) ? (int) $view['ts'] : 0, $starts ) . "\n" . $page;
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$total++;
		}

		return $total;
	}

	/** Which merged visit a view falls inside. Anything before the first belongs to it. */
	private static function visit_index( $ts, $starts ) {
		$index = 0;
		foreach ( $starts as $i => $start ) {
			if ( $start > 0 && $ts >= $start ) {
				$index = $i;
			}
		}
		return $index;
	}

	/**
	 * Write a recounted figure everywhere the journey keeps it, preserving the old one.
	 *
	 * A journey holds each count twice — a flat payload key that the reports read
	 * first, and the blob's `counts`, which they fall back to when the site does not
	 * store the flat one. Correcting one and not the other produces a record that
	 * disagrees with itself depending on which screen is open.
	 *
	 * The original is written once and never overwritten, on the same rule as the
	 * `*_original` attribution keys: a second pass must not be able to replace the
	 * value captured on the day with the value the first pass computed.
	 *
	 * The stored scalar TYPE is preserved deliberately. These arrive as strings, being
	 * merge-tag output, and rewriting one as an integer would make the row differ from
	 * its own encoding on every future pass — an idempotent operation that rewrites the
	 * whole table forever.
	 */
	private static function set_count( &$payload, &$journey, $key, $new ) {
		if ( null === $new ) {
			return;
		}
		$new = (int) $new;

		$before = null;
		if ( isset( $payload[ $key ] ) && is_scalar( $payload[ $key ] ) && is_numeric( $payload[ $key ] ) ) {
			$before = (int) $payload[ $key ];
		} elseif ( isset( $journey['counts'][ $key ] ) && is_numeric( $journey['counts'][ $key ] ) ) {
			$before = (int) $journey['counts'][ $key ];
		}

		if ( null !== $before && $before !== $new && ! array_key_exists( $key . '_original', $payload ) ) {
			$payload[ $key . '_original' ] = $before;
		}

		if ( isset( $journey['counts'] ) && is_array( $journey['counts'] ) ) {
			$journey['counts'][ $key ] = $new;
		}
		// Only where the site already stores it. Adding a datapoint nobody asked for
		// would change what a journey record contains, which is a settings decision.
		if ( array_key_exists( $key, $payload ) ) {
			$payload[ $key ] = is_string( $payload[ $key ] ) ? (string) $new : $new;
		}
	}

	/**
	 * The whole correction, as a pure function of one triple plus current settings.
	 *
	 * Pure on purpose: it is what makes a second pass a no-op, which is what makes
	 * the button safe to press twice.
	 *
	 * @return array channel, source, medium.
	 */
	private static function derive( $channel, $source, $medium, $rules, $channels, $own, $sep ) {
		$channel = (string) $channel;
		$source  = (string) $source;
		$medium  = (string) $medium;

		// 0. Recover a missing source from the channel.
		//
		// Records captured before the first-touch triple was made consistent have no
		// source at all, but their channel still holds the raw referring hostname —
		// which is the thing every step below wants to look at. Without this, one end
		// of a journey gets corrected and the other does not, and every one of those
		// reads as a channel switch that never happened.
		//
		// Done BEFORE the own-hostname test rather than inside the referral branch:
		// a recovered hostname that turns out to be ours must still be treated as
		// ours, and a hostname is the only shape worth guessing at — a label with a
		// space in it came from a channel rule, not from a referrer.
		if ( '' === $source && '' !== $channel
			&& false !== strpos( $channel, '.' ) && false === strpos( $channel, ' ' ) ) {
			$source = $channel;
			$medium = 'referral';
		}

		// 1. A hostname we own is not a traffic source.
		//
		// A landing-page platform on a subdomain, a booking tool, a second property:
		// arriving from one is not an acquisition, and crediting it means whatever
		// actually found that visitor gets nothing. Recorded as direct — an honest
		// "we do not know" rather than a confident attribution to ourselves.
		if ( '' !== $source && self::is_self_referral( $source, $own ) ) {
			// The `internal` medium, not (direct)/(none): an arrival via a
			// property the site owns is a KNOWN kind of unknown — the owner can
			// recover these by putting the shared-property snippet on that
			// property, which is exactly the action the "Own Property" label
			// points at. Folding them into the untracked bucket hid the one group
			// in it that is fixable. The original host stays as the source, so
			// the report can still say WHICH property.
			$medium = 'internal';

		} elseif ( 'referral' === strtolower( $medium ) && '' !== $source ) {
			// 2. Re-run the site's current referrer rules over the referring host.
			//
			// Only the guessed-at ones. A visit that carried explicit UTMs or a
			// click-ID was never guessed at, so there is nothing here to correct and
			// overwriting it would be destroying data rather than repairing it.
			$hit = self::classify_host( $source, $rules );
			if ( $hit ) {
				$source = $hit['source'];
				$medium = $hit['medium'];
			}
		}

		// 3. Always re-derive the channel LABEL from the (possibly updated) pair.
		//
		// This is the step that makes a site owner's own settings reach their
		// history. Steps 1 and 2 only fix what the plugin got wrong; someone who maps
		// `google/d` to Google Ads because their ad platform fires a truncated medium
		// has changed nothing about source or medium, so a correction that stops at
		// step 2 leaves their nine leads reading "google / d" forever and the mapping
		// they just made looks broken.
		$label = self::channel_label( $source, $medium, $channels, $sep );
		if ( '' !== $label ) {
			$channel = $label;
		}

		return array( 'channel' => $channel, 'source' => $source, 'medium' => $medium );
	}

	/**
	 * Does a referring hostname belong to a configured value?
	 *
	 * One matcher for the whole plugin, so a rule cannot mean one thing to a visitor
	 * arriving now and another to the same visit being re-derived later.
	 */
	private static function host_matches( $host, $value ) {
		return BW_Lead_AI_Settings::host_matches( $host, $value );
	}

	/** Is this host one of ours? See BW_Lead_AI_Settings::is_owned_host(). */
	private static function is_self_referral( $host, $own ) {
		return BW_Lead_AI_Settings::is_owned_host( $host, $own );
	}

	/** First matching referrer rule wins, exactly as the capture script walks them. */
	private static function classify_host( $host, $rules ) {
		foreach ( $rules as $class ) {
			foreach ( $class['sources'] as $source ) {
				if ( self::host_matches( $host, $source ) ) {
					return array( 'source' => $source, 'medium' => $class['medium'] );
				}
			}
		}
		return null;
	}

	/**
	 * The channel label a source/medium pair resolves to under the current rules.
	 *
	 * @return string '' when nothing can be derived, meaning "keep what was captured".
	 */
	private static function channel_label( $source, $medium, $channels, $sep ) {
		foreach ( $channels as $rule ) {
			foreach ( $rule['patterns'] as $pattern ) {
				$source_ok = ( '*' === $pattern['source'] || strtolower( $pattern['source'] ) === strtolower( $source ) );
				$medium_ok = ( '*' === $pattern['medium'] || strtolower( $pattern['medium'] ) === strtolower( $medium ) );
				if ( $source_ok && $medium_ok ) {
					// `{bw:source} : */referral` names the channel after the host.
					return str_replace(
						array( '{bw:source}', '{bw:medium}' ),
						array( $source, $medium ),
						(string) $rule['label']
					);
				}
			}
		}

		// No rule claimed it. The capture script falls back to "source <sep> medium",
		// but only do that when there is something on both sides: a journey that
		// recorded no source and no medium would otherwise have its captured label
		// replaced with a bare separator, which is worse than the imperfect label it
		// already had.
		if ( '' === $source || '' === $medium ) {
			return '';
		}
		return $source . $sep . $medium;
	}
}
