<?php
/**
 * Rebuild journey history from Gravity Forms entries that carry a {bw:summary}.
 *
 * Sites have been posting the attribution summary into a form field for months
 * before journeys were stored server-side. That text is a real record of how each
 * lead arrived — channel, campaign, first and last touch, and in the detailed
 * format the visit-by-visit page history too. This reads it back and writes proper
 * journey records, so a site that has been running the plugin arrives at the
 * reports with its actual history rather than an empty table.
 *
 * **Read-only against Gravity Forms.** Nothing here writes to a gf_* table. The
 * only writes are to the plugin's own journey and leads tables.
 *
 * **Idempotent.** Each imported record stores `gf_entry_id` in its payload; an
 * entry already present is skipped, so a re-run imports nothing twice. That makes
 * the dry run genuinely free and a partial run safe to resume.
 *
 * **Honest about gaps.** Older summaries carried only the headline attribution, so
 * those journeys get counts and touches but no page-level history. Nothing is
 * invented to fill the space — a journey with no parsed visits stores none, and
 * the raw summary text is always kept so the original is recoverable.
 *
 * Usage (from the site's WordPress root). Arguments are POSITIONAL — wp-cli
 * consumes anything that looks like a flag before the script ever sees it:
 *   wp eval-file wp-content/plugins/bw-lead-ai/tools/import-gf-history.php
 *   wp eval-file .../import-gf-history.php commit
 *   wp eval-file .../import-gf-history.php commit limit=50
 *   wp eval-file .../import-gf-history.php commit include-spam
 *   wp eval-file .../import-gf-history.php reclassify          (preview)
 *   wp eval-file .../import-gf-history.php reclassify commit
 *
 * Dry run is the default: it reports exactly what a commit would do and writes
 * nothing.
 */

if ( ! defined( 'ABSPATH' ) ) {
	fwrite( STDERR, "Run via: wp eval-file\n" );
	exit( 1 );
}

if ( ! class_exists( 'BW_Lead_AI_Handoff_Store' ) ) {
	fwrite( STDERR, "BW Lead AI is not active on this site.\n" );
	exit( 1 );
}

// ---------------------------------------------------------------- arguments

// wp-cli parses leading dashes as its OWN options and errors before the script
// runs, so these are accepted bare. A leading `--` is tolerated for anyone who
// types it out of habit.
$bwlai_argv   = isset( $args ) && is_array( $args ) ? array_map( 'strval', $args ) : array();
$bwlai_argv   = array_map( function ( $a ) { return ltrim( $a, '-' ); }, $bwlai_argv );
$bwlai_commit = in_array( 'commit', $bwlai_argv, true );
$bwlai_reclass = in_array( 'reclassify', $bwlai_argv, true );
$bwlai_spam   = in_array( 'include-spam', $bwlai_argv, true );
$bwlai_limit  = 0;
foreach ( $bwlai_argv as $bwlai_a ) {
	if ( 0 === strpos( $bwlai_a, 'limit=' ) ) {
		$bwlai_limit = max( 0, (int) substr( $bwlai_a, 6 ) );
	}
}

/**
 * Everything the importer knows how to pull out of one summary.
 *
 * Written defensively on purpose: these strings have changed shape across
 * releases, and an entry from months ago may predate half of them. Every field is
 * independently optional — a line that is missing leaves its value empty rather
 * than failing the entry.
 */
