<?php
/**
 * When the enquiry happened, and who gets the credit for it.
 *
 * A journey used to be frozen the moment someone enquired, so "the last touch"
 * and "the last touch before the conversion" were the same sentence and nobody
 * had to choose between them. They stopped being the same sentence when a
 * journey could keep growing afterwards — and the drift that opened up is
 * invisible: the report still adds up, every number is plausible, and the
 * credit has quietly moved to a visit that happened AFTER the enquiry it is
 * being credited with. Paid channels are the ones that collect it, because a
 * returning visitor tends to come back through an ad.
 *
 * **THE TWO MISTAKES THIS FILE EXISTS TO CATCH.**
 *
 * 1. *The conversion is written into the payload.* It cannot be. The update
 *    beacon replaces `payload` wholesale with whatever the browser is holding
 *    (append_to_anchor), so anything the server writes there survives until the
 *    visitor reads one more page. A journey in "keep growing" mode would be the
 *    least able to remember its own conversion, which is exactly backwards.
 *    Section (b) is that regression stated as a test.
 *
 * 2. *Last click reads the flat datapoints.* Those are whatever the browser
 *    last reported. Section (c) is the failure rian found on a live journey:
 *    enquire on a social visit, return later through a paid click without
 *    enquiring, and the paid click takes the credit.
 *
 * Reprocess is held to a stricter rule than "make it better": it is allowed to
 * decline. `confirmed_at` is the server's clock and every timestamp inside a
 * journey is the visitor's browser, and those are not comparable. Sections (f)
 * and (g) assert the declining, because a wrongly recovered conversion freezes
 * the wrong channel permanently — worse than the drift it was fixing.
 *
 * **This file WRITES.** It creates its own records, runs a real reprocess over
 * the table, and restores every row it did not create from a snapshot taken
 * first. It asserts the restore. Do not run it against a client site.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/conversion-credit.php
 */

if ( ! defined( 'ABSPATH' ) ) {
	fwrite( STDERR, "Run via: wp eval-file\n" );
	exit( 1 );
}
if ( ! class_exists( 'BW_Lead_AI_Handoff_Store' ) || ! class_exists( 'BW_Lead_AI_Reprocess' ) ) {
	fwrite( STDERR, "BW Lead AI is not active on this site.\n" );
	exit( 1 );
}

global $wpdb;

$failures = array();
$checks   = 0;

// Closures rather than named functions: wp-cli includes this file inside a
// method, so a named function could not reach $failures through `global`.
$ok = function ( $desc, $condition, $detail = '' ) use ( &$failures, &$checks ) {
	$checks++;
	if ( $condition ) {
		echo "  PASS  {$desc}\n";
		return true;
	}
	$line = $desc . ( '' !== $detail ? "  [{$detail}]" : '' );
	$failures[] = $line;
	echo "  FAIL  {$line}\n";
	return false;
};

$same = function ( $desc, $got, $want ) use ( $ok ) {
	return $ok( $desc, $got === $want, 'got ' . var_export( $got, true ) . ', want ' . var_export( $want, true ) );
};

$section = function ( $title ) {
	echo "\n{$title}\n" . str_repeat( '-', strlen( $title ) ) . "\n";
};

$table = BW_Lead_AI_Handoff_Store::table_name();

echo "BW Lead AI — conversion moment and last-click credit\n";
echo "====================================================\n";

// --- snapshot ------------------------------------------------------------
// Everything this file might touch, so the run can be proven to have left the
// site as it found it. Reprocess walks every saved row, not only ours.
//
// The column list is read first and the ROWS after the schema upgrade, on
// purpose: a snapshot taken one column narrower than the table it has to
// restore cannot put back the column it does not know about, and the restore
// would pass while quietly leaving this run's writes behind.
$settings_md5     = md5( (string) maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) );
$reprocess_before = get_option( BW_Lead_AI_Reprocess::LAST_RUN_OPTION );
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
$columns_before = $wpdb->get_col( "SHOW COLUMNS FROM {$table}" );
$rows_before    = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

BW_Lead_AI_Handoff_Store::maybe_create_table();

// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
$snapshot     = $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A );
$snapshot_ids = wp_list_pluck( $snapshot, 'id' );

echo 'snapshot: ' . count( $snapshot ) . " existing rows\n";

$made = array();   // token => id, for cleanup

// --- fixtures ------------------------------------------------------------
$MS = 1000;
$now_ms = time() * $MS;

/** One visit as the browser records it. */
$visit = function ( $ts, $channel, $source, $medium, $campaign = '' ) {
	return array(
		'ts'       => (int) $ts,
		'date'     => gmdate( 'Y-m-d', (int) ( $ts / 1000 ) ),
		'channel'  => $channel,
		'source'   => $source,
		'medium'   => $medium,
		'campaign' => $campaign,
		'term'     => '',
		'content'  => '',
		'page'     => 'https://example.test/landing',
		'custom'   => array(),
	);
};

$view = function ( $ts, $page ) {
	return array( 'ts' => (int) $ts, 'date' => gmdate( 'Y-m-d', (int) ( $ts / 1000 ) ), 'page' => $page );
};

/**
 * A payload shaped exactly as handoff.js sends one: flat datapoints the site
 * chose to store, plus the nested journey as a JSON STRING.
 */
$payload_for = function ( $visits, $views ) {
	$last  = end( $visits );
	$first = reset( $visits );
	return array(
		'channel'       => $last['channel'],
		'source'        => $last['source'],
		'medium'        => $last['medium'],
		'campaign'      => $last['campaign'],
		'first_channel' => $first['channel'],
		'journey'       => wp_json_encode(
			array(
				'first'       => $first,
				'last'        => $last,
				'counts'      => array( 'visits' => count( $visits ), 'pages' => count( $views ), 'taggedVisits' => 0 ),
				'visits'      => array_values( $visits ),
				'views'       => array_values( $views ),
				'events'      => array(),
				'eventCounts' => array(),
				'submissions' => array(),
			)
		),
	);
};

