<?php
/**
 * Every screen of the Lead AI menu is reachable, silent and styled.
 *
 * The three failures this exists for are all invisible to a syntax lint and to a
 * test that calls a render method directly, and all three have shipped here before
 * (docs/UI-REVIEW.md B1, B2):
 *
 * 1. A page that renders perfectly when called in isolation but answers "Sorry, you
 *    are not allowed to access this page" through admin.php, because the capability
 *    to check is looked up in $submenu and the entry is not there.
 * 2. A page that renders but loses every stylesheet and script, because enqueue()
 *    compares the hook against a guess rather than what registration returned.
 * 3. A bookmarked URL for a screen that has moved, landing on a 403 or silently on
 *    the wrong tab.
 *
 * So this builds the menu the way WordPress does, resolves each entry the way
 * wp-admin/admin.php does, renders every screen with notices promoted to failures,
 * and follows every legacy URL. It also round-trips the one nested setting through
 * render → scrape → sanitize, including a custom dimension, since a mapping lost by
 * a save from an unrelated tab is the other kind of silent damage.
 *
 * Read-only: every settings variation is applied through a read filter, and the
 * stored option is printed at the end so a run can be shown not to have written.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/menu-routing.php
 */

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

require_once ABSPATH . 'wp-admin/includes/plugin.php';
require_once ABSPATH . 'wp-admin/includes/screen.php';
require_once ABSPATH . 'wp-admin/includes/template.php';

$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] );

// wp eval-file includes this inside a function, so top-level variables are NOT
// globals. Counters live in $GLOBALS explicitly or the summary lies.
$GLOBALS['bwlai_fail'] = array();
$GLOBALS['bwlai_ok']   = 0;
function bwlai_ok( $msg ) {
	$GLOBALS['bwlai_ok']++;
	echo "  PASS  $msg\n";
}
function bwlai_fail( $msg ) {
	$GLOBALS['bwlai_fail'][] = $msg;
	echo "  FAIL  $msg\n";
}
/** The two above, for the many checks that are just a boolean. */
function bwlai_assert( $condition, $msg ) {
	if ( $condition ) {
		bwlai_ok( $msg );
		return true;
	}
	bwlai_fail( $msg );
	return false;
}

// Any notice/warning/deprecation raised while rendering is a failure, not noise.
$notices = array();
set_error_handler(
	function ( $no, $str, $file, $line ) use ( &$notices ) {
		$notices[] = sprintf( '%s in %s:%d', $str, basename( $file ), $line );
		return true;
	}
);

$GLOBALS['pagenow'] = 'admin.php';
do_action( 'admin_menu' );

echo "\n== 1. The menu as WordPress assembled it ==\n";
$parent  = BW_Lead_AI_Admin::PAGE_SLUG;
$entries = isset( $GLOBALS['submenu'][ $parent ] ) ? $GLOBALS['submenu'][ $parent ] : array();
if ( empty( $entries ) ) {
	bwlai_fail( 'no submenu registered under ' . $parent );
}
foreach ( $entries as $i => $entry ) {
	printf( "  [%d] %-18s cap=%-14s slug=%s\n", $i, $entry[0], $entry[1], $entry[2] );
}
$slugs = wp_list_pluck( $entries, 2 );
if ( ! empty( $slugs ) && end( $slugs ) === $parent ) {
	bwlai_ok( 'Settings is last (it owns the parent slug)' );
} else {
	bwlai_fail( 'Settings is not the last entry: ' . implode( ' | ', $slugs ) );
}
if ( ! in_array( BW_Lead_AI_Handoff_Admin::LIST_SLUG, $slugs, true ) ) {
	bwlai_ok( 'the old Visitor Journeys entry is gone from the menu' );
} else {
	bwlai_fail( 'the journeys list still has its own submenu entry' );
}

echo "\n== 2. Every entry resolves (trap 1: the \$submenu capability lookup) ==\n";
foreach ( $entries as $entry ) {
	$slug = $entry[2];
	$cap  = $entry[1];

	// Exactly what wp-admin/admin.php does before deciding to serve or to 403.
	$GLOBALS['plugin_page'] = $slug;
	$GLOBALS['parent_file'] = null;
	$hook                   = get_plugin_page_hook( $slug, 'admin.php' );

	if ( ! $hook ) {
		bwlai_fail( "$slug: no page hook — this is the \"you are not allowed to access this page\" bug" );
		continue;
	}
	if ( ! current_user_can( $cap ) ) {
		bwlai_fail( "$slug: administrator lacks the declared capability $cap" );
		continue;
	}
	bwlai_ok( "$slug resolves to $hook (cap $cap)" );
}