function bwlai_parse_summary( $text ) {
	$out = array(
		'channel' => '', 'source' => '', 'medium' => '', 'campaign' => '', 'term' => '', 'content' => '', 'adgroup' => '',
		'first_channel' => '', 'first_source' => '', 'first_medium' => '',
		'first_page' => '', 'last_page' => '', 'submitted_page' => '',
		'visits' => 0, 'pages' => 0, 'tagged_visits' => 0, 'events' => 0, 'days_to_conversion' => null,
		'first_visit' => '', 'last_visit' => '', 'channels' => array(),
		'journey_visits' => array(), 'interactions' => array(),
	);

	$text = str_replace( array( "\r\n", "\r" ), "\n", (string) $text );

	// Normalise the separator dash before anything reads a line.
	//
	// Some stored summaries use an EN DASH where the format writes a hyphen —
	// "Converted via – Google Ads" — most likely wptexturize somewhere between the
	// form and the database. Every prefix test here matches a literal "- ", so one
	// substituted character made those entries parse to nothing at all: no channel,
	// no landing page, no visits, no journey. They were not obviously broken
	// afterwards, they were simply absent, and the leads inside them were paid ad
	// clicks that then looked like they had no source.
	//
	// Only the spaced form is touched. Hyphens inside URLs, campaign names and
	// dates are never surrounded by spaces, so they are left alone.
	$text = str_replace( array( " \xE2\x80\x93 ", " \xE2\x80\x94 " ), ' - ', $text );

	$lines = explode( "\n", $text );

	// `source: x | medium: y | campaign: z | term: ...` — the same shape is used
	// for the converting touch, the first touch, and each visit's Tag Info line.
	$pairs = function ( $line ) {
		$found = array();
		foreach ( explode( '|', $line ) as $chunk ) {
			$bits = explode( ':', $chunk, 2 );
			if ( count( $bits ) !== 2 ) {
				continue;
			}
			$key = strtolower( trim( $bits[0] ) );
			$val = trim( $bits[1] );
			// Unresolved tag-manager placeholders are noise, not data.
			if ( '' === $val || '(not set)' === $val || preg_match( '/^\{.*\}$/', $val ) ) {
				continue;
			}
			$found[ $key ] = $val;
		}
		return $found;
	};

	$section  = 'head';
	$in_first = false;   // inside the "Originally found via" block
	// Index of the visit currently being filled, not a reference to it.
	// Appending a reference to a local, then reassigning that local for the next
	// visit, writes THROUGH the reference into the element already stored — so a
	// three-visit journey came out as three copies of the last visit, all sharing
	// its timestamp. It read as a capture bug and was a parser bug. Indices
	// cannot alias, so the shape of the mistake is unavailable here.
	$vi       = -1;

	foreach ( $lines as $raw ) {
		$line = trim( $raw );

		if ( '' === $line ) {
			continue;
		}
		if ( 0 === strpos( $line, '== Interactions ==' ) ) { $section = 'interactions'; continue; }
		if ( 0 === strpos( $line, '== Journey ==' ) )      { $section = 'journey'; continue; }
		if ( 0 === strpos( $line, '== ' ) )                { $section = 'head'; continue; }

		if ( 'head' === $section ) {
			if ( 0 === strpos( $line, 'Converted via - ' ) ) {
				$out['channel'] = trim( substr( $line, 16 ) );
				$in_first       = false;
				continue;
			}
			if ( 0 === strpos( $line, 'Originally found via - ' ) ) {
				$out['first_channel'] = trim( substr( $line, 23 ) );
				$in_first             = true;
				continue;
			}
			if ( 0 === strpos( $line, 'source:' ) || 0 === strpos( $line, 'campaign:' ) || 0 === strpos( $line, 'term:' ) ) {
				$p = $pairs( $line );
				if ( $in_first ) {
					if ( isset( $p['source'] ) ) { $out['first_source'] = $p['source']; }
					if ( isset( $p['medium'] ) ) { $out['first_medium'] = $p['medium']; }
				} else {
					foreach ( array( 'source', 'medium', 'campaign', 'term', 'content', 'adgroup' ) as $k ) {
						if ( isset( $p[ $k ] ) && '' === $out[ $k ] ) { $out[ $k ] = $p[ $k ]; }
					}
				}
				continue;
			}
			if ( 0 === strpos( $line, 'First landed on - ' ) ) { $out['first_page'] = trim( substr( $line, 18 ) ); continue; }
			if ( 0 === strpos( $line, 'Landed on - ' ) )       { $out['last_page'] = trim( substr( $line, 12 ) ); continue; }
			if ( 0 === strpos( $line, 'Submitted on - ' ) )    { $out['submitted_page'] = trim( substr( $line, 15 ) ); continue; }

			if ( 0 === strpos( $line, 'Visits - ' ) ) {
				if ( preg_match( '/Visits\s*-\s*(\d+)/i', $line, $m ) )            { $out['visits'] = (int) $m[1]; }
				if ( preg_match( '/Pages:\s*(\d+)/i', $line, $m ) )                { $out['pages'] = (int) $m[1]; }
				if ( preg_match( '/Tagged Visits:\s*(\d+)/i', $line, $m ) )        { $out['tagged_visits'] = (int) $m[1]; }
				if ( preg_match( '/Interactions:\s*(\d+)/i', $line, $m ) )         { $out['events'] = (int) $m[1]; }
				if ( preg_match( '/Days to Conversion:\s*(\d+)/i', $line, $m ) )   { $out['days_to_conversion'] = (int) $m[1]; }
				continue;
			}
			if ( 0 === strpos( $line, 'First visit - ' ) ) {
				if ( preg_match( '/First visit\s*-\s*([0-9:\- ]+)/', $line, $m ) )  { $out['first_visit'] = trim( $m[1] ); }
				if ( preg_match( '/Last visit:\s*([0-9:\- ]+)/', $line, $m ) )      { $out['last_visit'] = trim( $m[1] ); }
				continue;
			}
			if ( 0 === strpos( $line, 'Channels: ' ) ) {
				$out['channels'] = array_values( array_filter( array_map( 'trim', explode( ',', substr( $line, 10 ) ) ) ) );
				continue;
			}
			continue;
		}

		if ( 'interactions' === $section ) {
			$out['interactions'][] = $line;
			continue;
		}

		// --- Journey -----------------------------------------------------
		// Detailed form:  "YYYY-MM-DD HH:MM - Channel"
		//                 "Tag Info - source: … | medium: …"
		//                 "HH:MM - https://… [*submitted*]"
		// Compact form:   "YYYY-MM-DD HH:MM - Channel -> https://landing"
		if ( preg_match( '/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*-\s*(.*)$/', $line, $m ) ) {
			$rest    = trim( $m[2] );
			$landing = '';
			if ( false !== strpos( $rest, ' -> ' ) ) {
				list( $rest, $landing ) = array_map( 'trim', explode( ' -> ', $rest, 2 ) );
			}
			$out['journey_visits'][] = array(
				'date' => $m[1], 'channel' => $rest, 'page' => $landing,
				'source' => '', 'medium' => '', 'campaign' => '', 'term' => '', 'content' => '',
				'views' => array(),
			);
			$vi = count( $out['journey_visits'] ) - 1;
			continue;
		}
		if ( $vi >= 0 && 0 === strpos( $line, 'Tag Info - ' ) ) {
			$p = $pairs( substr( $line, 11 ) );
			foreach ( array( 'source', 'medium', 'campaign', 'term', 'content' ) as $k ) {
				if ( isset( $p[ $k ] ) ) { $out['journey_visits'][ $vi ][ $k ] = $p[ $k ]; }
			}
			continue;
		}
		if ( $vi >= 0 && preg_match( '/^(\d{2}:\d{2})\s*-\s*(\S.*)$/', $line, $m ) ) {
			$url       = trim( $m[2] );
			$submitted = false;
			if ( false !== strpos( $url, '*submitted*' ) ) {
				$submitted = true;
				$url       = trim( str_replace( '*submitted*', '', $url ) );
			}
			// Only URLs are page views; anything else on this line is an interaction.
			if ( 0 === strpos( $url, 'http' ) || 0 === strpos( $url, '/' ) ) {
				$out['journey_visits'][ $vi ]['views'][] = array( 'time' => $m[1], 'page' => $url, 'submitted' => $submitted );
				if ( '' === $out['journey_visits'][ $vi ]['page'] ) {
					$out['journey_visits'][ $vi ]['page'] = $url;
				}
			} else {
				$out['interactions'][] = $m[1] . ' - ' . $url;
			}
			continue;
		}
	}

	return $out;
}