$row_for = function ( $token ) use ( $wpdb, $table ) {
	return $wpdb->get_row(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
		$wpdb->prepare( "SELECT * FROM {$table} WHERE token_hash = %s", BW_Lead_AI_Handoff_Store::hash_token( $token ) ),
		ARRAY_A
	);
};

$conversions_of = function ( $token ) use ( $row_for ) {
	$row = $row_for( $token );
	if ( ! $row || empty( $row['conversions'] ) ) {
		return array();
	}
	$list = json_decode( (string) $row['conversions'], true );
	return is_array( $list ) ? $list : array();
};

// =========================================================================
$section( '(a) the schema upgrade adds the column to an existing install' );
{
	// dbDelta ran above, on a table that already had rows — the upgrade path
	// every live site takes, not the fresh-create path.
	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
	$columns = $wpdb->get_col( "SHOW COLUMNS FROM {$table}" );
	$ok( 'the conversions column exists', in_array( 'conversions', $columns, true ), implode( ',', $columns ) );
	$same( 'no other column was added or dropped with it', array_values( array_diff( $columns, $columns_before ) ), array_diff( $columns, $columns_before ) ? array( 'conversions' ) : array() );
	$same( 'nothing was removed', array_values( array_diff( $columns_before, $columns ) ), array() );
	$same( 'the schema option is at the version the code wants', (int) get_option( BW_Lead_AI_Handoff_Store::SCHEMA_OPTION ), BW_Lead_AI_Handoff_Store::SCHEMA_VERSION );
	$same( 'no rows were lost to the upgrade', (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ), $rows_before ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
}

// =========================================================================
$section( '(a2) the Reports page: a chart on day one, and a table of four columns' );
{
	/*
	 * A site with its first lead used to get a dashboard with the top half
	 * missing. Two gates did it — the chart wants two weeks, and the whole page
	 * short-circuits under ten leads — and the visible result is a page that
	 * reads as broken rather than as new, which is what rian reported.
	 *
	 * Below ten leads the page still SAYS the numbers are too few to read a trend
	 * from. That sentence is the thing that stops a reader over-reading three
	 * bars; withholding the chart as well was belt and braces at the cost of the
	 * screen looking wrong on the day somebody first opens it.
	 */
	// Channel names unique to this section. Sharing them with a later section
	// makes its lead count wrong in a way that looks like a merge bug — which is
	// exactly what happened, and the suite caught it.
	$ads    = $visit( $now_ms - ( 3600 * $MS ), 'ZZ Chart Ads', 'chartads', 'ppc' );
	$social = $visit( $now_ms - ( 10 * DAY_IN_SECONDS * $MS ), 'ZZ Chart Social', 'chartsocial', 'social' );

	// One lead in and out of the same bucket, one that switched — so the expander
	// has both a row worth showing and a row that must now be hidden.
	foreach ( array( array( $ads ), array( $social, $ads ) ) as $path ) {
		$t      = BW_Lead_AI_Handoff_Store::mint( $payload_for( $path, array( $view( $now_ms - ( 3500 * $MS ), 'https://example.test/c' ) ) ), 60 );
		$made[] = $t;
		BW_Lead_AI_Handoff_Store::save( $t, 'Contact Us', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
	}

	$was = wp_get_current_user()->ID;
	$who = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ) );
	if ( $who ) {
		wp_set_current_user( (int) $who[0] );
	}
	$_GET     = array( 'page' => BW_Lead_AI_Admin::REPORTS_SLUG, 'range' => 'all' );
	$_REQUEST = $_GET;
	ob_start();
	BW_Lead_AI_Admin::instance()->render_reports_page();
	$page = ob_get_clean();
	wp_set_current_user( $was );

	$ok( 'the chart is drawn even on a handful of leads', false !== strpos( $page, '<svg' ), 'no svg' );

	// The sparse branch is the one that used to withhold the chart, so assert it
	// is the branch being exercised — and say so plainly when it is not, rather
	// than passing quietly on a site that has outgrown the threshold.
	$leads_now = (int) BW_Lead_AI_Reports::build( 'all', 'last', 'channels' )['totals']['leads'];
	if ( $leads_now < BW_Lead_AI_Reports::SPARSE_LEADS ) {
		$ok( 'and the page still says the numbers are too few for a trend', false !== stripos( $page, 'too few to read a trend' ) );
	} else {
		echo "  SKIP  sparse-branch check — this site has {$leads_now} leads, past the threshold of "
			. BW_Lead_AI_Reports::SPARSE_LEADS . "\n";
	}

	if ( preg_match( '#<table class="widefat"><thead><tr>(.*?)</tr>#s', $page, $head ) ) {
		preg_match_all( '#<th[^>]*>(.*?)</th>#s', $head[1], $th );
		$heads = array_map( function ( $x ) { return trim( wp_strip_all_tags( $x ) ); }, $th[1] );
		$same( 'the table is four columns', count( $heads ), 4 );
		$ok( 'Came back is gone', ! in_array( 'Came back', $heads, true ), implode( '|', $heads ) );
		$ok( 'Assisted is gone', ! in_array( 'Assisted', $heads, true ), implode( '|', $heads ) );
	} else {
		$ok( 'the breakdown table rendered', false, 'no table found' );
	}

	// Every row — channel, tail, subrow — must match the header width, or the
	// columns shear apart further down the table.
	preg_match_all( '#<tr[^>]*>((?:\s*<td.*?</td>\s*)+)</tr>#s', $page, $body, PREG_SET_ORDER );
	$widths = array();
	foreach ( $body as $r ) {
		$cells = preg_match_all( '#<td#', $r[1] );
		preg_match_all( '#colspan="(\d+)"#', $r[1], $spans );
		foreach ( $spans[1] as $span ) {
			$cells += ( (int) $span - 1 );
		}
		$widths[ $cells ] = true;
	}
	$same( 'every row is four cells wide', array_keys( $widths ), array( 4 ) );

	$ok( 'no same-bucket subrow is offered', false === stripos( $page, 'the same' ), 'still there' );
	$ok( 'a genuine difference is named "Originally came through"', false !== strpos( $page, 'Originally came through' ) );

	// An expander that promises rows which are not rendered is a broken promise
	// to a screen reader, and skipping the same-bucket entries could cause it.
	preg_match_all( '#<tr class="bw-report-sub"[^>]*id="([^"]+)"#', $page, $rendered );
	preg_match_all( '#aria-controls="([^"]+)"#', $page, $promised );
	$claimed = array();
	foreach ( $promised[1] as $list ) {
		$claimed = array_merge( $claimed, explode( ' ', $list ) );
	}
	$same( 'every id an expander points at exists', array_values( array_diff( $claimed, $rendered[1] ) ), array() );
}

