<?php
/**
 * Export, import and factory reset, proved on a real option.
 *
 * The only proof that a settings export works is a ROUND TRIP: take the file,
 * destroy the settings, put the file back, and show that every key the export
 * claimed to carry is byte-identical to what was there before. Anything short of
 * that — "the export contains 11 keys", "the importer returned true" — tests the
 * plumbing and not the promise.
 *
 * The second half is the more important one. An import is a JSON document a browser
 * sent: it may be a different plugin's export, a hand-edited file naming a key that
 * is not a setting, or a deliberate attempt to write a script tag into a value that
 * is later rendered. So the hostile cases are run for real, through the real
 * handlers, and the option is inspected afterwards.
 *
 * WIRING, NOT FUNCTIONS. Every action here is exercised through the admin-post
 * handler with a real nonce, not by calling the worker underneath it — because a
 * handler that behaves perfectly and is not hooked to anything is exactly the bug
 * this suite has shipped before (docs/SESSION-LOG.md, the 403 that passed its test).
 *
 * WRITES, AND RESTORES. This is the one test in the plugin that changes the stored
 * settings. It snapshots them first, restores them at the end, and prints the md5
 * before and after so a run can be shown to have left nothing behind. It must NOT be
 * run against a live client site: a factory reset on the way through is real.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/settings-portability.php
 */

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

require_once ABSPATH . 'wp-admin/includes/plugin.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] );

$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";
}

// Redirects are how every handler here finishes. Turned into an exception so the
// handler's exit() never runs and the destination can be asserted.
add_filter(
	'wp_redirect',
	function ( $location ) {
		throw new Exception( (string) $location );
	},
	1
);

/**
 * Run one admin-post handler exactly as WordPress would: a real nonce in the
 * request, the POST it would have received, and the redirect caught.
 *
 * SLASHED, and that detail is the whole test. WordPress runs wp_magic_quotes() on
 * every request, so $_POST always arrives addslashes()'d and every reader has to
 * wp_unslash() it. A test that assigned $_POST raw would be testing a request shape
 * that never happens — and it would pass for a handler that had FORGOTTEN to
 * unslash, while the real thing stored a backslash before every quote. It also
 * matters more here than anywhere else in this plugin: the payload is JSON, whose
 * newlines are the two characters \ and n, so an over- or under-slashed round trip
 * silently turns every line break in the imported rules into the letter n.
 */
function bwlai_post( $method, $action, $post = array() ) {
	$post['_wpnonce'] = wp_create_nonce( $action );
	$post['action']   = $action;
	$_POST            = wp_slash( $post );
	$_GET             = array();
	$_REQUEST         = $_POST;
	try {
		call_user_func( array( BW_Lead_AI_Admin::instance(), $method ) );
	} catch ( Exception $e ) {
		return $e->getMessage();
	}
	return '';
}

// --- snapshot ------------------------------------------------------------

$option_before = get_option( BW_LEAD_AI_OPTION, null );
$md5_before    = md5( maybe_serialize( $option_before ) );
echo "\n== 0. Snapshot ==\n";
echo "  settings option md5 before: $md5_before\n";

// ==========================================================================
echo "\n== 1. What the export carries, and what it refuses to ==\n";

$payload  = BW_Lead_AI_Settings::export_payload();
$portable = BW_Lead_AI_Settings::PORTABLE_SETTINGS;

if ( BW_Lead_AI_Settings::EXPORT_MARKER === $payload['format'] && (int) $payload['schema'] === BW_Lead_AI_Settings::EXPORT_SCHEMA ) {
	bwlai_ok( 'export carries the marker and a schema number' );
} else {
	bwlai_fail( 'export has no usable marker/schema' );
}

$exported_keys = array_keys( $payload['settings'] );
if ( $exported_keys === $portable ) {
	bwlai_ok( 'export carries exactly PORTABLE_SETTINGS, in order (' . count( $portable ) . ' keys): ' . implode( ', ', $portable ) );
} else {
	bwlai_fail(
		'export keys do not match PORTABLE_SETTINGS — extra: '
		. implode( ',', array_diff( $exported_keys, $portable ) )
		. ' missing: ' . implode( ',', array_diff( $portable, $exported_keys ) )
	);
}

/*
 * The keys that must NEVER travel, each with the reason it must not. Named
 * individually rather than derived as "everything else", because the value of this
 * assertion is that somebody adding a key to PORTABLE_SETTINGS has to come here and
 * argue with a sentence.
 */