/** "2026-08-05 09:15" in site time -> epoch ms, or null. */
function bwlai_ms( $local, $fallback_ms = null ) {
	$local = trim( (string) $local );
	if ( '' === $local ) {
		return $fallback_ms;
	}
	if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/', $local ) ) {
		return $fallback_ms;
	}
	if ( 10 === strlen( $local ) ) {
		$local .= ' 00:00';
	}
	$ts = strtotime( get_gmt_from_date( $local . ':00' ) . ' UTC' );
	return $ts ? $ts * 1000 : $fallback_ms;
}

/**
 * Turn a parsed summary into the journey structure the report renders, plus the
 * flat datapoints the list view and merge tags read.
 */
function bwlai_build_payload( $parsed, $entry_id, $submitted_ms, $raw_summary ) {
	$first_ms = bwlai_ms( $parsed['first_visit'], $submitted_ms );
	$last_ms  = bwlai_ms( $parsed['last_visit'], $submitted_ms );

	$visits = array();
	$views  = array();
	$subs   = array();

	foreach ( $parsed['journey_visits'] as $v ) {
		$vms      = bwlai_ms( $v['date'], $first_ms );
		$visits[] = array(
			'ts'       => $vms,
			'date'     => $v['date'],
			'channel'  => $v['channel'],
			'source'   => $v['source'],
			'medium'   => $v['medium'],
			'campaign' => $v['campaign'],
			'term'     => $v['term'],
			'content'  => $v['content'],
			'page'     => $v['page'],
			'custom'   => array(),
		);
		$day = substr( (string) $v['date'], 0, 10 );
		foreach ( $v['views'] as $w ) {
			$wms     = bwlai_ms( $day . ' ' . $w['time'], $vms );
			$record  = array( 'ts' => $wms, 'date' => $day . ' ' . $w['time'], 'page' => $w['page'] );
			$views[] = $record;
			if ( $w['submitted'] ) {
				$subs[] = $record;
			}
		}
	}

	if ( empty( $subs ) && $parsed['submitted_page'] ) {
		$subs[] = array( 'ts' => $submitted_ms, 'date' => '', 'page' => $parsed['submitted_page'] );
	}

	$touch = function ( $channel, $source, $medium, $page, $ms, $extra = array() ) {
		return array_merge(
			array(
				'ts' => $ms, 'date' => '', 'channel' => $channel, 'source' => $source, 'medium' => $medium,
				'campaign' => '', 'term' => '', 'content' => '', 'page' => $page, 'custom' => array(),
			),
			$extra
		);
	};

	$journey = array(
		'first'  => $touch(
			$parsed['first_channel'] ? $parsed['first_channel'] : $parsed['channel'],
			$parsed['first_source'] ? $parsed['first_source'] : $parsed['source'],
			$parsed['first_medium'] ? $parsed['first_medium'] : $parsed['medium'],
			$parsed['first_page'] ? $parsed['first_page'] : $parsed['last_page'],
			$first_ms
		),
		'last'   => $touch(
			$parsed['channel'],
			$parsed['source'],
			$parsed['medium'],
			$parsed['last_page'],
			$last_ms,
			array( 'campaign' => $parsed['campaign'], 'term' => $parsed['term'], 'content' => $parsed['content'] )
		),
		'counts' => array(
			'visits'       => $parsed['visits'] ? $parsed['visits'] : count( $visits ),
			'pages'        => $parsed['pages'] ? $parsed['pages'] : count( $views ),
			'taggedVisits' => $parsed['tagged_visits'],
		),
		'visits'      => $visits,
		'views'       => $views,
		'events'      => array(),
		'eventCounts' => array(),
		'submissions' => $subs,
	);

	$payload = array(
		'channel'        => $parsed['channel'],
		'source'         => $parsed['source'],
		'medium'         => $parsed['medium'],
		'campaign'       => $parsed['campaign'],
		'term'           => $parsed['term'],
		'content'        => $parsed['content'],
		// All three fall back together. `first_channel` alone falling back to
		// `channel` left the triple inconsistent: a later pass that reclassifies
		// on source/medium would correct the last touch and leave the first touch
		// showing a raw hostname, inventing a channel switch that never happened.
		'first_channel'  => $parsed['first_channel'] ? $parsed['first_channel'] : $parsed['channel'],
		'first_source'   => $parsed['first_source'] ? $parsed['first_source'] : $parsed['source'],
		'first_medium'   => $parsed['first_medium'] ? $parsed['first_medium'] : $parsed['medium'],
		'first_page'     => $parsed['first_page'],
		'last_page'      => $parsed['last_page'],
		'visits'         => (string) ( $parsed['visits'] ? $parsed['visits'] : count( $visits ) ),
		'pages'          => (string) ( $parsed['pages'] ? $parsed['pages'] : count( $views ) ),
		'tagged_visits'  => (string) $parsed['tagged_visits'],
		'summary'        => $raw_summary,
		'journey'        => wp_json_encode( $journey ),
		// The idempotency marker, and the audit trail back to the original entry.
		'gf_entry_id'    => (string) $entry_id,
		'imported'       => 'gf-history',
	);

	foreach ( $payload as $k => $v ) {
		if ( '' === $v || null === $v ) {
			unset( $payload[ $k ] );
		}
	}
	$payload['gf_entry_id'] = (string) $entry_id;

	return $payload;
}