// =========================================================================
$section( '(b) a destination reporting a submission records when it happened' );
{
	$v1     = $visit( $now_ms - ( 3600 * $MS ), 'ZZ Social', 'facebook', 'social', 'spring-open-day' );
	$views  = array( $view( $now_ms - ( 3500 * $MS ), 'https://example.test/apply' ), $view( $now_ms - ( 3400 * $MS ), 'https://example.test/fees' ) );
	$token  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v1 ), $views ), 60 );
	$made[] = $token;

	$saved = BW_Lead_AI_Handoff_Store::save( $token, 'Enrolment enquiry', 30, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	$ok( 'the confirm saved the record', true === $saved );

	$list = $conversions_of( $token );
	$same( 'exactly one conversion is recorded', count( $list ), 1 );

	$c = $list ? $list[0] : array();
	$same( 'it carries the form name the destination sent', isset( $c['context'] ) ? $c['context'] : null, 'Enrolment enquiry' );
	$same( 'it knows the destination reported it', isset( $c['via'] ) ? $c['via'] : null, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	$same( 'it is anchored to the visit that was running', isset( $c['visit_ts'] ) ? (int) $c['visit_ts'] : null, (int) $v1['ts'] );
	$same( 'and to the last thing they did before it', isset( $c['after_ts'] ) ? (int) $c['after_ts'] : null, (int) $views[1]['ts'] );
	$same( 'it froze the channel', isset( $c['channel'] ) ? $c['channel'] : null, 'ZZ Social' );
	$same( 'it froze the campaign', isset( $c['campaign'] ) ? $c['campaign'] : null, 'spring-open-day' );
	$ok( 'it carries a server timestamp', ! empty( $c['ts'] ) && false !== strtotime( (string) $c['ts'] . ' +00:00' ), isset( $c['ts'] ) ? $c['ts'] : 'missing' );

	// THE REGRESSION. The beacon replaces the payload with the browser's newer
	// journey. Anything the server had written into the payload is gone at this
	// point; the conversion must not be.
	$v2 = $visit( $now_ms - ( 60 * $MS ), 'ZZ Paid', 'google', 'cpc', 'always-on' );
	$row = $row_for( $token );
	BW_Lead_AI_Handoff_Store::append_to_anchor( (int) $row['id'], $payload_for( array( $v1, $v2 ), $views ) );

	$after = $conversions_of( $token );
	$same( 'the conversion survives the update beacon', count( $after ), 1 );
	$same( 'unchanged by it', wp_json_encode( $after ), wp_json_encode( $list ) );

	$moved = json_decode( (string) $row_for( $token )['payload'], true );
	$same( 'while the payload itself DID drift to the newer visit', $moved['channel'], 'ZZ Paid' );
}

// =========================================================================
$section( '(b2) the anchor is the last thing they did, not the last page' );
{
	// The cross-domain shape: the last thing on record before a submission is the
	// click that LEFT for the form, which happens after the page it was clicked
	// on. Anchoring to the last pageview puts the submission above that click —
	// the journey then reads "submitted the form, then left for the form".
	$v1    = $visit( $now_ms - ( 7200 * $MS ), 'ZZ Social', 'facebook', 'social' );
	$views = array( $view( $now_ms - ( 7100 * $MS ), 'https://example.test/apply' ) );
	$leave = array( 'ts' => $now_ms - ( 7000 * $MS ), 'date' => '', 'type' => 'outbound', 'label' => 'forms.example.test', 'detail' => '', 'page' => 'https://example.test/apply' );

	$payload            = $payload_for( array( $v1 ), $views );
	$journey            = json_decode( $payload['journey'], true );
	$journey['events']  = array( $leave );
	$payload['journey'] = wp_json_encode( $journey );

	$token  = BW_Lead_AI_Handoff_Store::mint( $payload, 60 );
	$made[] = $token;
	BW_Lead_AI_Handoff_Store::save( $token, 'Off-site form', 30, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );

	$c = $conversions_of( $token )[0];
	$same( 'the anchor is the outbound click, not the page it was on', (int) $c['after_ts'], (int) $leave['ts'] );
	$ok( 'so it sorts after that click on the timeline', (int) $c['after_ts'] > (int) $views[0]['ts'] );
}

// =========================================================================
$section( '(c) last click stays with the visit the enquiry was made on' );
{
	// rian's journey, exactly: enquire on a social visit, come back a fortnight
	// later through a paid click, do not enquire again.
	$social = $visit( $now_ms - ( 20 * DAY_IN_SECONDS * $MS ), 'ZZ Social', 'facebook', 'social' );
	$paid   = $visit( $now_ms - ( 6 * DAY_IN_SECONDS * $MS ), 'ZZ Paid', 'google', 'cpc' );
	$views  = array( $view( $now_ms - ( 20 * DAY_IN_SECONDS * $MS ) + 1000, 'https://example.test/apply' ) );

	$token  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $social ), $views ), 60 );
	$made[] = $token;
	BW_Lead_AI_Handoff_Store::save( $token, 'Enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );

	// They come back through the ad; the beacon reports the grown journey.
	$row = $row_for( $token );
	BW_Lead_AI_Handoff_Store::append_to_anchor( (int) $row['id'], $payload_for( array( $social, $paid ), $views ) );

	// The real resolver, reached the way fetch() reaches it.
	$resolve = new ReflectionMethod( 'BW_Lead_AI_Reports', 'resolve' );
	$resolve->setAccessible( true );
	$conversion = new ReflectionMethod( 'BW_Lead_AI_Reports', 'conversion' );
	$conversion->setAccessible( true );

	$fresh   = $row_for( $token );
	$payload = json_decode( (string) $fresh['payload'], true );
	$c       = $conversion->invoke( null, $fresh );
	$out     = $resolve->invoke( null, $payload, true, $c );

	$same( 'the credited channel is the one they enquired through', $out['channel'], 'ZZ Social' );
	$same( 'and its source', $out['source'], 'facebook' );
	$same( 'and its medium', $out['medium'], 'social' );
	$same( 'first click is untouched', $out['first_channel'], 'ZZ Social' );

	// Without the conversion this is what the report said, and it is the bug.
	$drifted = $resolve->invoke( null, $payload, true, null );
	$same( 'with no conversion recorded, the ad takes the credit — the bug', $drifted['channel'], 'ZZ Paid' );
	$ok( 'so the conversion is what changes the answer', $out['channel'] !== $drifted['channel'] );

	// End to end, through the page the reader actually looks at.
	$report = BW_Lead_AI_Reports::build( 'all', 'last', 'channels' );
	$rows   = array();
	foreach ( $report['channels'] as $r ) {
		$rows[ $r['channel'] ] = (int) $r['leads'];
	}
	$ok( 'the Reports page counts it under the enquiry channel', ! empty( $rows['ZZ Social'] ), wp_json_encode( array_slice( $rows, 0, 8 ) ) );
	$ok( 'and not under the later paid visit', empty( $rows['ZZ Paid'] ), wp_json_encode( array_slice( $rows, 0, 8 ) ) );
}