$must_not_travel = array(
	'handoff_enabled'          => 'turns on sending a visitor history to another domain',
	'handoff_mode_data'        => 'ditto',
	'handoff_mode_link'        => 'ditto',
	'handoff_ack'              => 'is the owner\'s recorded acknowledgement of that sharing',
	'handoff_domains'          => 'names one site\'s destination',
	'handoff_origins'          => 'ditto',
	'handoff_self_origins'     => 'names the other addresses one site answers on',
	'handoff_field_map'        => 'names the field IDs on one site\'s destination form',
	'handoff_identity_map'     => 'ditto',
	'handoff_link_field'       => 'ditto',
	'handoff_param'            => 'is inert without the rest of the handoff config',
	'handoff_ttl'              => 'ditto',
	'handoff_retention'        => 'ditto',
	'handoff_datapoints'       => 'decides what personal data is retained',
	'handoff_share_datapoints' => 'decides what is handed to a third party',
	'capture_identity'         => 'starts retaining personal data',
	'capture_submission'       => 'ditto',
	'field_targets'            => 'names the fields of one site\'s forms',
	'event_custom_selectors'   => 'is CSS written against one site\'s markup',
	'debug'                    => 'is per-site troubleshooting state',
	'journey_save_mode'        => 'is derived per site from that site\'s own handoff config',
	'journey_update_mode'      => 'ditto',
	'continuity_enabled'       => 'feeds the same per-site derivation',
	'self_referral_hosts'      => 'is retired: a migration source no screen writes',
);
$leaked = array();
foreach ( $must_not_travel as $key => $why ) {
	if ( array_key_exists( $key, $payload['settings'] ) ) {
		$leaked[] = "$key ($why)";
	}
}
if ( empty( $leaked ) ) {
	bwlai_ok( 'none of the ' . count( $must_not_travel ) . ' site-specific / data-consequence keys is in the export' );
} else {
	bwlai_fail( 'the export carries: ' . implode( ' ; ', $leaked ) );
}

// Every key in defaults() is either exported or explicitly refused. A key that is
// neither is a key nobody has decided about.
$undecided = array();
foreach ( array_keys( BW_Lead_AI_Settings::defaults() ) as $key ) {
	if ( ! in_array( $key, $portable, true ) && ! isset( $must_not_travel[ $key ] ) ) {
		$undecided[] = $key;
	}
}
if ( empty( $undecided ) ) {
	bwlai_ok( 'every setting is either exported or explicitly refused — none undecided' );
} else {
	bwlai_fail( 'settings nobody has decided about: ' . implode( ', ', $undecided ) );
}

// No secret is in this option to leak. Checked against the shape a secret has, not
// against a list of names, so a future key called anything at all is still caught.
$json    = (string) wp_json_encode( $payload );
$secrets = array();
foreach ( array( '/[A-Za-z0-9+\/=]{40,}/', '/sk_live_/', '/AKIA[0-9A-Z]{16}/', '/BEGIN [A-Z ]*PRIVATE KEY/' ) as $pattern ) {
	if ( preg_match( $pattern, $json, $sm ) ) {
		$secrets[] = $pattern . ' matched ' . substr( $sm[0], 0, 24 );
	}
}
if ( empty( $secrets ) ) {
	bwlai_ok( 'nothing in the export has the shape of a key, token or password' );
} else {
	bwlai_fail( 'possible secret in the export: ' . implode( ' ; ', $secrets ) );
}

// ==========================================================================
echo "\n== 2. The handlers are actually hooked (trap: a handler nobody calls) ==\n";

$hooks = array(
	BW_Lead_AI_Admin::SETTINGS_EXPORT_ACTION       => 'handle_settings_export',
	BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION       => 'handle_settings_import',
	BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION => 'handle_settings_import_apply',
	BW_Lead_AI_Admin::SETTINGS_RESET_ACTION        => 'handle_settings_reset',
	BW_Lead_AI_Admin::QUALITY_DISMISS_ACTION       => 'handle_quality_dismiss',
);
foreach ( $hooks as $action => $method ) {
	if ( false !== has_action( 'admin_post_' . $action, array( BW_Lead_AI_Admin::instance(), $method ) ) ) {
		bwlai_ok( "admin_post_$action → $method()" );
	} else {
		bwlai_fail( "admin_post_$action is not hooked to $method() — the form would 404" );
	}
}

// ==========================================================================
echo "\n== 3. Export → factory reset → import, byte for byte ==\n";