/** Name / email / phone from an entry, using field types rather than labels. */
function bwlai_entry_identity( $entry, $form ) {
	$out    = array();
	$fields = isset( $form['fields'] ) ? $form['fields'] : array();

	foreach ( $fields as $field ) {
		$type = isset( $field->type ) ? $field->type : '';
		$id   = isset( $field->id ) ? (string) $field->id : '';

		if ( 'email' === $type && empty( $out['email'] ) ) {
			$v = isset( $entry[ $id ] ) ? trim( (string) $entry[ $id ] ) : '';
			if ( is_email( $v ) ) { $out['email'] = sanitize_email( $v ); }
			continue;
		}
		if ( 'name' === $type && empty( $out['first'] ) && empty( $out['last'] ) ) {
			$f = isset( $entry[ $id . '.3' ] ) ? trim( (string) $entry[ $id . '.3' ] ) : '';
			$l = isset( $entry[ $id . '.6' ] ) ? trim( (string) $entry[ $id . '.6' ] ) : '';
			if ( '' !== $f ) { $out['first'] = sanitize_text_field( $f ); }
			if ( '' !== $l ) { $out['last'] = sanitize_text_field( $l ); }
			continue;
		}
		if ( 'phone' === $type && empty( $out['phone'] ) ) {
			$v = isset( $entry[ $id ] ) ? trim( (string) $entry[ $id ] ) : '';
			if ( '' !== $v ) { $out['phone'] = sanitize_text_field( $v ); }
		}
	}

	// Forms built from plain text inputs still usually label them honestly.
	foreach ( $fields as $field ) {
		$label = strtolower( isset( $field->label ) ? $field->label : '' );
		$id    = isset( $field->id ) ? (string) $field->id : '';
		$v     = isset( $entry[ $id ] ) ? trim( (string) $entry[ $id ] ) : '';
		if ( '' === $v ) { continue; }
		if ( empty( $out['email'] ) && false !== strpos( $label, 'email' ) && is_email( $v ) ) {
			$out['email'] = sanitize_email( $v );
		}
		if ( empty( $out['first'] ) && ( false !== strpos( $label, 'first name' ) || 'first' === $label || 'name' === $label ) ) {
			$out['first'] = sanitize_text_field( $v );
		}
		if ( empty( $out['last'] ) && ( false !== strpos( $label, 'last name' ) || false !== strpos( $label, 'surname' ) ) ) {
			$out['last'] = sanitize_text_field( $v );
		}
		if ( empty( $out['phone'] ) && false !== strpos( $label, 'phone' ) ) {
			$out['phone'] = sanitize_text_field( $v );
		}
	}

	return $out;
}