// =========================================================================
$section( '(d) a retried confirm is the same submission, not a second one' );
{
	$v1     = $visit( $now_ms - ( 900 * $MS ), 'ZZ Social', 'facebook', 'social' );
	$token  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v1 ), array( $view( $now_ms - ( 880 * $MS ), 'https://example.test/x' ) ) ), 60 );
	$made[] = $token;

	// A tag manager firing twice, the second time with a context the first lacked.
	BW_Lead_AI_Handoff_Store::save( $token, '', 30, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	BW_Lead_AI_Handoff_Store::save( $token, 'Late-arriving name', 30, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	BW_Lead_AI_Handoff_Store::save( $token, 'Late-arriving name', 30, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );

	$list = $conversions_of( $token );
	$same( 'three confirms are still one conversion', count( $list ), 1 );
	$same( 'and the name that arrived late was kept', $list ? $list[0]['context'] : null, 'Late-arriving name' );
}

// =========================================================================
$section( '(e) a form on this site records the same thing, its own way' );
{
	$v1     = $visit( $now_ms - ( 500 * $MS ), 'ZZ Organic', 'google', 'organic' );
	$token  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v1 ), array( $view( $now_ms - ( 490 * $MS ), 'https://example.test/contact' ) ) ), 60 );
	$made[] = $token;

	BW_Lead_AI_Handoff_Store::save( $token, 'Contact form', 30, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	$list = $conversions_of( $token );
	$same( 'one conversion', count( $list ), 1 );
	$same( 'marked as this site seeing its own form', $list ? $list[0]['via'] : null, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
	$ok( 'so the journey screen will not claim a destination reported it', BW_Lead_AI_Handoff_Store::VIA_CONFIRM !== ( $list ? $list[0]['via'] : '' ) );
}

// =========================================================================
$section( '(f) reprocess recovers the moment on a journey stored before this' );
{
	// A record exactly as the old code left it: confirmed a fortnight ago, with a
	// later visit appended after that, and no conversion.
	$enquired_at = time() - ( 14 * DAY_IN_SECONDS );
	$social      = $visit( ( $enquired_at - 600 ) * $MS, 'ignored, reprocess re-derives this', 'facebook', 'social' );
	$paid        = $visit( ( time() - ( 2 * DAY_IN_SECONDS ) ) * $MS, 'ignored too', 'google', 'cpc' );
	$views       = array( $view( ( $enquired_at - 300 ) * $MS, 'https://example.test/apply' ) );

	$token  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $social, $paid ), $views ), 60 );
	$made[] = $token;
	BW_Lead_AI_Handoff_Store::save( $token, 'Old enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );

	// Backdate it and strip the conversion, so this is genuinely a pre-1.8.5 row.
	$wpdb->update(
		$table,
		array( 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $enquired_at ), 'created_at' => gmdate( 'Y-m-d H:i:s', $enquired_at ), 'conversions' => null ),
		array( 'token_hash' => BW_Lead_AI_Handoff_Store::hash_token( $token ) ),
		array( '%s', '%s', '%s' ),
		array( '%s' )
	);
	$same( 'starts with no conversion', count( $conversions_of( $token ) ), 0 );

	$dry = BW_Lead_AI_Reprocess::run( 0, false );
	$same( 'a dry run still writes nothing', count( $conversions_of( $token ) ), 0 );
	$ok( 'and it reports there is something to do', $dry['changed'] > 0, 'changed=' . $dry['changed'] );

	BW_Lead_AI_Reprocess::run( 0, true );

	$list = $conversions_of( $token );
	$same( 'the conversion is recovered', count( $list ), 1 );
	$c = $list ? $list[0] : array();
	$same( 'anchored to the visit they enquired on', (int) $c['visit_ts'], (int) $social['ts'] );
	$ok( 'and NOT to the visit that came after it', (int) $c['visit_ts'] !== (int) $paid['ts'] );
	$same( 'the form name comes off the record', $c['context'], 'Old enquiry' );
	$same( 'how it was reported is left blank rather than guessed', $c['via'], '' );

	// The channel is whatever the site's own rules make of facebook/social — the
	// point is that it is the EARLIER visit's, not the later one's.
	$journey = json_decode( json_decode( (string) $row_for( $token )['payload'], true )['journey'], true );
	$visits  = $journey['visits'];
	$early   = $visits[0]['channel'];
	$late    = $visits[ count( $visits ) - 1 ]['channel'];
	$same( 'the frozen channel is the earlier visit’s', $c['channel'], $early );
	$ok( 'which is a different channel from the later visit', $early !== $late, "early={$early} late={$late}" );

	// Idempotence — the property that makes the button safe to press twice.
	$second = BW_Lead_AI_Reprocess::run( 0, true );
	$again  = $conversions_of( $token );
	$same( 'a second run leaves it byte-identical', wp_json_encode( $again ), wp_json_encode( $list ) );
	$ok( 'and reports nothing further to do on it', $second['changed'] < $dry['changed'], 'first=' . $dry['changed'] . ' second=' . $second['changed'] );
}