echo "\n== 3. Every screen renders, silently ==\n";
$screens = array(
	'Reports'           => array( BW_Lead_AI_Admin::REPORTS_SLUG, array() ),
	// One entry per value of BW_Lead_AI_Admin::REPORTS_VIEWS, plus the two cases a
	// whitelist exists for: a value that is not on it, and the value being absent.
	// Both must land on the aggregate report rather than on a blank screen.
	'Reports→Data Quality' => array( BW_Lead_AI_Admin::REPORTS_SLUG, array( 'view' => 'quality' ) ),
	'Reports→AI Analysis'  => array( BW_Lead_AI_Admin::REPORTS_SLUG, array( 'view' => 'ai' ) ),
	'Reports→Journeys'  => array( BW_Lead_AI_Admin::REPORTS_SLUG, array( 'view' => 'journeys' ) ),
	'Reports→bogus view' => array( BW_Lead_AI_Admin::REPORTS_SLUG, array( 'view' => 'no-such-view' ) ),
	'Reports→quality, 90 days, first click, mediums' => array(
		BW_Lead_AI_Admin::REPORTS_SLUG,
		array( 'view' => 'quality', 'range' => '90', 'attribution' => 'first', 'grouping' => 'medium' ),
	),
	'UTM Builder'       => array( BW_Lead_AI_Admin::UTM_SLUG, array() ),
	'Help'              => array( BW_Lead_AI_Admin::HELP_SLUG, array() ),
	'Settings'          => array( BW_Lead_AI_Admin::PAGE_SLUG, array() ),
	'Tracking Rules'    => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'settings' ) ),
	'Interactions'      => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'events' ) ),
	'Journey Storage'   => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'journeys' ) ),
	'Cross-domain'      => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'handoff' ) ),
	'Form Field Mapping' => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'targets' ) ),
	'Other Settings'    => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'other' ) ),
	'Test'              => array( BW_Lead_AI_Admin::PAGE_SLUG, array( 'tab' => 'test' ) ),
);
$html = array();
foreach ( $screens as $label => $spec ) {
	list( $slug, $args ) = $spec;
	$_GET                = array_merge( array( 'page' => $slug ), $args );
	$_REQUEST            = $_GET;
	$notices             = array();

	$callback = ( BW_Lead_AI_Admin::PAGE_SLUG === $slug )
		? array( BW_Lead_AI_Admin::instance(), 'render_page' )
		: ( ( BW_Lead_AI_Admin::REPORTS_SLUG === $slug )
			? array( BW_Lead_AI_Admin::instance(), 'render_reports_page' )
			: ( ( BW_Lead_AI_Admin::UTM_SLUG === $slug )
				? array( BW_Lead_AI_Admin::instance(), 'render_utm_page' )
				: array( BW_Lead_AI_Admin::instance(), 'render_help_page' ) ) );

	ob_start();
	call_user_func( $callback );
	$out           = (string) ob_get_clean();
	$html[ $label ] = $out;

	$problems = array();
	if ( '' === trim( $out ) ) {
		$problems[] = 'rendered nothing';
	}
	if ( false !== stripos( $out, 'not allowed to access' ) ) {
		$problems[] = 'rendered the not-allowed message';
	}
	if ( false !== stripos( $out, 'critical error' ) || false !== stripos( $out, 'Fatal error' ) ) {
		$problems[] = 'rendered an error';
	}
	if ( $notices ) {
		$problems[] = count( $notices ) . ' PHP notice(s): ' . implode( ' | ', array_slice( $notices, 0, 3 ) );
	}
	if ( $problems ) {
		bwlai_fail( "$label: " . implode( '; ', $problems ) );
	} else {
		bwlai_ok( sprintf( '%-19s rendered %6d bytes, no notices', $label, strlen( $out ) ) );
	}
}

echo "\n== 3b. Each promoted page has its own content ==\n";
$must_contain = array(
	'UTM Builder'        => array( 'UTM Tracking Links', 'bw-lead-ai-utm-items', '<h1>UTM Builder</h1>' ),
	'Help'               => array( 'All available merge tags', '{bw:source_medium}', '<h1>Help</h1>' ),
	'Reports→Journeys'   => array( 'Visitor journeys', 'subsubsub', '<h1>Reports</h1>' ),
	'Form Field Mapping' => array( 'Form Field Mapping', 'field_targets', 'bw-target-table' ),
	// The Reports tab keeps a pointer to the checks and to any block it withheld —
	// a tab that moved its own trustworthiness section away and said nothing would
	// be silently confident, which is the one thing this plugin must not be.
	'Reports'            => array( 'Break leads down by:', 'view=quality' ),
	// Every check, whether it fired or not, plus the sentence that makes an empty
	// result mean something.
	'Reports→Data Quality' => array( 'Can you trust these numbers?', 'Unresolved placeholders in the tracking tags', 'bw-quality-table', 'Clear' ),
	'Reports→AI Analysis'  => array( 'AI Analysis', 'Paste this into your AI assistant', 'data-ai-package', 'action=bw_lead_ai_ai_export' ),
	// An unrecognised view is the aggregate report, not a blank page.
	'Reports→bogus view'   => array( 'Break leads down by:' ),
	'Other Settings'     => array( 'Export and import settings', 'Factory reset', 'bw_lead_ai_settings_export', 'bw_lead_ai_settings_import', 'bw_lead_ai_settings_reset', 'Type RESET to confirm' ),
);
foreach ( $must_contain as $label => $needles ) {
	$out = isset( $html[ $label ] ) ? $html[ $label ] : '';
	$miss  = array();
	foreach ( $needles as $needle ) {
		if ( false === strpos( $out, $needle ) ) {
			$miss[] = $needle;
		}
	}
	if ( $miss ) {
		bwlai_fail( "$label: missing " . implode( ' , ', $miss ) );
	} else {
		bwlai_ok( "$label: content present" );
	}
}

echo "\n== 4. The Reports tab strip ==\n";
/*
 * wp-admin's nav-tab strip, the same control the settings page uses — four tabs,
 * exactly one lit, and every tab carrying the window / credit / dimension so a trip
 * to Data Quality and back does not reset the report somebody had set up.
 */
$tab_labels = array( 'Reports', 'Data Quality', 'AI Analysis', 'Journeys' );
$expected   = array(
	'Reports'              => 'Reports',
	'Reports→Data Quality' => 'Data Quality',
	'Reports→AI Analysis'  => 'AI Analysis',
	'Reports→Journeys'     => 'Journeys',
	'Reports→bogus view'   => 'Reports',
);
foreach ( $expected as $label => $lit_label ) {
	$out = $html[ $label ];
	if ( false === strpos( $out, 'nav-tab-wrapper bw-report-tabs' ) ) {
		bwlai_fail( "$label: no tab strip" );
		continue;
	}
	$problems = array();
	foreach ( $tab_labels as $tab ) {
		if ( false === strpos( $out, '>' . $tab . '</a>' ) ) {
			$problems[] = "missing the $tab tab";
		}
	}
	// Exactly one active tab, and it is the right one.
	$active = preg_match_all( '/class="nav-tab nav-tab-active" href="[^"]*">([^<]+)<\/a>/', $out, $am );
	if ( 1 !== $active ) {
		$problems[] = "$active tabs are lit, expected 1";
	} elseif ( $am[1][0] !== $lit_label ) {
		$problems[] = 'the lit tab is ' . $am[1][0] . ', expected ' . $lit_label;
	}
	if ( $problems ) {
		bwlai_fail( "$label: " . implode( '; ', $problems ) );
	} else {
		bwlai_ok( sprintf( '%-38s four tabs, "%s" lit', $label, $lit_label ) );
	}
}