/**
 * Re-run the site's CURRENT referrer rules over already-imported records.
 *
 * The classification a summary captured is frozen at whatever the rules were that
 * day, so a fix to those rules — or a fix to the matching itself — never reaches
 * the history. On one site every untagged search visit before 1.2.1 was stored as
 * `www.google.com / referral` because hostname matching was broken; the live
 * capture has been correct since, which leaves a dataset that changes its mind
 * about what Google is halfway through.
 *
 * Only records whose medium is still the `referral` catch-all are touched — a
 * visit that carried explicit UTMs or a click-ID was never guessed at, so there is
 * nothing to correct. The original values are kept alongside the new ones and the
 * raw summary is never modified, so this is reversible and auditable.
 */
function bwlai_host_matches( $host, $source ) {
	$h = strtolower( (string) $host );
	$s = strtolower( trim( (string) $source, " \t\n\r\0\x0B." ) );
	if ( '' === $h || '' === $s ) { return false; }
	if ( $h === $s ) { return true; }
	if ( false !== strpos( $s, '.' ) ) {
		return strlen( $h ) > strlen( $s ) && substr( $h, -( strlen( $s ) + 1 ) ) === '.' . $s;
	}
	return in_array( $s, explode( '.', $h ), true );
}

function bwlai_classify_host( $host, $rules ) {
	foreach ( $rules as $cls ) {
		foreach ( $cls['sources'] as $src ) {
			if ( bwlai_host_matches( $host, $src ) ) {
				return array( 'source' => $src, 'medium' => $cls['medium'] );
			}
		}
	}
	return null;
}