// =========================================================================
$section( '(g) reprocess declines when it cannot tell, and never overwrites' );
{
	// Every visit is well AFTER the recorded confirmation. Either the clocks are
	// badly out or the record is odd; guessing would freeze a channel that may be
	// wrong, permanently. It must leave the row alone.
	$enquired_at = time() - ( 30 * DAY_IN_SECONDS );
	$later       = $visit( ( time() - 3600 ) * $MS, 'x', 'facebook', 'social' );
	$token       = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $later ), array( $view( ( time() - 3500 ) * $MS, 'https://example.test/a' ) ) ), 60 );
	$made[]      = $token;
	BW_Lead_AI_Handoff_Store::save( $token, 'Unknowable', 3650, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	$wpdb->update(
		$table,
		array( 'confirmed_at' => gmdate( 'Y-m-d H:i:s', $enquired_at ), 'created_at' => gmdate( 'Y-m-d H:i:s', $enquired_at ), 'conversions' => null ),
		array( 'token_hash' => BW_Lead_AI_Handoff_Store::hash_token( $token ) ),
		array( '%s', '%s', '%s' ),
		array( '%s' )
	);

	// A live conversion, recorded at the time. A reconstruction must never
	// overwrite a fact.
	$live_visit = $visit( $now_ms - ( 200 * $MS ), 'ZZ Live', 'linkedin', 'social' );
	$live       = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $live_visit ), array( $view( $now_ms - ( 190 * $MS ), 'https://example.test/b' ) ) ), 60 );
	$made[]     = $live;
	BW_Lead_AI_Handoff_Store::save( $live, 'Live enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
	$live_before = $conversions_of( $live );

	BW_Lead_AI_Reprocess::run( 0, true );

	$same( 'the unknowable journey is left with no conversion', count( $conversions_of( $token ) ), 0 );
	$same( 'the live conversion is untouched', wp_json_encode( $conversions_of( $live ) ), wp_json_encode( $live_before ) );
	$same( 'and it still says the destination reported it', $conversions_of( $live )[0]['via'], BW_Lead_AI_Handoff_Store::VIA_CONFIRM );
}

// =========================================================================
$section( '(h) every conversion lands in exactly one visit on the timeline' );
{
	// A journey keeps only the first and last few visits, so the visit a
	// conversion names can be gone by the time anyone reads the page. Dropping it
	// would remove the one row that says they converted, on the longest journeys.
	$admin = new ReflectionMethod( 'BW_Lead_AI_Handoff_Admin', 'conversions_by_visit' );
	$admin->setAccessible( true );
	$instance = BW_Lead_AI_Handoff_Admin::instance();

	$visits = array( $visit( 1000, 'A', 'a', 'x' ), $visit( 2000, 'B', 'b', 'x' ), $visit( 3000, 'C', 'c', 'x' ) );

	$placed = $admin->invoke( $instance, array( array( 'visit_ts' => 2000 ) ), $visits );
	$same( 'a conversion goes in the visit it names', array_keys( $placed ), array( 1 ) );

	$orphan = $admin->invoke( $instance, array( array( 'visit_ts' => 999999 ) ), $visits );
	$same( 'one naming a trimmed-away visit falls to the last', array_keys( $orphan ), array( 2 ) );

	$anchorless = $admin->invoke( $instance, array( array( 'visit_ts' => 0 ) ), $visits );
	$same( 'so does one with no anchor at all', array_keys( $anchorless ), array( 2 ) );

	$none = $admin->invoke( $instance, array( array( 'visit_ts' => 1000 ) ), array() );
	$same( 'and a journey with no visits places nothing', $none, array() );

	$two = $admin->invoke( $instance, array( array( 'visit_ts' => 1000 ), array( 'visit_ts' => 1000 ) ), $visits );
	$same( 'two conversions in one visit both survive', count( $two[0] ), 2 );
}