// The three axes must survive a tab change, or a reader who set the report up and
// went to check its data quality comes back to the default view.
$carried = $html['Reports→quality, 90 days, first click, mediums'];
$axes    = array( 'range=90', 'attribution=first', 'grouping=medium' );
$missing = array();
foreach ( $axes as $axis ) {
	// html entities: add_query_arg output is escaped with &#038; between args.
	if ( false === strpos( $carried, $axis ) ) {
		$missing[] = $axis;
	}
}
if ( $missing ) {
	bwlai_fail( 'the Data Quality tab drops ' . implode( ' , ', $missing ) . ' from its links' );
} else {
	bwlai_ok( 'window, credit mode and dimension all survive on the Data Quality tab\'s links' );
}
// ...and the range pills on that tab must keep you on that tab.
if ( preg_match_all( '/href="[^"]*page=' . preg_quote( BW_Lead_AI_Admin::REPORTS_SLUG, '/' ) . '[^"]*range=(?:7|365)[^"]*"/', $carried, $rm ) ) {
	$stray = array();
	foreach ( $rm[0] as $href ) {
		if ( false === strpos( $href, 'view=quality' ) ) {
			$stray[] = $href;
		}
	}
	if ( $stray ) {
		bwlai_fail( 'range pills on the Data Quality tab leave it: ' . implode( ' , ', array_slice( $stray, 0, 2 ) ) );
	} else {
		bwlai_ok( 'range pills on the Data Quality tab stay on the Data Quality tab' );
	}
} else {
	bwlai_fail( 'no range pills rendered on the Data Quality tab' );
}

// The journeys list's own links must all carry the view, or the first click walks
// you out. The tab strip is meant to leave, so it is removed before counting rather
// than allowed for by a magic number that drifts every time a tab is added.
$j     = preg_replace( '/<h2 class="nav-tab-wrapper bw-report-tabs">.*?<\/h2>/s', '', $html['Reports→Journeys'] );
$leaks = preg_match_all( '/page=' . preg_quote( BW_Lead_AI_Admin::REPORTS_SLUG, '/' ) . '(?![^"\']*view=journeys)[^"\']*/', $j, $m );
if ( ! $leaks ) {
	bwlai_ok( 'every journeys-list link outside the tab strip keeps view=journeys' );
} else {
	bwlai_fail( 'journeys-list links that drop view=journeys: ' . implode( ' , ', array_slice( $m[0], 0, 4 ) ) );
}

echo "\n== 4b. The data-quality check register ==\n";
/*
 * The register is the single source: the screen, the aggregate and the AI package
 * all read it. Two things can break that, and neither is visible to a lint — an
 * entry with no detector (which would silently always read "clear"), and the
 * evaluated result carrying a different set of keys from the declaration.
 */
$register = BW_Lead_AI_Reports::quality_checks();
if ( count( $register ) >= 6 ) {
	bwlai_ok( 'register declares ' . count( $register ) . ' checks' );
} else {
	bwlai_fail( 'register has only ' . count( $register ) . ' checks' );
}
foreach ( $register as $key => $meta ) {
	$problems = array();
	if ( ! is_callable( array( 'BW_Lead_AI_Reports', 'quality_' . $key ) ) ) {
		$problems[] = "no detector BW_Lead_AI_Reports::quality_$key()";
	}
	if ( empty( $meta['label'] ) ) {
		$problems[] = 'no label';
	}
	if ( empty( $meta['watches'] ) || strlen( $meta['watches'] ) < 40 ) {
		$problems[] = 'no usable "what this looks for" description';
	}
	if ( $problems ) {
		bwlai_fail( "check '$key': " . implode( '; ', $problems ) );
	} else {
		bwlai_ok( sprintf( "check %-20s declared, described and detectable", "'$key'" ) );
	}
}
$built = BW_Lead_AI_Reports::build( '30', 'last', 'groups' );
if ( array_keys( $built['quality_checks'] ) === array_keys( $register ) ) {
	bwlai_ok( 'the evaluated result carries exactly the register\'s keys, in order' );
} else {
	bwlai_fail(
		'register/result mismatch: register=' . implode( ',', array_keys( $register ) )
		. ' result=' . implode( ',', array_keys( $built['quality_checks'] ) )
	);
}
// The findings-only list the AI package reads must be derivable from the register
// alone — same keys, same counts — or the two screens can disagree.
$derived = array();
foreach ( $built['quality_checks'] as $key => $check ) {
	if ( ! empty( $check['fired'] ) ) {
		$derived[ $key ] = (int) $check['count'];
	}
}
$reported = array();
foreach ( $built['quality'] as $finding ) {
	$reported[ $finding['key'] ] = (int) $finding['count'];
}
ksort( $derived );
ksort( $reported );
if ( $derived === $reported ) {
	bwlai_ok( 'findings list and register agree exactly (' . count( $derived ) . ' firing)' );
} else {
	bwlai_fail( 'findings list disagrees with the register: ' . wp_json_encode( array( $derived, $reported ) ) );
}

// The Reports tab must keep a pointer to all of this. Conditional on the page
// actually drawing its blocks: below the sparse floor it says so and stops, and
// there is nothing there for a trustworthiness note to be about.
$leads_30 = (int) $built['totals']['leads'];
echo "  leads in the default window: $leads_30\n";
if ( $leads_30 >= BW_Lead_AI_Reports::SPARSE_LEADS ) {
	$pointer = array( 'Can you trust these numbers?', 'Open Data Quality' );
	$miss    = array();
	foreach ( $pointer as $needle ) {
		if ( false === strpos( $html['Reports'], $needle ) ) {
			$miss[] = $needle;
		}
	}
	if ( $miss ) {
		bwlai_fail( 'the Reports tab lost its data-quality pointer: missing ' . implode( ' , ', $miss ) );
	} else {
		bwlai_ok( 'the Reports tab still says whether you can trust it, and links to the checks' );
	}
} else {
	echo "  (below the sparse floor, so the Reports tab draws no blocks and no pointer)\n";
}