$file = (string) wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
echo '  export file: ' . strlen( $file ) . " bytes\n";
$live_before   = BW_Lead_AI_Settings::get();
$before_values = array();
foreach ( $portable as $key ) {
	$before_values[ $key ] = $live_before[ $key ];
}

// --- reset, through the handler, with the wrong word first ---
$got = bwlai_post( 'handle_settings_reset', BW_Lead_AI_Admin::SETTINGS_RESET_ACTION, array( 'bw_reset_confirm' => 'yes' ) );
if ( false !== strpos( $got, 'reset_unconfirmed' ) && maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) === maybe_serialize( $option_before ) ) {
	bwlai_ok( 'a reset without the confirmation word changes nothing' );
} else {
	bwlai_fail( 'an unconfirmed reset was not refused: ' . var_export( $got, true ) );
}

$got = bwlai_post( 'handle_settings_reset', BW_Lead_AI_Admin::SETTINGS_RESET_ACTION, array( 'bw_reset_confirm' => ' reset ' ) );
if ( false !== strpos( $got, 'bw_settings=reset' ) ) {
	bwlai_ok( 'the confirmation word is accepted trimmed and case-insensitively' );
} else {
	bwlai_fail( 'a confirmed reset did not run: ' . var_export( $got, true ) );
}

$after_reset = get_option( BW_LEAD_AI_OPTION );
$defaults    = BW_Lead_AI_Settings::defaults();

/*
 * Factory state is sanitize(defaults()), not defaults(). update_option() runs the
 * registered sanitizer in a browser and does not in WP-CLI, so a reset that wrote
 * raw defaults would land on two different values depending on where it was run —
 * see BW_Lead_AI_Settings::factory_reset(). Asserting against the sanitized form is
 * what makes the two contexts one answer.
 */
$factory = BW_Lead_AI_Settings::instance()->sanitize( $defaults );
$diff    = array();
foreach ( $factory as $k => $v ) {
	if ( ! array_key_exists( $k, $after_reset ) || maybe_serialize( $after_reset[ $k ] ) !== maybe_serialize( $v ) ) {
		$diff[] = $k;
	}
}
if ( empty( $diff ) && count( $after_reset ) === count( $factory ) ) {
	bwlai_ok( 'after the reset the option IS factory state, key for key (' . count( $after_reset ) . ' keys)' );
} else {
	bwlai_fail( 'the reset did not land on factory state; differing keys: ' . implode( ', ', $diff ) );
}

// Idempotent, which is what makes the browser's second sanitize pass a no-op.
$twice = BW_Lead_AI_Settings::instance()->sanitize( $factory );
if ( maybe_serialize( $twice ) === maybe_serialize( $factory ) ) {
	bwlai_ok( 'sanitize() is idempotent on factory state — the browser\'s extra pass changes nothing' );
} else {
	bwlai_fail( 'sanitize() is not idempotent, so a browser reset lands somewhere else again' );
}

// And the only way factory state differs from the raw shipped defaults is the
// alignment padding in the mapping rules, which sanitize_text_field() collapses.
// Asserted rather than assumed: anything else moving would be a real change of
// meaning hidden inside a whitespace excuse.
$moved = array();
foreach ( $defaults as $k => $v ) {
	if ( ! is_string( $v ) ) {
		continue;
	}
	if ( (string) $factory[ $k ] === $v ) {
		continue;
	}
	$flat_a = preg_replace( '/[ \t]+/', ' ', $v );
	$flat_b = preg_replace( '/[ \t]+/', ' ', (string) $factory[ $k ] );
	if ( $flat_a !== $flat_b ) {
		$moved[] = $k;
	}
}
if ( empty( $moved ) ) {
	bwlai_ok( 'factory state differs from the shipped defaults only in alignment whitespace' );
} else {
	bwlai_fail( 'sanitize() changes more than whitespace in the defaults: ' . implode( ', ', $moved ) );
}

// --- import the file back, through the two-step handlers ---
$got = bwlai_post( 'handle_settings_import', BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION, array( 'bw_import_json' => $file ) );
if ( false !== strpos( $got, 'bw_import=preview' ) ) {
	bwlai_ok( 'a valid import parks a preview instead of writing' );
} else {
	bwlai_fail( 'the import did not reach the preview: ' . var_export( $got, true ) );
}
// Nothing may have been written yet. That is the whole point of the two steps.
if ( maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) === maybe_serialize( $factory ) ) {
	bwlai_ok( 'step one wrote nothing to the settings option' );
} else {
	bwlai_fail( 'step one changed the settings before the reader confirmed' );
}

