<?php
/**
 * The UTM builder: a compose panel, and a list that stays a list.
 *
 * **The failure this exists to catch is a blank link on every save.** The
 * sanitizer keeps every item it is handed — it has no notion of an empty one —
 * so the moment a builder field carries a real `bw_lead_ai_utm_tracking[...]`
 * name, pressing Save appends an empty link, and pressing it again appends
 * another. Nothing errors; the list just grows junk between the links somebody
 * meant to keep. Section (c) is that stated as a test, and it is the reason the
 * builder's inputs are addressed by data attribute instead of by name.
 *
 * **Second: the fields must be present whether a row is open or shut.** Every
 * saved link renders collapsed, and a row that only emitted its inputs when
 * expanded would drop every link the user had not clicked on the next save.
 * Section (d) posts the rendered form through the real sanitizer and asserts
 * what comes back is what went in — collapsed rows included.
 *
 * **Third: it collapses natively.** A `<details>` rather than a table row with a
 * JS toggle, so somebody with JavaScript off can still open a link and edit it.
 * That worked before this change (everything was expanded) and a JS-only toggle
 * would have quietly taken it away.
 *
 * Read-only. The fixture is supplied through `pre_option_` — not `option_`,
 * which does not fire for an option that has never been written — and the stored
 * value is hashed before and after to prove nothing was saved.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/utm-builder.php
 */

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

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

$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;
};
$section = function ( $title ) {
	echo "\n{$title}\n" . str_repeat( '-', strlen( $title ) ) . "\n";
};

echo "BW Lead AI — UTM builder\n";
echo "========================\n";

$stored_md5 = md5( (string) maybe_serialize( get_option( BW_LEAD_AI_UTM_OPTION, array() ) ) );

$admins = get_users( array( 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ) );
if ( empty( $admins ) ) {
	fwrite( STDERR, "No administrator to run as.\n" );
	exit( 1 );
}
wp_set_current_user( (int) $admins[0] );

// Three links: a fully-filled one, one sharing its campaign, and one with
// nothing but a destination — the case the summary line has to name somehow.
$fixture = array(
	array( 'link_to_uri' => 'https://x.test/apply', 'utm_source' => 'google', 'utm_medium' => 'cpc', 'utm_campaign' => 'spring-open-day', 'utm_term' => 'private school', 'utm_content' => '', 'note' => "Search brand\nsecond line" ),
	array( 'link_to_uri' => 'https://x.test/apply', 'utm_source' => 'facebook', 'utm_medium' => 'social', 'utm_campaign' => 'spring-open-day', 'utm_term' => '', 'utm_content' => 'carousel', 'note' => '' ),
	array( 'link_to_uri' => 'https://x.test/fees', 'utm_source' => '', 'utm_medium' => '', 'utm_campaign' => '', 'utm_term' => '', 'utm_content' => '', 'note' => '' ),
);

$notices = array();
set_error_handler(
	function ( $no, $str, $file, $line ) use ( &$notices ) {
		$notices[] = sprintf( '%s in %s:%d', $str, basename( $file ), $line );
		return true;
	}
);

$supply = function () use ( $fixture ) {
	return $fixture;
};
add_filter( 'pre_option_' . BW_LEAD_AI_UTM_OPTION, $supply );
$_GET     = array( 'page' => BW_Lead_AI_Admin::UTM_SLUG );
$_REQUEST = $_GET;
ob_start();
BW_Lead_AI_Admin::instance()->render_utm_page();
$html = (string) ob_get_clean();
remove_filter( 'pre_option_' . BW_LEAD_AI_UTM_OPTION, $supply );
restore_error_handler();

// The <template> holds an identical item, so every count has to be taken on the
// list alone or it reads one too many.
$list = ( false !== strpos( $html, '<template' ) ) ? substr( $html, 0, strpos( $html, '<template' ) ) : $html;

// =========================================================================
$section( '(a) the page renders' );
{
	$ok( 'with no PHP notices', empty( $notices ), implode( ' | ', array_slice( $notices, 0, 3 ) ) );
	$ok( 'and a builder panel', false !== strpos( $html, 'bw-utm-builder' ) );
	// The closing quote matters: the container is `bw-lead-ai-utm-items`, and
	// counting the bare prefix counts the wrapper as a fourth link.
	$ok( 'the three saved links are listed', 3 === substr_count( $list, 'bw-lead-ai-utm-item"' ), substr_count( $list, 'bw-lead-ai-utm-item"' ) . ' found' );
	$ok( 'the count says three', false !== strpos( $html, 'data-utm-count>3<' ) );
	$ok( 'one JS template, rendered by PHP so its labels translate', 1 === substr_count( $html, 'id="bw-utm-item-template"' ) );
	$ok( 'carrying the index placeholder', false !== strpos( $html, '[__INDEX__][utm_source]' ) );
}