// =========================================================================
$section( '(j) the same person enquiring twice is one lead, not two' );
{
	$key = BW_Lead_AI_Handoff_Store::generate_token();

	// First enquiry, on a paid click. This is the one that won them.
	$ads    = $visit( $now_ms - ( 10 * DAY_IN_SECONDS * $MS ), 'ZZ Ads', 'google', 'cpc', 'spring' );
	$first  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $ads ), array( $view( $now_ms - ( 10 * DAY_IN_SECONDS * $MS ) + 1000, 'https://example.test/a' ) ) ), 60 );
	$made[] = $first;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $first, $key );
	BW_Lead_AI_Handoff_Store::save( $first, 'Enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	// Weeks later they come back through a referral and use the contact form to
	// ask a follow-up question. Same device, so the same visitor key.
	$ref    = $visit( $now_ms - ( 60 * $MS ), 'ZZ Referral', 'somesite.test', 'referral' );
	$second = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $ads, $ref ), array( $view( $now_ms - ( 50 * $MS ), 'https://example.test/b' ) ) ), 60 );
	$made[] = $second;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $second, $key );
	$ok( 'the second submission saved', true === BW_Lead_AI_Handoff_Store::save( $second, 'Follow-up question', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION ) );

	$anchor = $row_for( $first );
	$alias  = $row_for( $second );

	$same( 'the first record holds both enquiries', count( $conversions_of( $first ) ), 2 );
	$same( 'the second record holds none of its own', count( $conversions_of( $second ) ), 0 );
	$same( 'and is marked merged, so no count can see it', $alias['status'], BW_Lead_AI_Handoff_Store::STATUS_MERGED );
	$same( 'pointing at the journey it joined', (int) $alias['merged_into'], (int) $anchor['id'] );
	$same( 'its payload is cleared — the survivor holds it', (string) $alias['payload'], '' );
	$same( 'the anchor is still SAVED', $anchor['status'], BW_Lead_AI_Handoff_Store::STATUS_SAVED );

	// CREDIT MUST NOT MOVE. The follow-up question did not win this lead.
	$list = $conversions_of( $first );
	$same( 'the first conversion is still the paid click', $list[0]['channel'], 'ZZ Ads' );
	$same( 'the second is recorded but is the later one', $list[1]['channel'], 'ZZ Referral' );

	$resolve = new ReflectionMethod( 'BW_Lead_AI_Reports', 'resolve' );
	$resolve->setAccessible( true );
	$conversion = new ReflectionMethod( 'BW_Lead_AI_Reports', 'conversion' );
	$conversion->setAccessible( true );
	$fresh = $row_for( $first );
	$out   = $resolve->invoke( null, json_decode( (string) $fresh['payload'], true ), true, $conversion->invoke( null, $fresh ) );
	$same( 'so last click still credits the paid click', $out['channel'], 'ZZ Ads' );

	// One lead, which is the whole point of merging rather than minting.
	$report = BW_Lead_AI_Reports::build( 'all', 'last', 'channels' );
	$rows   = array();
	foreach ( $report['channels'] as $r ) {
		$rows[ $r['channel'] ] = (int) $r['leads'];
	}
	$same( 'the reports count one lead, not two', isset( $rows['ZZ Ads'] ) ? $rows['ZZ Ads'] : 0, 1 );
	$ok( 'and none at all for the follow-up channel', empty( $rows['ZZ Referral'] ), wp_json_encode( array_slice( $rows, 0, 8 ) ) );

	// THE CRM LINK. A journey link was written into someone's record the moment
	// the second enquiry converted. It must still open the journey.
	$viewed = BW_Lead_AI_Handoff_Store::get_record( $second );
	$ok( 'the merged-away token still opens a journey', ! empty( $viewed ), 'got nothing back' );
	$same( 'and it is the surviving one', (int) $viewed['id'], (int) $anchor['id'] );
	$same( 'reached by record hash too', (int) BW_Lead_AI_Handoff_Store::get_record_by_hash( $alias['token_hash'] )['id'], (int) $anchor['id'] );

	// Identity arrives AFTER the save, carrying the token that was merged away.
	BW_Lead_AI_Handoff_Store::attach_identity( $second, array( 'first' => 'Zed', 'email' => 'zed@example.test' ), null );
	$ok( 'a name sent with the merged token lands on the journey', 'Zed' === ( $row_for( $first )['identity'] ? json_decode( $row_for( $first )['identity'], true )['first'] : '' ) );
	$ok( 'and not on the alias', empty( $row_for( $second )['identity'] ) );
}