function bwlai_channel_label( $source, $medium, $channels ) {
	foreach ( $channels as $rule ) {
		foreach ( $rule['patterns'] as $p ) {
			$sok = ( '*' === $p['source'] || strtolower( $p['source'] ) === strtolower( $source ) );
			$mok = ( '*' === $p['medium'] || strtolower( $p['medium'] ) === strtolower( $medium ) );
			if ( $sok && $mok ) {
				$label = $rule['label'];
				// `{bw:source} : */referral` names the channel after the host.
				return str_replace( array( '{bw:source}', '{bw:medium}' ), array( $source, $medium ), $label );
			}
		}
	}
	return $source . ' / ' . $medium;
}

// ------------------------------------------------------------------ the run

global $wpdb;

if ( $bwlai_reclass ) {
	$table   = BW_Lead_AI_Handoff_Store::table_name();
	$rules   = BW_Lead_AI_Settings::classification_rules();
	$channels = BW_Lead_AI_Settings::parse_channels( BW_Lead_AI_Settings::get()['channels'] );

	echo "BW Lead AI — reclassify imported history\n";
	echo "========================================\n";
	echo $bwlai_commit ? "MODE: COMMIT (writes)\n\n" : "MODE: preview (writes nothing)\n\n";

	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
	$rows    = $wpdb->get_results( "SELECT id, payload FROM {$table} WHERE payload LIKE '%gf_entry_id%'", ARRAY_A );
	$changed = 0;
	$moves   = array();

	foreach ( $rows as $r ) {
		$p = json_decode( $r['payload'], true );
		if ( ! is_array( $p ) ) { continue; }

		$touched = false;
		foreach ( array( '' => 'channel', 'first_' => 'first_channel' ) as $prefix => $chan_key ) {
			$src_key = $prefix . 'source';
			$med_key = $prefix . 'medium';
			$source  = isset( $p[ $src_key ] ) ? $p[ $src_key ] : '';
			$medium  = isset( $p[ $med_key ] ) ? $p[ $med_key ] : '';
			$current = isset( $p[ $chan_key ] ) ? (string) $p[ $chan_key ] : '';

			// Records imported before the first-touch triple was made consistent
			// have no first_source at all, but their first_channel still holds the
			// raw referring hostname — which is the thing to classify. Without
			// this the last touch gets corrected and the first touch does not,
			// and every one of those reads as a channel switch that never
			// happened. A hostname is the only shape worth trying here.
			if ( '' === $source && '' !== $current
				&& false !== strpos( $current, '.' ) && false === strpos( $current, ' ' ) ) {
				$source = $current;
				$medium = 'referral';
			}

			// Only the guessed-at ones. Anything else was explicitly tagged.
			if ( 'referral' !== strtolower( (string) $medium ) || '' === $source ) { continue; }

			$hit = bwlai_classify_host( $source, $rules );
			if ( ! $hit ) { continue; }

			$new_label = bwlai_channel_label( $hit['source'], $hit['medium'], $channels );
			$old_label = isset( $p[ $chan_key ] ) ? $p[ $chan_key ] : '';
			if ( $new_label === $old_label ) { continue; }

			$p[ $chan_key . '_original' ] = $old_label;
			$p[ $src_key . '_original' ]  = $source;
			$p[ $med_key . '_original' ]  = $medium;
			$p[ $chan_key ]               = $new_label;
			$p[ $src_key ]                = $hit['source'];
			$p[ $med_key ]                = $hit['medium'];
			$touched                      = true;

			if ( '' === $prefix ) {
				$key           = $old_label . '  ->  ' . $new_label;
				$moves[ $key ] = isset( $moves[ $key ] ) ? $moves[ $key ] + 1 : 1;
			}
		}

		if ( ! $touched ) { continue; }
		$changed++;
		$p['reclassified'] = '1';

		if ( $bwlai_commit ) {
			$wpdb->update( $table, array( 'payload' => wp_json_encode( $p ) ), array( 'id' => (int) $r['id'] ), array( '%s' ), array( '%d' ) );
		}
	}

	arsort( $moves );
	echo sprintf( "Records changed: %d of %d imported\n\nReclassifications\n-----------------\n", $changed, count( $rows ) );
	foreach ( $moves as $move => $count ) {
		echo sprintf( "  %-58s %d\n", $move, $count );
	}
	echo $bwlai_commit ? "\nDone.\n" : "\nPreview — nothing was written. Add `commit` to apply.\n";
	return;
}