echo "\n== 4c. Dismissing a finding ==\n";
/*
 * Writes to its OWN option and restores it, so the settings option is untouched —
 * which is the point of the dismissals living somewhere else in the first place.
 */
$dismiss_option = BW_Lead_AI_Reports::QUALITY_DISMISS_OPTION;
$dismiss_before = get_option( $dismiss_option, null );

$firing = array_keys( $derived );
$victim = $firing ? $firing[0] : 'no_channel';
$check  = $built['quality_checks'][ $victim ];
BW_Lead_AI_Reports::quality_dismiss( $victim, (int) $check['count'], (int) $check['pct'], '30' );

$states = BW_Lead_AI_Reports::quality_with_dismissals( $built['quality_checks'] );
$after  = $states[ $victim ];
if ( ! empty( $after['dismissal'] ) && (int) $after['dismissal']['pct'] === (int) $check['pct'] ) {
	bwlai_ok( "'$victim' records the numbers it was dismissed at (" . (int) $check['pct'] . '%)' );
} else {
	bwlai_fail( "'$victim' did not record its dismissal" );
}
$want = empty( $check['fired'] ) ? 'clear' : 'dismissed';
if ( $want === $after['state'] ) {
	bwlai_ok( "'$victim' is now in state '$want' and STILL in the register (never removed)" );
} else {
	bwlai_fail( "'$victim' state is '" . $after['state'] . "', expected '$want'" );
}
if ( isset( $states[ $victim ] ) && count( $states ) === count( $register ) ) {
	bwlai_ok( 'a dismissed check is still one of the ' . count( $register ) . ' listed checks' );
} else {
	bwlai_fail( 'dismissing removed a check from the register' );
}

// Worsening: the same check, five points bigger, must climb back out of dismissed.
$grown                 = $built['quality_checks'];
$grown[ $victim ]['fired'] = true;
$grown[ $victim ]['count'] = (int) $check['count'] + 100;
$grown[ $victim ]['pct']   = min( 100, (int) $check['pct'] + BW_Lead_AI_Reports::DISMISS_REGROWTH_POINTS );
$lapsed                = BW_Lead_AI_Reports::quality_with_dismissals( $grown );
if ( 'lapsed' === $lapsed[ $victim ]['state'] ) {
	bwlai_ok( 'a dismissed finding that grows by ' . BW_Lead_AI_Reports::DISMISS_REGROWTH_POINTS . ' points lapses back to a finding' );
} else {
	bwlai_fail( 'a grown finding stayed dismissed (state ' . $lapsed[ $victim ]['state'] . ') — the screen would be hiding a worsening problem' );
}
if ( BW_Lead_AI_Reports::quality_needs_attention( $lapsed[ $victim ]['state'] ) ) {
	bwlai_ok( 'a lapsed finding counts as needing attention again' );
} else {
	bwlai_fail( 'a lapsed finding is not counted as needing attention' );
}

// Round trip.
BW_Lead_AI_Reports::quality_undismiss( $victim );
$restored = BW_Lead_AI_Reports::quality_with_dismissals( $built['quality_checks'] );
if ( null === $restored[ $victim ]['dismissal'] && $restored[ $victim ]['state'] === ( empty( $check['fired'] ) ? 'clear' : 'active' ) ) {
	bwlai_ok( 'un-dismissing puts it back exactly as it was' );
} else {
	bwlai_fail( 'un-dismiss did not round-trip' );
}

// A key that is not a check cannot get in.
BW_Lead_AI_Reports::quality_dismiss( 'not_a_check', 5, 5, '30' );
BW_Lead_AI_Reports::quality_dismiss( '<script>', 5, 5, '30' );
$leftover = BW_Lead_AI_Reports::quality_dismissals();
if ( empty( $leftover ) ) {
	bwlai_ok( 'a key that is not in the register is refused' );
} else {
	bwlai_fail( 'the dismissal option accepted ' . implode( ',', array_keys( $leftover ) ) );
}

// And the option is put back exactly as it was found.
if ( null === $dismiss_before ) {
	delete_option( $dismiss_option );
} else {
	update_option( $dismiss_option, $dismiss_before, false );
}
$dismiss_now = get_option( $dismiss_option, null );
if ( maybe_serialize( $dismiss_before ) === maybe_serialize( $dismiss_now ) ) {
	bwlai_ok( 'the dismissal option is back exactly as it was found (md5 ' . substr( md5( maybe_serialize( $dismiss_now ) ), 0, 8 ) . ')' );
} else {
	bwlai_fail( 'the dismissal option was left changed' );
}

// It must NOT be in a settings export — it is site state, not configuration.
$payload = BW_Lead_AI_Settings::export_payload();
$json    = wp_json_encode( $payload );
if ( ! isset( $payload['settings'][ $dismiss_option ] ) && false === strpos( $json, 'quality_dismissed' ) && false === strpos( $json, 'dismiss' ) ) {
	bwlai_ok( 'dismissals are absent from a settings export' );
} else {
	bwlai_fail( 'a settings export carries dismissal state' );
}