// =========================================================================
$section( '(k) what does NOT merge' );
{
	// A different device is a different journey. Nothing to merge onto.
	$v      = $visit( $now_ms - ( 300 * $MS ), 'ZZ Solo', 'bing', 'organic' );
	$alone  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v ), array( $view( $now_ms - ( 290 * $MS ), 'https://example.test/c' ) ) ), 60 );
	$made[] = $alone;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $alone, BW_Lead_AI_Handoff_Store::generate_token() );
	BW_Lead_AI_Handoff_Store::save( $alone, 'Solo', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
	$same( 'a different visitor key starts its own journey', $row_for( $alone )['status'], BW_Lead_AI_Handoff_Store::STATUS_SAVED );
	$same( 'with its own conversion', count( $conversions_of( $alone ) ), 1 );

	// A HELD record is a maybe, not a lead — it is not a merge target.
	$key    = BW_Lead_AI_Handoff_Store::generate_token();
	$held   = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v ), array() ), 60 );
	$made[] = $held;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $held, $key );

	$later  = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $v ), array() ), 60 );
	$made[] = $later;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $later, $key );
	BW_Lead_AI_Handoff_Store::save( $later, 'First real one', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	$same( 'a held record is not merged onto', $row_for( $later )['status'], BW_Lead_AI_Handoff_Store::STATUS_SAVED );
	$same( 'and the held one is untouched', $row_for( $held )['status'], BW_Lead_AI_Handoff_Store::STATUS_HELD );

	// A retry of the SAME submission is not a second enquiry.
	BW_Lead_AI_Handoff_Store::save( $later, 'First real one', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
	$same( 'a retried confirm does not merge into itself', count( $conversions_of( $later ) ), 1 );
	$same( 'and stays SAVED', $row_for( $later )['status'], BW_Lead_AI_Handoff_Store::STATUS_SAVED );
}

// =========================================================================
$section( '(l) snapshot mode merges the enquiry but not the browsing' );
{
	// rian chose this deliberately, against the recommendation: the update mode
	// governs whether the BROWSING HISTORY keeps being rewritten, not whether a
	// second enquiry attaches to the person who made it.
	// NOT named $snapshot: that is the table snapshot the cleanup section restores
	// from, and shadowing it here silently turned the restore into a walk over a
	// settings array. The test then "passed" everything and left the site dirty.
	$saved_settings = get_option( BW_LEAD_AI_OPTION );
	$as_snapshot    = BW_Lead_AI_Settings::get();
	$as_snapshot['journey_update_mode'] = 'snapshot';
	update_option( BW_LEAD_AI_OPTION, $as_snapshot );
	$same( 'the site is in snapshot mode for this section', BW_Lead_AI_Settings::journey_update_mode(), 'snapshot' );

	$key   = BW_Lead_AI_Handoff_Store::generate_token();
	$one   = $visit( $now_ms - ( 5 * DAY_IN_SECONDS * $MS ), 'ZZ Frozen', 'google', 'organic' );
	$first_views = array( $view( $now_ms - ( 5 * DAY_IN_SECONDS * $MS ) + 1000, 'https://example.test/frozen' ) );

	$t1     = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $one ), $first_views ), 60 );
	$made[] = $t1;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $t1, $key );
	BW_Lead_AI_Handoff_Store::save( $t1, 'Frozen enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
	$frozen_payload = $row_for( $t1 )['payload'];

	$two    = $visit( $now_ms - ( 30 * $MS ), 'ZZ Later', 'facebook', 'social' );
	$t2     = BW_Lead_AI_Handoff_Store::mint( $payload_for( array( $one, $two ), array_merge( $first_views, array( $view( $now_ms - ( 20 * $MS ), 'https://example.test/new-page' ) ) ) ), 60 );
	$made[] = $t2;
	BW_Lead_AI_Handoff_Store::attach_visitor_key( $t2, $key );
	BW_Lead_AI_Handoff_Store::save( $t2, 'Second frozen enquiry', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	$same( 'the enquiry still merges in snapshot mode', count( $conversions_of( $t1 ) ), 2 );
	$same( 'the second row is still retired as an alias', $row_for( $t2 )['status'], BW_Lead_AI_Handoff_Store::STATUS_MERGED );
	$same( 'but the browsing history is left frozen', $row_for( $t1 )['payload'], $frozen_payload );
	$ok( 'so the page they read on the second visit is NOT in it', false === strpos( (string) $row_for( $t1 )['payload'], 'new-page' ) );

	update_option( BW_LEAD_AI_OPTION, $saved_settings );
	$same( 'settings put back', BW_Lead_AI_Settings::journey_update_mode(), BW_Lead_AI_Settings::journey_update_mode() );
}

// =========================================================================
$section( '(m) trimming never drops the enquiry that won the lead' );
{
	$trim = new ReflectionMethod( 'BW_Lead_AI_Handoff_Store', 'trim_conversions' );
	$trim->setAccessible( true );

	$list = array();
	for ( $i = 0; $i < BW_Lead_AI_Handoff_Store::MAX_CONVERSIONS + 5; $i++ ) {
		$list[] = array( 'channel' => 'ZZ ' . $i, 'context' => '', 'ts' => '', 'visit_ts' => 0, 'after_ts' => 0, 'via' => '' );
	}
	$out = $trim->invoke( null, $list );

	$same( 'the list is bounded', count( $out ), BW_Lead_AI_Handoff_Store::MAX_CONVERSIONS );
	$same( 'the FIRST is kept — it is the one credited', $out[0]['channel'], 'ZZ 0' );
	$same( 'the newest is kept too', $out[ count( $out ) - 1 ]['channel'], 'ZZ ' . ( BW_Lead_AI_Handoff_Store::MAX_CONVERSIONS + 4 ) );
	$ok( 'and it is the middle that goes', 'ZZ 1' !== $out[1]['channel'], $out[1]['channel'] );

	$short = $trim->invoke( null, array_slice( $list, 0, 3 ) );
	$same( 'a short list is untouched', count( $short ), 3 );
}

// =========================================================================
$section( '(n) the timeline reads newest-first, and the clock never goes backwards' );
{
	/*
	 * The shape rian hit on a real journey. The browser reports the form-start
	 * event AFTER the save — it fired before the submission, but the payload had
	 * already gone — so anchoring the conversion to "the last thing we knew about"
	 * put it above events showing an earlier time. The column read
	 * 10:38, 10:40, 10:38, 10:40 and the sequence was unreadable.
	 */
	$base = time() - 3600;
	$v1ts = $base * 1000;
	$v2ts = ( $base + 3600 ) * 1000;
	$one  = $visit( $v1ts, 'ZZ Ads', 'adwords', 'ppc' );
	$two  = $visit( $v2ts, 'ZZ Ads', 'adwords', 'ppc' );

	$payload = $payload_for( array( $one ), array( $view( $v1ts, 'https://example.test/contact-us/' ) ) );
	$token   = BW_Lead_AI_Handoff_Store::mint( $payload, 60 );
	$made[]  = $token;
	BW_Lead_AI_Handoff_Store::save( $token, 'Contact Us', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	// Everything the browser reports afterwards, including the event that fired
	// BEFORE the submission and arrived after it.
	$grown            = json_decode( $payload['journey'], true );
	$grown['visits']  = array( $one, $two );
	$grown['last']    = $two;
	$grown['events']  = array(
		array( 'ts' => $v1ts + 30000, 'date' => '', 'type' => 'form_start', 'label' => 'gform_3', 'detail' => '', 'page' => 'https://example.test/contact-us/' ),
		array( 'ts' => $v1ts + 150000, 'date' => '', 'type' => 'outbound', 'label' => 'calendly.com', 'detail' => '', 'page' => 'https://example.test/contact-us/' ),
	);
	$grown['views'][] = $view( $v1ts + 160000, 'https://example.test/thanks/' );
	$grown['views'][] = $view( $v2ts + 5000, 'https://example.test/contact-us/' );
	BW_Lead_AI_Handoff_Store::append_to_anchor( (int) $row_for( $token )['id'], array_merge( $payload, array( 'journey' => wp_json_encode( $grown ) ) ) );

	$was = wp_get_current_user()->ID;
	$who = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ) );
	if ( $who ) {
		wp_set_current_user( (int) $who[0] );
	}
	$_GET     = array( 'token' => $token );
	$_REQUEST = $_GET;
	ob_start();
	BW_Lead_AI_Handoff_Admin::instance()->render_viewer();
	$html = ob_get_clean();
	wp_set_current_user( $was );

	preg_match_all( '#<div class="bw-journey-visit">.*?<h3[^>]*>(.*?)</h3>(.*?)</ul>#s', $html, $blocks, PREG_SET_ORDER );
	$same( 'both visits rendered', count( $blocks ), 2 );

	if ( 2 === count( $blocks ) ) {
		$head_top    = strtotime( trim( explode( '·', wp_strip_all_tags( $blocks[0][1] ) )[0] ) );
		$head_bottom = strtotime( trim( explode( '·', wp_strip_all_tags( $blocks[1][1] ) )[0] ) );
		$ok( 'the NEWEST visit is at the top', $head_top > $head_bottom, "$head_top vs $head_bottom" );
	}

	// Within each visit, times must never increase going down the page.
	$backwards = array();
	foreach ( $blocks as $block ) {
		preg_match_all( '#<span class="bw-journey-time">(.*?)</span>#s', $block[2], $times );
		$previous = null;
		foreach ( $times[1] as $shown ) {
			$at = strtotime( trim( wp_strip_all_tags( $shown ) ) );
			if ( false !== $at && null !== $previous && $at > $previous ) {
				$backwards[] = trim( wp_strip_all_tags( $shown ) );
			}
			$previous = $at;
		}
	}
	$ok( 'the clock never jumps forward going down a visit', empty( $backwards ), implode( ', ', $backwards ) );

	$ok( 'the conversion is on the page', false !== strpos( $html, 'bw-journey-item--conversion' ) );
	$ok( 'and stayed inside a visit rather than being clamped out of the timeline', 2 === count( $blocks ) );
}

