<?php
/**
 * Nothing about the enquirer travels in a URL.
 *
 * **The failure this exists to catch leaves no trace in the plugin at all.** The
 * generated destination tag reported a submission by putting the enquirer's name,
 * email and phone in the confirm URL's query string. It worked perfectly. It also
 * wrote all three, in plaintext, into the web server access log of every host the
 * request touched — logs that are rotated, backed up and shipped around by tooling
 * that has no idea it is carrying personal data. On a school's inquiry form the
 * enquirer is a parent and the subject is a child.
 *
 * It cannot be fixed on the receiving side: the access-log line is written before
 * any of that server's code runs. The only place it can be stopped is in the
 * snippet, which is why this file asserts the SHAPE of generated JavaScript rather
 * than the behaviour of a function.
 *
 * **And the back-compatibility half matters just as much.** A snippet already
 * pasted on a destination site keeps sending identity in the query string until
 * somebody regenerates and re-pastes it. If the server ever stopped reading the
 * query string, those sites would silently stop recording who enquired — no error,
 * no warning, just leads with no name on them. So both readings are asserted, and
 * the query-string one is asserted as REQUIRED rather than as tolerated.
 *
 * Read-only: builds a snippet in memory and dispatches REST requests that create
 * nothing. It does not write.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/confirm-privacy.php
 */

if ( ! defined( 'ABSPATH' ) ) {
	fwrite( STDERR, "Run via: wp eval-file\n" );
	exit( 1 );
}
if ( ! class_exists( 'BW_Lead_AI_Handoff_Wizard' ) ) {
	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 — the confirm call carries no personal data in its URL\n";
echo "=================================================================\n";

$settings_md5 = md5( (string) maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) );

// =========================================================================
$section( '(a) the generated tag puts identity in the body, not the URL' );
{
	$snippet = BW_Lead_AI_Handoff_Wizard::build_snippet(
		array(
			'mode'       => 'data',
			'form_url'   => 'https://forms.example.test/inquiry',
			'fields'     => array(),
			'link_field' => '',
			'identity'   => array( 'email' => 'email', 'first' => 'fname', 'last' => 'lname', 'phone' => 'tel' ),
			'context'    => 'Inquiry',
		)
	);

	$ok( 'a snippet was generated', is_string( $snippet ) && '' !== $snippet, gettype( $snippet ) );

	// Isolate confirm() so an identity mention elsewhere cannot mask a regression
	// here — the claim call legitimately builds a URL.
	$body = '';
	if ( preg_match( '#function confirm\(\)\s*\{(.*?)\n  \}#s', $snippet, $m ) ) {
		$body = $m[1];
	}
	$ok( 'confirm() was found in it', '' !== $body );

	// THE ASSERTION. Any of these means identity is being concatenated into a URL.
	$ok(
		'nothing appends identity to the url',
		! preg_match( '#url\s*\+=#', $body ),
		'url is being appended to'
	);
	$ok(
		'the encoded-bracket query form is gone entirely',
		false === strpos( $snippet, '%5B' ),
		'a query-string identity parameter is still being built'
	);
	$ok(
		'the confirm url is only the endpoint and the token',
		preg_match( '#var\s+url\s*=\s*API \+ encodeURIComponent\(state\.token\) \+ \'/confirm\';#', $body ),
		'url is built from more than the token'
	);
	$ok( 'context travels in the body', false !== strpos( $body, "var body = 'context='" ) );
	$ok( 'identity travels in the body', false !== strpos( $body, "body += '&identity[" ) );

	// The transport itself.
	$ok( 'sendBeacon is given a Blob, not a bare url', false !== strpos( $body, 'navigator.sendBeacon(url, blob)' ) );
	$ok(
		'with a CORS-safelisted content type, so no preflight',
		false !== strpos( $body, "type: 'application/x-www-form-urlencoded'" )
	);
	$ok(
		'the fallback carries the body too',
		preg_match( '#fetch\(url, \{.*?body: body#s', $body ),
		'the fetch fallback would drop identity'
	);
	$ok(
		'and declares the same content type',
		preg_match( "#headers: \{ 'Content-Type': 'application/x-www-form-urlencoded' \}#", $body )
	);
	$ok(
		'a sendBeacon that refuses the payload falls through rather than giving up',
		preg_match( '#if \(navigator\.sendBeacon\(url, blob\)\) \{ return; \}#', $body )
	);

	// Syntax is checked where node lives — see the note at the end of this file.
	// Asserting it here would mean asserting `node` is installed in a WordPress
	// container, which is not a property of this plugin.

	// Whole-snippet sweep: no other line may put a person into a URL either.
	$ok(
		'no line anywhere in the snippet puts identity into a query string',
		! preg_match( '#\?[^\'"\n]*identity#i', $snippet ) && ! preg_match( '#&identity[^\'"\n]*=#', str_replace( "&identity[' + key + ']=", '', $snippet ) ),
		'an identity query parameter survives somewhere'
	);
}