$statuses = $bwlai_spam ? array( 'active', 'spam' ) : array( 'active' );
$in       = "'" . implode( "','", array_map( 'esc_sql', $statuses ) ) . "'";

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table names from $wpdb->prefix; status list built from a fixed allow-list above.
$rows = $wpdb->get_results(
	"SELECT m.entry_id, m.meta_value AS summary, e.form_id, e.date_created, e.status
	   FROM {$wpdb->prefix}gf_entry_meta m
	   JOIN {$wpdb->prefix}gf_entry e ON e.id = m.entry_id
	  WHERE m.meta_value LIKE '%== Lead Attribution ==%'
	    AND e.status IN ({$in})
	  ORDER BY e.date_created ASC",
	ARRAY_A
);
// phpcs:enable

echo "BW Lead AI — Gravity Forms history import\n";
echo "=========================================\n";
echo $bwlai_commit ? "MODE: COMMIT (writes)\n" : "MODE: dry run (writes nothing)\n";
echo 'Entries with a summary: ' . count( $rows ) . "\n\n";

if ( empty( $rows ) ) {
	echo "Nothing to import.\n";
	return;
}

if ( $bwlai_commit && ! BW_Lead_AI_Handoff_Store::maybe_create_table() ) {
	fwrite( STDERR, "Could not create the journey table.\n" );
	exit( 1 );
}

$table            = BW_Lead_AI_Handoff_Store::table_name();
$bwlai_retention  = BW_Lead_AI_Settings::handoff_retention_days();

echo $bwlai_retention
	? sprintf( "Retention: %d days from each entry's own date.\n\n", $bwlai_retention )
	: "Retention: unlimited — imported journeys never expire.\n\n";

// One query for what is already imported beats one per entry.
$existing = array();
if ( BW_Lead_AI_Handoff_Store::table_exists() ) {
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
	foreach ( (array) $wpdb->get_col( "SELECT payload FROM {$table} WHERE payload LIKE '%gf_entry_id%'" ) as $blob ) {
		$decoded = json_decode( (string) $blob, true );
		if ( is_array( $decoded ) && ! empty( $decoded['gf_entry_id'] ) ) {
			$existing[ (string) $decoded['gf_entry_id'] ] = true;
		}
	}
}

$forms   = array();
$stats   = array( 'imported' => 0, 'skipped_existing' => 0, 'skipped_unparsed' => 0, 'with_journey' => 0, 'with_identity' => 0, 'leads' => 0 );
$channels = array();
$done    = 0;