$got = bwlai_post( 'handle_settings_import_apply', BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION, array( 'state' => 'apply' ) );
if ( false !== strpos( $got, 'bw_settings=imported' ) ) {
	bwlai_ok( 'step two applied it' );
} else {
	bwlai_fail( 'apply did not run: ' . var_export( $got, true ) );
}

$restored = BW_Lead_AI_Settings::get();
$bad      = array();
foreach ( $portable as $key ) {
	if ( (string) $restored[ $key ] !== (string) $before_values[ $key ] ) {
		$bad[] = $key;
	}
}
if ( empty( $bad ) ) {
	bwlai_ok( 'ROUND TRIP: all ' . count( $portable ) . ' exported settings are byte-identical to before the reset' );
	foreach ( $portable as $key ) {
		printf(
			"          %-24s %4d bytes  md5 %s\n",
			$key,
			strlen( (string) $restored[ $key ] ),
			substr( md5( (string) $restored[ $key ] ), 0, 8 )
		);
	}
} else {
	bwlai_fail( 'these exported settings did NOT survive the round trip: ' . implode( ', ', $bad ) );
	foreach ( $bad as $key ) {
		echo "      before: " . var_export( $before_values[ $key ], true ) . "\n";
		echo "      after:  " . var_export( $restored[ $key ], true ) . "\n";
	}
}

// A second apply must not silently re-run: the payload is gone.
$got = bwlai_post( 'handle_settings_import_apply', BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION, array( 'state' => 'apply' ) );
if ( false !== strpos( $got, 'import_expired' ) ) {
	bwlai_ok( 'a parked import is consumed once — a refresh cannot re-apply it' );
} else {
	bwlai_fail( 'the parked import survived being applied: ' . var_export( $got, true ) );
}

// Discard must throw it away without writing.
bwlai_post( 'handle_settings_import', BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION, array( 'bw_import_json' => $file ) );
$before_discard = maybe_serialize( get_option( BW_LEAD_AI_OPTION ) );
$got            = bwlai_post( 'handle_settings_import_apply', BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION, array( 'state' => 'discard' ) );
if ( false !== strpos( $got, 'import_discarded' ) && maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) === $before_discard ) {
	bwlai_ok( 'discard throws the payload away and writes nothing' );
} else {
	bwlai_fail( 'discard did something: ' . var_export( $got, true ) );
}

// ==========================================================================
echo "\n== 4. Hostile imports ==\n";

$guard = maybe_serialize( get_option( BW_LEAD_AI_OPTION ) );

$hostile = array(
	'not JSON at all'      => 'this is not json {{{',
	'empty'                => '',
	'a bare array'         => '[1,2,3]',
	'another plugin\'s export' => '{"format":"some-other-plugin","schema":1,"settings":{"channels":"Evil : evil/evil"}}',
	'no marker'            => '{"schema":1,"settings":{"channels":"Evil : evil/evil"}}',
	'a schema from the future' => '{"format":"' . BW_Lead_AI_Settings::EXPORT_MARKER . '","schema":99,"settings":{"channels":"Evil : evil/evil"}}',
	'no settings'          => '{"format":"' . BW_Lead_AI_Settings::EXPORT_MARKER . '","schema":1}',
	'only unknown keys'    => '{"format":"' . BW_Lead_AI_Settings::EXPORT_MARKER . '","schema":1,"settings":{"handoff_domains":"evil.example","capture_identity":1,"../../wp-config":"x"}}',
);
foreach ( $hostile as $name => $body ) {
	$got = bwlai_post( 'handle_settings_import', BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION, array( 'bw_import_json' => $body ) );
	$rejected = ( false !== strpos( $got, 'bw_settings=import_failed' ) );
	$intact   = ( maybe_serialize( get_option( BW_LEAD_AI_OPTION ) ) === $guard );
	if ( $rejected && $intact ) {
		bwlai_ok( sprintf( '%-26s rejected, option untouched', $name ) );
	} else {
		bwlai_fail( sprintf( '%s: rejected=%s intact=%s (%s)', $name, var_export( $rejected, true ), var_export( $intact, true ), $got ) );
	}
}