// =========================================================================
$section( '(b) the server reads identity from a form-encoded body' );
{
	/*
	 * Built the way a real request arrives, which is NOT `set_body()`.
	 *
	 * WP_REST_Request only parses its own body for methods that are not POST —
	 * for a POST, PHP has already populated $_POST and `rest_api_loaded()` hands
	 * that straight to `set_body_params()`. A test that called `set_body()` and
	 * asserted `get_param()` would fail against a server that works perfectly,
	 * which is exactly what the first draft of this file did.
	 *
	 * So the two halves are asserted separately: that PHP turns the wire format
	 * into the nested array (parse_str is what PHP itself uses), and that the
	 * request object then resolves it.
	 */
	$wire = 'context=Inquiry&identity[email]=parent%40example.test&identity[first]=Sam&identity[phone]=555';
	$parsed = array();
	parse_str( $wire, $parsed );

	$ok( 'PHP turns the bracket syntax into a nested array', isset( $parsed['identity']['email'] ), wp_json_encode( $parsed ) );
	$ok( 'and decodes the percent-encoding', isset( $parsed['identity']['email'] ) && 'parent@example.test' === $parsed['identity']['email'], wp_json_encode( $parsed ) );

	$request = new WP_REST_Request( 'POST', '/bw-lead-ai/v1/handoff/' . str_repeat( 'a', 32 ) . '/confirm' );
	$request->set_header( 'Content-Type', 'application/x-www-form-urlencoded' );
	$request->set_body_params( $parsed );

	$ok( 'context is readable from the body', 'Inquiry' === $request->get_param( 'context' ), var_export( $request->get_param( 'context' ), true ) );

	$identity = $request->get_param( 'identity' );
	$ok( 'identity is readable from the body', is_array( $identity ), gettype( $identity ) );
	$ok( 'and arrives as the shape sanitize_identity() expects', is_array( $identity ) && isset( $identity['email'], $identity['first'] ), wp_json_encode( $identity ) );
	$ok( 'with the values intact', is_array( $identity ) && 'parent@example.test' === $identity['email'], is_array( $identity ) ? wp_json_encode( $identity ) : '—' );
}

// =========================================================================
$section( '(c) a tag already pasted on a destination keeps working' );
{
	/*
	 * REQUIRED, not tolerated. Every snippet generated before this change sends
	 * identity in the query string, and those are live on other people's sites
	 * until somebody regenerates and re-pastes them. A server that stopped reading
	 * the query string would not error — it would record leads with no name on
	 * them, and nobody would notice for weeks.
	 */
	$legacy = new WP_REST_Request( 'POST', '/bw-lead-ai/v1/handoff/' . str_repeat( 'b', 32 ) . '/confirm' );
	$legacy->set_query_params(
		array(
			'context'  => 'Inquiry',
			'identity' => array( 'email' => 'parent@example.test', 'last' => 'Okafor' ),
		)
	);

	$ok( 'context still reads from the query string', 'Inquiry' === $legacy->get_param( 'context' ) );
	$old = $legacy->get_param( 'identity' );
	$ok( 'identity still reads from the query string', is_array( $old ) && 'parent@example.test' === $old['email'], wp_json_encode( $old ) );
	$ok( 'including the other fields', is_array( $old ) && 'Okafor' === $old['last'], wp_json_encode( $old ) );
}

// =========================================================================
$section( '(d) nothing was written' );
{
	$ok( 'the settings option is untouched', md5( (string) maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) ) === $settings_md5 );
}

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