echo "\n== 5. CSS and JS fire on each page (trap 5) ==\n";
foreach ( array( BW_Lead_AI_Admin::PAGE_SLUG, BW_Lead_AI_Admin::REPORTS_SLUG, BW_Lead_AI_Admin::UTM_SLUG, BW_Lead_AI_Admin::HELP_SLUG ) as $slug ) {
	$GLOBALS['plugin_page'] = $slug;
	$GLOBALS['parent_file'] = null;
	$hook                   = get_plugin_page_hook( $slug, 'admin.php' );

	// Fresh registries so each page is judged on its own enqueue call.
	$GLOBALS['wp_styles']  = new WP_Styles();
	$GLOBALS['wp_scripts'] = new WP_Scripts();

	BW_Lead_AI_Admin::instance()->enqueue( $hook );

	$css = wp_style_is( 'bw-lead-ai-admin', 'enqueued' );
	$js  = wp_script_is( 'bw-lead-ai-utm-builder', 'registered' );
	if ( $css && $js ) {
		bwlai_ok( "$slug ($hook): stylesheet enqueued, scripts registered" );
	} else {
		bwlai_fail( "$slug ($hook): css=" . var_export( $css, true ) . ' js=' . var_export( $js, true ) );
	}
}
// And a page that is NOT ours must not get them.
$GLOBALS['wp_styles'] = new WP_Styles();
BW_Lead_AI_Admin::instance()->enqueue( 'edit.php' );
if ( ! wp_style_is( 'bw-lead-ai-admin', 'enqueued' ) ) {
	bwlai_ok( 'a foreign hook (edit.php) gets nothing' );
} else {
	bwlai_fail( 'the stylesheet leaks onto unrelated admin pages' );
}

echo "\n== 6. Old URLs still land somewhere ==\n";
add_filter(
	'wp_redirect',
	function ( $location ) {
		throw new Exception( (string) $location );
	},
	1
);
function bwlai_redirect_of( $get, $method ) {
	$_GET     = $get;
	$_REQUEST = $get;
	try {
		call_user_func( array( BW_Lead_AI_Admin::instance(), $method ) );
	} catch ( Exception $e ) {
		return $e->getMessage();
	}
	return '';
}
$cases = array(
	'tab=utm'      => array( array( 'page' => 'bw-lead-ai', 'tab' => 'utm' ), 'redirect_moved_screens', 'page=bw-lead-ai-utm' ),
	'tab=help'     => array( array( 'page' => 'bw-lead-ai', 'tab' => 'help' ), 'redirect_moved_screens', 'page=bw-lead-ai-help' ),
	'tab=reports'  => array( array( 'page' => 'bw-lead-ai', 'tab' => 'reports', 'range' => '90' ), 'redirect_moved_screens', 'page=bw-lead-ai-reports' ),
	'journeys'     => array( array( 'page' => 'bw-lead-ai-journeys' ), 'redirect_moved_screens', 'page=bw-lead-ai-reports' ),
	'journeys+tok' => array( array( 'page' => 'bw-lead-ai-journeys', 'token' => 'abc123', 'status' => 'held', 'paged' => '3' ), 'redirect_moved_screens', 'token=abc123' ),
	'tab=targets'  => array( array( 'page' => 'bw-lead-ai', 'tab' => 'targets' ), 'redirect_moved_screens', '' ),
);
/*
 * Calling the handler proves it does the right thing. It does NOT prove WordPress
 * ever reaches it — and for an UNREGISTERED slug it does not, on admin_init.
 *
 * wp-admin/admin.php requires wp-admin/menu.php (and with it
 * wp-admin/includes/menu.php) BEFORE it fires admin_init. That file ends in
 * `do_action( 'admin_page_access_denied' ); wp_die( 'Sorry, you are not allowed
 * to access this page.', 403 );` — so a bookmark to a moved, no-longer-registered
 * screen is already dead by the time admin_init runs. This suite passed while the
 * real page 403'd in a browser, because it only ever called the method.
 *
 * So assert the WIRING, not just the behaviour: the handler must be on
 * admin_page_access_denied, which is the one hook early enough to catch it.
 */
$denied_hooked = has_action( 'admin_page_access_denied', array( BW_Lead_AI_Admin::instance(), 'redirect_moved_screens' ) );
if ( false !== $denied_hooked ) {
	bwlai_ok( 'redirect_moved_screens is hooked to admin_page_access_denied (reachable for unregistered slugs)' );
} else {
	bwlai_fail( 'redirect_moved_screens is NOT on admin_page_access_denied — a bookmarked URL for a moved screen will 403 instead of forwarding, and admin_init is too late to save it' );
}
$init_hooked = has_action( 'admin_init', array( BW_Lead_AI_Admin::instance(), 'redirect_moved_screens' ) );
if ( false !== $init_hooked ) {
	bwlai_ok( 'redirect_moved_screens is hooked to admin_init (handles moved tabs on pages that DO resolve)' );
} else {
	bwlai_fail( 'redirect_moved_screens is not on admin_init — moved tab URLs will not forward' );
}

foreach ( $cases as $name => $case ) {
	list( $get, $method, $want ) = $case;
	$got = bwlai_redirect_of( $get, $method );
	if ( '' === $want ) {
		if ( '' === $got ) {
			bwlai_ok( "$name: not redirected (still a tab)" );
		} else {
			bwlai_fail( "$name: unexpectedly redirected to $got" );
		}
		continue;
	}
	if ( '' !== $got && false !== strpos( $got, $want ) ) {
		bwlai_ok( "$name → $got" );
	} else {
		bwlai_fail( "$name: expected a redirect containing '$want', got " . var_export( $got, true ) );
	}
}
// The journeys redirect must carry the view, or it lands on the aggregate report.
$got = bwlai_redirect_of( array( 'page' => 'bw-lead-ai-journeys', 'record' => 'deadbeef' ), 'redirect_moved_screens' );
if ( false !== strpos( $got, 'view=journeys' ) && false !== strpos( $got, 'record=deadbeef' ) ) {
	bwlai_ok( "record link → $got" );
} else {
	bwlai_fail( 'record link lost the view or the record: ' . var_export( $got, true ) );
}

echo "\n== 7. A site with no stored journeys ==\n";
// Read-time filters only. Nothing is written.
$zero_schema = function () {
	return 0;
};
add_filter( 'option_' . BW_Lead_AI_Handoff_Store::SCHEMA_OPTION, $zero_schema, 99 );
$no_handoff = function ( $value ) {
	if ( is_array( $value ) ) {
		$value['handoff_enabled'] = 0;
	}
	return $value;
};
add_filter( 'option_' . BW_LEAD_AI_OPTION, $no_handoff, 99 );