// A well-formed export carrying keys that are not settings: the good keys apply,
// the rest are named and dropped. This is the case that must NOT be a blanket
// rejection — a file from a newer version legitimately carries more.
$mixed = wp_json_encode(
	array(
		'format'   => BW_Lead_AI_Settings::EXPORT_MARKER,
		'schema'   => 1,
		'settings' => array(
			'channels'         => "Test Channel : test/test",
			'capture_identity' => 1,
			'handoff_domains'  => 'evil.example.com',
			'field_targets'    => array( 'summary' => array( 'attr' => 'id', 'val' => 'x' ) ),
			'../../evil'       => 'x',
			'debug'            => 1,
		),
	)
);
$got = bwlai_post( 'handle_settings_import', BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION, array( 'bw_import_json' => $mixed ) );
if ( false !== strpos( $got, 'bw_import=preview' ) ) {
	bwlai_ok( 'a mixed file reaches the preview on the strength of its valid keys' );
} else {
	bwlai_fail( 'a mixed file was rejected outright: ' . var_export( $got, true ) );
}
$live_now        = BW_Lead_AI_Settings::get();
$identity_before = (int) $live_now['capture_identity'];
$domains_before  = (string) $live_now['handoff_domains'];
$debug_before    = (int) $live_now['debug'];
bwlai_post( 'handle_settings_import_apply', BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION, array( 'state' => 'apply' ) );
$now      = BW_Lead_AI_Settings::get();
$problems = array();
if ( false === strpos( (string) $now['channels'], 'Test Channel' ) ) {
	$problems[] = 'the one legitimate key did not apply';
}
if ( (int) $now['capture_identity'] !== $identity_before ) {
	$problems[] = 'capture_identity was changed by an import';
}
if ( (string) $now['handoff_domains'] !== $domains_before ) {
	$problems[] = 'handoff_domains was changed by an import';
}
if ( (int) $now['debug'] !== $debug_before ) {
	$problems[] = 'debug was changed by an import';
}
if ( isset( $now['../../evil'] ) || isset( $now['evil'] ) ) {
	$problems[] = 'an arbitrary key reached the option';
}
if ( empty( $problems ) ) {
	bwlai_ok( 'only the portable key applied; the personal-data, site-specific and unknown keys were all refused' );
} else {
	bwlai_fail( implode( '; ', $problems ) );
}

// A script tag in a value must be sanitized, not stored raw, and must never reach
// the screen unescaped.
$xss = wp_json_encode(
	array(
		'format'   => BW_Lead_AI_Settings::EXPORT_MARKER,
		'schema'   => 1,
		'settings' => array(
			'channels'          => "Evil <script>alert(1)</script> : \"><img src=x onerror=alert(1)>/cpc",
			'referrer_classification' => "organic : <script>alert('r')</script>",
		),
	)
);
bwlai_post( 'handle_settings_import', BW_Lead_AI_Admin::SETTINGS_IMPORT_ACTION, array( 'bw_import_json' => $xss ) );
bwlai_post( 'handle_settings_import_apply', BW_Lead_AI_Admin::SETTINGS_IMPORT_APPLY_ACTION, array( 'state' => 'apply' ) );
$stored = BW_Lead_AI_Settings::get();
$raw    = (string) $stored['channels'] . "\n" . (string) $stored['referrer_classification'];
if ( false === stripos( $raw, '<script' ) && false === stripos( $raw, 'onerror=' ) ) {
	bwlai_ok( 'a script payload is stripped by sanitize() before it is stored' );
} else {
	bwlai_fail( 'raw markup reached the option: ' . var_export( $raw, true ) );
}
// And whatever DID survive must still be escaped when the screen draws it.
$_GET     = array( 'page' => BW_Lead_AI_Admin::PAGE_SLUG, 'tab' => 'settings' );
$_REQUEST = $_GET;
ob_start();
BW_Lead_AI_Admin::instance()->render_page();
$screen = (string) ob_get_clean();
if ( false === stripos( $screen, '<script>alert' ) && false === stripos( $screen, 'onerror=alert' ) ) {
	bwlai_ok( 'the settings screen renders the imported value escaped' );
} else {
	bwlai_fail( 'the settings screen echoed an imported payload unescaped' );
}

// ==========================================================================
echo "\n== 5. Restore ==\n";

if ( null === $option_before ) {
	delete_option( BW_LEAD_AI_OPTION );
} else {
	update_option( BW_LEAD_AI_OPTION, $option_before );
}
$md5_after = md5( maybe_serialize( get_option( BW_LEAD_AI_OPTION, null ) ) );
echo "  settings option md5 after:  $md5_after\n";
if ( $md5_before === $md5_after ) {
	bwlai_ok( 'the settings option is back exactly as it was found' );
} else {
	bwlai_fail( "THE SETTINGS OPTION WAS LEFT CHANGED ($md5_before -> $md5_after)" );
}

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