foreach ( $rows as $row ) {
	$entry_id = (string) $row['entry_id'];

	if ( isset( $existing[ $entry_id ] ) ) {
		$stats['skipped_existing']++;
		continue;
	}
	if ( $bwlai_limit && $done >= $bwlai_limit ) {
		break;
	}

	$parsed = bwlai_parse_summary( $row['summary'] );

	// A record with no channel and no touches carries nothing worth storing.
	if ( '' === $parsed['channel'] && '' === $parsed['source'] && empty( $parsed['journey_visits'] ) ) {
		$stats['skipped_unparsed']++;
		continue;
	}

	$submitted_ms = strtotime( get_gmt_from_date( $row['date_created'] ) . ' UTC' ) * 1000;
	$payload      = bwlai_build_payload( $parsed, $entry_id, $submitted_ms, (string) $row['summary'] );

	$form_id = (int) $row['form_id'];
	if ( ! isset( $forms[ $form_id ] ) ) {
		$forms[ $form_id ] = function_exists( 'GFAPI' ) || class_exists( 'GFAPI' ) ? GFAPI::get_form( $form_id ) : null;
	}
	$form  = $forms[ $form_id ];
	$title = ( $form && ! empty( $form['title'] ) ) ? $form['title'] : ( 'Form ' . $form_id );

	$identity = array();
	if ( class_exists( 'GFAPI' ) ) {
		$entry = GFAPI::get_entry( (int) $entry_id );
		if ( $entry && ! is_wp_error( $entry ) && $form ) {
			$identity = bwlai_entry_identity( $entry, $form );
		}
	}

	if ( ! empty( $parsed['journey_visits'] ) ) { $stats['with_journey']++; }
	if ( ! empty( $identity ) )                 { $stats['with_identity']++; }
	$ch = $parsed['channel'] ? $parsed['channel'] : '(unknown)';
	$channels[ $ch ] = isset( $channels[ $ch ] ) ? $channels[ $ch ] + 1 : 1;

	$done++;
	$stats['imported']++;

	if ( ! $bwlai_commit ) {
		continue;
	}

	// Mint, then backdate. The store stamps "now" on creation because that is
	// right for a live handoff; a historical record has to carry the date the
	// lead actually arrived or every report would show one enormous spike today.
	$token = BW_Lead_AI_Handoff_Store::mint( $payload, 60 );
	if ( is_wp_error( $token ) || ! $token ) {
		$stats['imported']--;
		$stats['skipped_unparsed']++;
		continue;
	}
	// save() is told NEVER here, then the real expiry is written below alongside
	// the backdated timestamps. Passing the retention directly would compute the
	// expiry from *today*, and passing 0 expires the record on the spot — which it
	// did: the first test batch imported cleanly and was invisible everywhere,
	// because `expires_at > now` was false the moment it was written.
	BW_Lead_AI_Handoff_Store::save( $token, $title, null, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );

	$hash    = hash( 'sha256', $token );
	$created = $row['date_created'];

	// The conversion save() just recorded is stamped "now" for the same reason
	// the record is — backdate it to the same instant as confirmed_at, or the
	// journey report shows every imported lead converting the day of the import.
	$conversions = $wpdb->get_var( $wpdb->prepare( "SELECT conversions FROM {$table} WHERE token_hash = %s", $hash ) );
	$conversions = json_decode( (string) $conversions, true );
	if ( is_array( $conversions ) && $conversions ) {
		foreach ( $conversions as $ci => $unused ) {
			$conversions[ $ci ]['ts'] = $created;
		}
		$wpdb->update( $table, array( 'conversions' => wp_json_encode( $conversions ) ), array( 'token_hash' => $hash ), array( '%s' ), array( '%s' ) );
	}

	// Retention counts from the last contact, so a historical record expires
	// relative to when the lead actually arrived — not when it was imported.
	// That honours the site's own policy instead of quietly making the archive
	// permanent, and nothing already past its retention window is resurrected.
	$expires = ( null === $bwlai_retention )
		? BW_Lead_AI_Handoff_Store::NEVER
		: gmdate( 'Y-m-d H:i:s', strtotime( get_gmt_from_date( $created ) . ' UTC' ) + ( $bwlai_retention * DAY_IN_SECONDS ) );

	$wpdb->update(
		$table,
		array( 'created_at' => $created, 'confirmed_at' => $created, 'last_seen_at' => $created, 'expires_at' => $expires ),
		array( 'token_hash' => $hash ),
		array( '%s', '%s', '%s', '%s' ),
		array( '%s' )
	);

	if ( ! empty( $identity ) ) {
		BW_Lead_AI_Handoff_Store::attach_identity( $token, $identity, null );
		if ( ! empty( $identity['email'] ) && class_exists( 'BW_Lead_AI_Leads' ) ) {
			$lead_id = BW_Lead_AI_Leads::resolve( $identity );
			if ( $lead_id ) {
				$stats['leads']++;
				$anchor = BW_Lead_AI_Handoff_Store::get_record( $token );
				if ( $anchor ) {
					BW_Lead_AI_Leads::link_anchor( $anchor, $lead_id );
				}
			}
		}
	}
}

arsort( $channels );

echo "Result\n------\n";
echo sprintf( "  imported          : %d\n", $stats['imported'] );
echo sprintf( "  already present   : %d\n", $stats['skipped_existing'] );
echo sprintf( "  unparseable       : %d\n", $stats['skipped_unparsed'] );
echo sprintf( "  with page history : %d\n", $stats['with_journey'] );
echo sprintf( "  with a name/email : %d\n", $stats['with_identity'] );
if ( $bwlai_commit ) {
	echo sprintf( "  leads resolved    : %d\n", $stats['leads'] );
}
echo "\nChannels seen\n-------------\n";
$shown = 0;
foreach ( $channels as $name => $count ) {
	echo sprintf( "  %-46s %d\n", substr( $name, 0, 46 ), $count );
	if ( ++$shown >= 15 ) {
		echo sprintf( "  … and %d more\n", count( $channels ) - $shown );
		break;
	}
}

if ( ! $bwlai_commit ) {
	echo "\nDry run — nothing was written. Re-run with `commit` to import.\n";
} else {
	$s = BW_Lead_AI_Handoff_Store::stats();
	echo sprintf( "\nStore now holds: %d saved, %d held.\n", $s['saved'], $s['held'] );
}