if ( ! BW_Lead_AI_Handoff_Admin::journeys_ui_enabled() ) {
	bwlai_ok( 'journeys_ui_enabled() is false, so no Reports entry is registered' );
} else {
	bwlai_fail( 'journeys_ui_enabled() still true with no store and no handoff' );
}
$got = bwlai_redirect_of( array( 'page' => 'bw-lead-ai-journeys', 'token' => 'abc' ), 'redirect_moved_screens' );
if ( false !== strpos( $got, 'page=bw-lead-ai' ) && false === strpos( $got, 'reports' ) ) {
	bwlai_ok( "a bookmarked journeys URL lands on Settings, not a 403: $got" );
} else {
	bwlai_fail( 'no-journeys fallback went somewhere unexpected: ' . var_export( $got, true ) );
}
$got = bwlai_redirect_of( array( 'page' => 'bw-lead-ai', 'tab' => 'reports' ), 'redirect_moved_screens' );
if ( '' === $got ) {
	bwlai_ok( 'a bookmarked &tab=reports falls through to the settings tab strip' );
} else {
	bwlai_fail( 'tab=reports redirected to an unregistered page: ' . $got );
}
remove_filter( 'option_' . BW_Lead_AI_Handoff_Store::SCHEMA_OPTION, $zero_schema, 99 );
remove_filter( 'option_' . BW_LEAD_AI_OPTION, $no_handoff, 99 );

echo "\n== 8. field_targets round-trip, including a custom dimension ==\n";
restore_error_handler();

$settings = BW_Lead_AI_Settings::get();
$aliases  = BW_Lead_AI_Settings::parse_parameter_aliases( $settings['parameter_aliases'] );
$custom   = array_keys( $aliases['custom'] );
echo '  custom dimensions on this site: ' . ( $custom ? implode( ', ', $custom ) : '(none)' ) . "\n";

$tag_keys = array_keys( BW_Lead_AI_Merge_Tags::available_tags( $settings ) );
echo '  register has ' . count( $tag_keys ) . " data points\n";
foreach ( array( 'sources', 'terms' ) as $legacy ) {
	if ( in_array( $legacy, $tag_keys, true ) ) {
		bwlai_ok( "legacy aggregation '$legacy' is still mappable" );
	} else {
		bwlai_fail( "legacy aggregation '$legacy' vanished from the register" );
	}
}

// Build a settings array that already HAS mappings, including one on a custom
// dimension, and prove a save of any tab returns it byte-for-byte. Purely in
// memory: the stored option is never touched.
$with_maps = $settings;

// A site with no custom dimensions still has to prove the custom path, so one is
// declared for the duration of the test — in memory, through a read filter.
if ( empty( $custom ) ) {
	$with_maps['parameter_aliases'] = rtrim( (string) $settings['parameter_aliases'] ) . "\nmatch_type : mt, utm_match_type";
	$custom                         = array( 'match_type' );
	echo "  (none stored — declaring 'match_type' for the duration of this test)\n";
}

$sample = array(
	'summary' => array( 'attr' => 'name', 'val' => 'lead_summary' ),
	'channel' => array( 'attr' => 'id', 'val' => 'lead_channel' ),
);
if ( $custom ) {
	$sample[ $custom[0] ] = array( 'attr' => 'class', 'val' => 'lead_' . $custom[0] );
}
if ( count( $custom ) > 1 ) {
	$sample[ $custom[1] ] = array( 'attr' => 'selector', 'val' => 'form.enquiry input[name="' . $custom[1] . '"]' );
}
$with_maps['field_targets'] = array_merge( $settings['field_targets'], $sample );

$filter_maps = function ( $value ) use ( $with_maps ) {
	return $with_maps;
};
add_filter( 'option_' . BW_LEAD_AI_OPTION, $filter_maps, 99 );

$live = BW_Lead_AI_Settings::get();
if ( maybe_serialize( $live['field_targets'] ) === maybe_serialize( $with_maps['field_targets'] ) ) {
	bwlai_ok( 'test mappings are in place (in memory only)' );
} else {
	bwlai_fail( 'could not stage the test mappings' );
}

$live_keys = array_keys( BW_Lead_AI_Merge_Tags::available_tags( $live ) );
echo '  register now has ' . count( $live_keys ) . " data points\n";
foreach ( $custom as $c ) {
	if ( in_array( $c, $live_keys, true ) ) {
		bwlai_ok( "custom dimension '$c' is a mappable row" );
	} else {
		bwlai_fail( "custom dimension '$c' missing from the register" );
	}
}

require_once ABSPATH . 'wp-admin/includes/plugin.php';
function bwlai_scrape( $html, $option ) {
	$post = array();
	if ( ! preg_match_all( '/<input\b[^>]*>|<(textarea|select)\b[^>]*>.*?<\/\1>/is', $html, $tags ) ) {
		return $post;
	}
	foreach ( $tags[0] as $tag ) {
		if ( ! preg_match( '/\bname="([^"]+)"/', $tag, $nm ) ) {
			continue;
		}
		$name = html_entity_decode( $nm[1], ENT_QUOTES );
		if ( 0 !== strpos( $name, $option . '[' ) ) {
			continue;
		}
		if ( preg_match( '/\s(disabled)(=|\s|>|\/)/i', $tag ) ) {
			continue;
		}
		$is_input = 0 === strpos( $tag, '<input' );
		$type     = ( $is_input && preg_match( '/\btype="([^"]+)"/', $tag, $tm ) ) ? strtolower( $tm[1] ) : 'text';
		if ( in_array( $type, array( 'checkbox', 'radio' ), true ) && ! preg_match( '/\s(checked)(=|\s|>|\/)/i', $tag ) ) {
			continue;
		}
		if ( 0 === strpos( $tag, '<textarea' ) ) {
			$value = preg_match( '/<textarea\b[^>]*>(.*?)<\/textarea>/is', $tag, $cm ) ? html_entity_decode( $cm[1], ENT_QUOTES ) : '';
		} elseif ( 0 === strpos( $tag, '<select' ) ) {
			$value = ( preg_match( '/<option\b[^>]*\bselected\b[^>]*value="([^"]*)"/i', $tag, $om )
				|| preg_match( '/<option\b[^>]*value="([^"]*)"[^>]*\bselected\b/i', $tag, $om ) )
				? html_entity_decode( $om[1], ENT_QUOTES ) : '';
		} else {
			$value = preg_match( '/\bvalue="([^"]*)"/', $tag, $vm ) ? html_entity_decode( $vm[1], ENT_QUOTES ) : '';
		}
		if ( ! preg_match( '/^' . preg_quote( $option, '/' ) . '\[([^\]]+)\](?:\[([^\]]*)\])?(?:\[([^\]]*)\])?$/', $name, $km ) ) {
			continue;
		}
		$k1 = $km[1];
		$k2 = isset( $km[2] ) ? $km[2] : '';
		$k3 = isset( $km[3] ) ? $km[3] : '';
		if ( '' !== $k3 ) {
			$post[ $k1 ][ $k2 ][ $k3 ] = $value;
		} elseif ( '' !== $k2 ) {
			$post[ $k1 ][ $k2 ] = $value;
		} else {
			$post[ $k1 ] = $value;
		}
	}
	return $post;
}