// =========================================================================
$section( '(i) cleanup — the site is left as it was found' );
{
	foreach ( $made as $token ) {
		$wpdb->delete( $table, array( 'token_hash' => BW_Lead_AI_Handoff_Store::hash_token( $token ) ), array( '%s' ) );
	}

	// Guard the restore's own input before using it. A section that reuses the
	// name `$snapshot` for something else silently turns this loop into a walk
	// over the wrong thing — which happened, and left the site dirty behind a
	// screen of green PASSes. A named failure beats a stack trace.
	$ok( 'the table snapshot is still a list of rows', $snapshot && isset( $snapshot[0]['token_hash'] ), gettype( $snapshot ) );

	// Reprocess ran over the real rows too. Put them back exactly — including any
	// column it set that was NULL before, which is what a null value in the data
	// array restores.
	foreach ( (array) $snapshot as $original ) {
		if ( ! is_array( $original ) || ! isset( $original['id'] ) ) {
			continue;
		}
		$id = (int) $original['id'];
		unset( $original['id'] );
		$wpdb->update( $table, $original, array( 'id' => $id ), null, array( '%d' ) );
	}
	if ( false === $reprocess_before ) {
		delete_option( BW_Lead_AI_Reprocess::LAST_RUN_OPTION );
	} else {
		update_option( BW_Lead_AI_Reprocess::LAST_RUN_OPTION, $reprocess_before, false );
	}

	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
	$after = $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A );
	$same( 'no test rows are left behind', count( $after ), count( $snapshot ) );
	$same( 'the surviving rows are the ones that were here', wp_list_pluck( $after, 'id' ), $snapshot_ids );
	$same( 'and every one of them is byte-identical', md5( wp_json_encode( $after ) ), md5( wp_json_encode( $snapshot ) ) );
	$same( 'the settings option was never written', md5( (string) maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) ), $settings_md5 );
}

// =========================================================================
echo "\n" . str_repeat( '=', 52 ) . "\n";
if ( $failures ) {
	echo 'FAILED — ' . count( $failures ) . " of {$checks} checks\n";
	foreach ( $failures as $f ) {
		echo "  - {$f}\n";
	}
	exit( 1 );
}
echo "PASS — {$checks} checks\n";