// =========================================================================
$section( '(b) each link is one readable line until it is opened' );
{
	$ok( 'every item is a <details>', 3 === substr_count( $list, '<details' ), substr_count( $list, '<details' ) . ' found' );
	$ok( 'so it collapses with JavaScript off', 0 === substr_count( $list, '<details open' ) );

	preg_match_all( '#data-utm-name>(.*?)</span>\s*<span class="bw-utm-detail" data-utm-detail>(.*?)</span>#s', $list, $rows, PREG_SET_ORDER );
	$ok( 'each has a summary line', 3 === count( $rows ), count( $rows ) . ' found' );
	if ( 3 === count( $rows ) ) {
		$ok( 'the campaign leads it', 'spring-open-day' === trim( $rows[0][1] ), trim( $rows[0][1] ) );
		$ok( 'source / medium tells two links of one campaign apart', 'google / cpc' === trim( $rows[0][2] ) && 'facebook / social' === trim( $rows[1][2] ), trim( $rows[1][2] ) );
		// "Untitled" repeated is the same as no list at all.
		$ok( 'a link with no campaign is named by where it points', '/fees' === trim( $rows[2][1] ), trim( $rows[2][1] ) );
	}
	$ok( 'the copy button is on the closed row, where it is wanted', 3 === substr_count( $list, 'bw-utm-actions' ) );
}

// =========================================================================
$section( '(c) THE TRAP: the builder cannot post a blank link' );
{
	$builder = '';
	if ( preg_match( '#<div class="bw-utm-builder">(.*?)<h3 class="bw-utm-list-head"#s', $html, $bm ) ) {
		$builder = $bm[1];
	}
	$ok( 'the builder panel was found', '' !== $builder );
	$ok(
		'no builder field carries an option name',
		'' !== $builder && false === strpos( $builder, BW_LEAD_AI_UTM_OPTION . '[' ),
		'a named builder field appends an empty link on every save, forever'
	);
	$ok( 'they are addressed by data attribute instead', substr_count( $builder, 'data-utm-field=' ) >= 7, substr_count( $builder, 'data-utm-field=' ) . ' found' );
	$ok( 'and there is a button to turn one into a real item', false !== strpos( $builder, 'bw-lead-ai-utm-add' ) );
}

// =========================================================================
$section( '(d) a save round-trips, collapsed rows included' );
{
	// Tag by tag rather than one regex with an optional group: a lazy quantifier
	// with `(?:value="…")?` after it matches the empty string every time, which
	// is how the first draft of this file reported every value as blank against
	// a page that was rendering them correctly.
	$posted = array();
	preg_match_all( '#<input\b[^>]*>#s', $list, $tags );
	foreach ( $tags[0] as $tag ) {
		if ( ! preg_match( '#name="' . preg_quote( BW_LEAD_AI_UTM_OPTION, '#' ) . '\[(\d+)\]\[([a-z_]+)\]"#', $tag, $nm ) ) {
			continue;
		}
		$value = preg_match( '#value="([^"]*)"#', $tag, $vm ) ? html_entity_decode( $vm[1], ENT_QUOTES ) : '';
		$posted[ (int) $nm[1] ][ $nm[2] ] = $value;
	}
	preg_match_all( '#name="' . preg_quote( BW_LEAD_AI_UTM_OPTION, '#' ) . '\[(\d+)\]\[note\]"[^>]*>(.*?)</textarea>#s', $list, $notes, PREG_SET_ORDER );
	foreach ( $notes as $note ) {
		$posted[ (int) $note[1] ]['note'] = html_entity_decode( $note[2], ENT_QUOTES );
	}
	ksort( $posted );

	$ok( 'the form posts all three links', 3 === count( $posted ), count( $posted ) . ' scraped' );
	$ok( 'each with all seven fields', 7 === count( $posted[0] ), isset( $posted[0] ) ? count( $posted[0] ) . ' fields' : 'none' );

	$saved = BW_Lead_AI_Settings::instance()->sanitize_utm( array_values( $posted ) );
	$ok(
		'and what comes back is what went in',
		wp_json_encode( $saved ) === wp_json_encode( $fixture ),
		wp_json_encode( array_slice( $saved, 0, 1 ) )
	);

	// The empty third link survives too: a destination with nothing on it is a
	// draft somebody is part-way through, not junk to drop.
	$ok( 'including the one with only a destination', isset( $saved[2] ) && 'https://x.test/fees' === $saved[2]['link_to_uri'] );
}

// =========================================================================
$section( '(e) nothing was written' );
{
	$ok( 'the stored option is untouched', md5( (string) maybe_serialize( get_option( BW_LEAD_AI_UTM_OPTION, array() ) ) ) === $stored_md5 );
}

echo "\n" . str_repeat( '=', 40 ) . "\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";