foreach ( array( 'targets', 'settings', 'other', 'events', 'journeys', 'handoff' ) as $tab ) {
	$_GET     = array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => $tab );
	$_REQUEST = $_GET;
	ob_start();
	BW_Lead_AI_Admin::instance()->render_page();
	$out = (string) ob_get_clean();

	$posted = bwlai_scrape( $out, BW_LEAD_AI_OPTION );
	$saved  = BW_Lead_AI_Settings::instance()->sanitize( $posted );

	$before = maybe_serialize( $live['field_targets'] );
	$after  = maybe_serialize( $saved['field_targets'] );
	if ( $before === $after ) {
		bwlai_ok( sprintf( '%-9s save: field_targets byte-identical (%d rows, md5 %s)', $tab, count( $saved['field_targets'] ), substr( md5( $after ), 0, 8 ) ) );
	} else {
		bwlai_fail( sprintf( '%s save: field_targets changed', $tab ) );
		echo '      before: ' . var_export( $live['field_targets'], true ) . "\n";
		echo '      after:  ' . var_export( $saved['field_targets'], true ) . "\n";
	}

	// Second pass: sanitizing the sanitizer's own output must not move anything.
	$twice = BW_Lead_AI_Settings::instance()->sanitize( $posted );
	if ( maybe_serialize( $saved ) !== maybe_serialize( $twice ) ) {
		bwlai_fail( "$tab: a second save is not idempotent" );
	}

	if ( 'targets' === $tab ) {
		foreach ( $sample as $key => $row ) {
			$rendered = ( false !== strpos( $out, 'value="' . esc_attr( $row['val'] ) . '"' ) );
			if ( $rendered ) {
				bwlai_ok( "mapping screen renders the configured value for '$key'" );
			} else {
				bwlai_fail( "mapping screen does not render the configured value for '$key'" );
			}
		}
	}
}

// A crafted key must not get into the option.
$_GET     = array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => 'targets' );
$_REQUEST = $_GET;
ob_start();
BW_Lead_AI_Admin::instance()->render_page();
$posted = bwlai_scrape( (string) ob_get_clean(), BW_LEAD_AI_OPTION );
$posted['field_targets']['../../evil']      = array( 'attr' => 'id', 'val' => 'x' );
$posted['field_targets']['<script>']        = array( 'attr' => 'id', 'val' => 'x' );
$posted['field_targets']['not_a_real_tag']  = array( 'attr' => 'id', 'val' => 'x' );
$posted['field_targets']['source']          = array( 'attr' => 'javascript', 'val' => 'ok_val' );
$hostile = BW_Lead_AI_Settings::instance()->sanitize( $posted );
$rejected = ! isset( $hostile['field_targets']['../../evil'] )
	&& ! isset( $hostile['field_targets']['<script>'] )
	&& ! isset( $hostile['field_targets']['not_a_real_tag'] )
	&& ! isset( $hostile['field_targets']['evil'] )
	&& ! isset( $hostile['field_targets']['script'] );
if ( $rejected && '' === $hostile['field_targets']['source']['attr'] ) {
	bwlai_ok( 'unknown keys are refused and an unknown match-by falls back to none' );
} else {
	bwlai_fail( 'the whitelist let something through: ' . var_export( array_keys( $hostile['field_targets'] ), true ) );
}

remove_filter( 'option_' . BW_LEAD_AI_OPTION, $filter_maps, 99 );

echo "\n== 9. A read-only user gets the reports and nothing else ==\n";
/*
 * The case the two capabilities exist for: somebody whose job is to read
 * enquiries. Two failures are possible and only one of them is loud.
 *
 * The loud one is a settings screen rendering for them. The QUIET one is the menu
 * disappearing — gate the top-level entry on manage and the tab is invisible to
 * exactly the people the read capability is for, while every screen inside it
 * would have rendered perfectly well. Nothing errors; the feature simply is not
 * there, and the person it was built for reports that "it didn't work".
 *
 * So this resolves the menu the way wp-admin/admin.php does, as a user holding
 * ONLY bw_lead_ai_view — no role, no manage_options, no administrator floor.
 */
$viewer = get_user_by( 'login', 'bwlai-cap-probe' );
if ( ! $viewer ) {
	$viewer_id = wp_insert_user(
		array(
			'user_login' => 'bwlai-cap-probe',
			'user_pass'  => wp_generate_password( 32 ),
			'user_email' => 'bwlai-cap-probe@example.invalid',
			'role'       => '',
		)
	);
	$viewer = is_wp_error( $viewer_id ) ? null : get_user_by( 'id', $viewer_id );
}

if ( ! $viewer ) {
	bwlai_fail( 'could not create a probe user to test the read capability with' );
} else {
	$viewer->add_cap( BW_Lead_AI_Caps::VIEW );
	wp_set_current_user( $viewer->ID );

	bwlai_assert( ! current_user_can( 'manage_options' ), 'the probe user is NOT an administrator' );
	bwlai_assert( BW_Lead_AI_Caps::can_view(), 'and can view' );
	bwlai_assert( ! BW_Lead_AI_Caps::can_manage(), 'and cannot manage' );

	// Rebuild the menu as this user. add_submenu_page() returns false for an entry
	// the current user cannot see, which is what makes this a real check.
	$GLOBALS['menu']    = array();
	$GLOBALS['submenu'] = array();
	do_action( 'admin_menu' );

	$top = wp_list_pluck( $GLOBALS['menu'], 2 );
	bwlai_assert(
		in_array( BW_Lead_AI_Admin::PAGE_SLUG, $top, true ),
		'the Lead AI menu APPEARS for a read-only user'
	);

	$subs = isset( $GLOBALS['submenu'][ BW_Lead_AI_Admin::PAGE_SLUG ] )
		? wp_list_pluck( $GLOBALS['submenu'][ BW_Lead_AI_Admin::PAGE_SLUG ], 2 )
		: array();
	bwlai_assert( in_array( BW_Lead_AI_Admin::REPORTS_SLUG, $subs, true ), 'Reports is offered to them' );
	bwlai_assert( in_array( BW_Lead_AI_Admin::HELP_SLUG, $subs, true ), 'Help is offered to them' );
	bwlai_assert( ! in_array( BW_Lead_AI_Admin::UTM_SLUG, $subs, true ), 'the UTM builder, which writes, is not' );

	// With Settings gone, WordPress points the parent at the first surviving
	// submenu. If that were empty the menu would be a dead link.
	bwlai_assert( ! empty( $subs ), 'the menu has somewhere to land them' );
	bwlai_assert( BW_Lead_AI_Admin::REPORTS_SLUG === $subs[0], 'and it lands on Reports' );

	/*
	 * The parent slug IS still in the submenu — but as WordPress's own auto-added
	 * link back to the parent, not as the Settings entry, which add_submenu_page()
	 * refused. Told apart by the capability it carries: the back-link inherits the
	 * parent's (read), the real Settings entry declares manage. If the manage one
	 * were ever listed for this user, the settings screen would be one click away
	 * from somebody who must not have it.
	 */
	$parent_entries = array();
	foreach ( (array) $GLOBALS['submenu'][ BW_Lead_AI_Admin::PAGE_SLUG ] as $entry ) {
		if ( BW_Lead_AI_Admin::PAGE_SLUG === $entry[2] ) {
			$parent_entries[] = $entry[1];
		}
	}
	bwlai_assert(
		! in_array( BW_Lead_AI_Caps::manage(), $parent_entries, true ),
		'no manage-gated entry is listed for them'
	);

	// And that back-link must not be a blank page: its callback is the settings
	// screen, which renders nothing for them.
	bwlai_assert(
		BW_Lead_AI_Admin::settings_fallback_for_viewer() !== '',
		'opening it sends them somewhere rather than rendering blank'
	);
	bwlai_assert(
		false !== strpos( BW_Lead_AI_Admin::settings_fallback_for_viewer(), BW_Lead_AI_Admin::REPORTS_SLUG ),
		'and that somewhere is Reports'
	);

	// The screens themselves, called directly — a capability on a menu entry is not
	// a guarantee the render method checks anything.
	$render = function ( $method, $args ) {
		$_GET     = $args;
		$_REQUEST = $args;
		ob_start();
		BW_Lead_AI_Admin::instance()->$method();
		return ob_get_clean();
	};
	bwlai_assert( '' === trim( $render( 'render_page', array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG ) ) ), 'the settings screen renders NOTHING for them' );
	bwlai_assert( '' === trim( $render( 'render_utm_page', array( 'page' => BW_Lead_AI_Admin::UTM_SLUG ) ) ), 'nor does the UTM builder' );
	bwlai_assert( '' !== trim( $render( 'render_help_page', array( 'page' => BW_Lead_AI_Admin::HELP_SLUG ) ) ), 'Help does render for them' );

	if ( BW_Lead_AI_Handoff_Admin::journeys_ui_enabled() ) {
		bwlai_assert( '' !== trim( $render( 'render_reports_page', array( 'page' => BW_Lead_AI_Admin::REPORTS_SLUG ) ) ), 'and so does Reports' );
	}

	// The REST route the UTM builder uses must refuse them, since the screen does.
	bwlai_assert( ! BW_Lead_AI_REST::instance()->require_manage(), 'the REST route refuses them' );
	bwlai_assert( BW_Lead_AI_REST::instance()->require_view(), 'but a read route would not' );

	// And the floor: an administrator keeps everything, with no role grant needed.
	wp_set_current_user( (int) $admins[0] );
	bwlai_assert( BW_Lead_AI_Caps::can_view() && BW_Lead_AI_Caps::can_manage(), 'an administrator still has both — nobody loses a screen on upgrade' );
	bwlai_assert( '' === BW_Lead_AI_Admin::settings_fallback_for_viewer(), 'and is never redirected away from Settings' );

	require_once ABSPATH . 'wp-admin/includes/user.php';
	wp_delete_user( $viewer->ID );
	bwlai_assert( ! get_user_by( 'login', 'bwlai-cap-probe' ), 'the probe user is removed' );
}

echo "\n== 10. Nothing was written ==\n";
$now = get_option( BW_LEAD_AI_OPTION );
echo '  settings option md5: ' . md5( maybe_serialize( $now ) ) . "\n";

echo "\n";
if ( empty( $GLOBALS['bwlai_fail'] ) ) {
	echo 'PASS: ' . (int) $GLOBALS['bwlai_ok'] . " checks.\n";
	exit( 0 );
}
echo 'FAIL: ' . count( $GLOBALS['bwlai_fail'] ) . ' problem(s) out of '
	. ( (int) $GLOBALS['bwlai_ok'] + count( $GLOBALS['bwlai_fail'] ) ) . " checks.\n";
foreach ( $GLOBALS['bwlai_fail'] as $f ) {
	echo "  - $f\n";
}
exit( 1 );
