<?php
/**
 * Saving one settings tab must not disturb any other tab's settings.
 *
 * The settings screen is several forms writing one option. Each form renders real
 * inputs for the fields it owns and hidden pass-through inputs for everything else;
 * the sanitizer then falls back to the DEFAULT for any key absent from the POST. So
 * a key that is in a form's "skip" list but has no matching input on that form is
 * silently reset to its default the next time that tab is saved.
 *
 * That has already happened twice: once when the tabs were first split out, and
 * again when Datapoints, retention and identity capture moved to the Journey Storage tab
 * and the cross-domain skip list was left claiming them. Neither was visible in
 * code review, and neither is catchable by a syntax lint — the page renders fine
 * and the data disappears one save later.
 *
 * This renders each tab for real, scrapes the form it produced, feeds that through
 * the actual sanitizer, and asserts every untouched setting survives.
 *
 * Usage:
 *   srv-gw wp --project bw-plugins -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/tab-save-roundtrip.php
 */

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

// render_page() checks manage_options, and WP-CLI runs with no current user.
$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] );
if ( ! current_user_can( 'manage_options' ) ) {
	fwrite( STDERR, "Could not acquire manage_options.\n" );
	exit( 1 );
}

/**
 * Every tab that renders a settings form. A tab added to the strip and not added
 * here is not covered — which is how a new tab ships with a stale skip list and
 * quietly resets somebody else's settings on its first save.
 */
$tabs = array( 'journeys', 'handoff', 'settings', 'events', 'other', 'targets' );

/**
 * Tabs that MUST post something. Without this, a tab whose form stopped rendering
 * — a moved section, a typo'd slug, a renderer that returns early — reports "no
 * option inputs, skipped" and the run still passes, which is the same false green
 * as not testing it at all.
 */
$must_post = array( 'journeys', 'handoff', 'settings', 'events', 'other', 'targets' );

/**
 * Settings that USED to exist and deliberately no longer do.
 *
 * A retired key stays in the stored option — deleting a site's saved data for a
 * cosmetic change is not warranted — but the sanitizer stops emitting it, so the
 * comparison below sees it disappear and would report it as damage on every tab
 * forever. Listing it here says "expected", and the check is inverted: a retired
 * key that comes BACK in the sanitizer's output is the failure, because that means
 * the removal was undone by half.
 */
$retired = array(
	// 1.8.0 — the source/medium separator is a constant now.
	'source_medium_separator',
);

/**
 * Settings that are still STORED and still read, but that no screen may edit or even
 * carry any more.
 *
 * `self_referral_hosts` is the retired owned-properties list. Its value is preserved —
 * it is folded into the `internal` row of Medium Mappings at read time, so deleting it
 * would delete a site's other owned properties — but it has no editor, and a hidden
 * pass-through would be a form field posting a value nobody can see. Worse, leaving one
 * in makes "is this setting gone from the UI?" impossible to answer by looking. The
 * assertion is therefore two-sided: no tab renders an input for it, AND the value
 * survives every save anyway (which the round-trip comparison below already proves,
 * because it is in $original and must still be in $saved).
 */
$no_control = array( 'self_referral_hosts' );

$original = BW_Lead_AI_Settings::get();
$failures = array();

/**
 * Settings a tab renders in a DERIVED form rather than exactly as stored, and the
 * value a save from that tab is therefore expected to write.
 *
 * `referrer_classification` gains its `internal` row at read time on any site
 * configured before that row existed — see
 * BW_Lead_AI_Settings::referrer_classification_text(). The Settings tab renders the
 * derived text on purpose: deriving instead of migrating the option is what puts the
 * row in front of the owner and makes their own save the thing that commits it. So on
 * that tab "the value changed" is the designed behaviour, once.
 *
 * The check is re-based, not waived. The saved value must equal the derived text
 * EXACTLY, a second pass must be a no-op (asserted below), and every other tab must
 * still write the stored value untouched — a pass-through that started rewriting a
 * setting it only carries would be the bug this file exists to catch.
 *
 * Tab => setting => the value that tab is expected to store.
 */
$derived_on = array(
	'settings' => array(
		'referrer_classification' => BW_Lead_AI_Settings::referrer_classification_text( $original ),
	),
);

// Idempotence, checked here rather than trusted: if deriving twice moved, every save
// from the Settings tab would move it again and the option would never settle.
foreach ( $derived_on as $tab_name => $map ) {
	foreach ( $map as $key => $value ) {
		$again = BW_Lead_AI_Settings::referrer_classification_text( array_merge( $original, array( $key => $value ) ) );
		if ( $again !== $value ) {
			$failures[] = sprintf( '[%s] %s: derivation is not idempotent', $tab_name, $key );
		}
	}
}

/**
 * The `label : value, value` settings are edited as rows, and the rows are the
 * fields most easily lost by accident: their names are two levels deep
 * (`option[channels_key][0]`), which is the deepest shape the scraper above can
 * see. Rename them one level deeper and every one of them silently stops being
 * scraped — the sanitizer then falls back to the textarea or the default and the
 * test above would still pass on any setting that happens to equal its default.
 *
 * So each of these is checked for presence and for row count, not just for
 * "nothing moved". Tab => settings whose rows that tab must post.
 */
$row_editors = array(
	'settings' => array( 'parameter_aliases', 'referrer_classification', 'click_ids', 'channels', 'channel_groups' ),
	'events'   => array( 'event_custom_selectors' ),
);

/**
 * Settings a tab must render a REAL, editable control for — not a hidden
 * pass-through.
 *
 * The round-trip check above cannot tell the difference: a checkbox that is off
 * and a hidden pass-through carrying 0 both come back as 0, so a tab that lost
 * its controls entirely still round-trips perfectly while being useless. This is
 * what makes a tab genuinely covered rather than merely rendered.
 *
 * Tab => settings whose editable input that tab owns.
 */
$owned_controls = array(
	'other' => array( 'debug', 'capture_ga_client_id' ),
	// `journey_save_mode` is a CHECKBOX, which is the shape this list exists for:
	// unticked it posts nothing, so it rides with a `journey_save_mode_present`
	// marker and the sanitizer reads the pair. If the control ever vanished, the
	// marker alone would post and every save would write 'confirm' — a silent
	// switch-off of journey storage on every submission-mode site.
	'journeys' => array( 'journey_save_mode' ),
	// Same shape again, twice. Both milestone lists are switched by a checkbox
	// whose OFF state is an empty list, and an unticked box posts nothing — so
	// each rides with a hidden `*_present` marker. The CHECKBOX is what is
	// asserted, not the marker: if the box vanished the marker would still post
	// on its own and every save of this tab would silently switch the milestones
	// off, which is precisely the failure the round-trip cannot see on a site
	// that has them off already.
	'events' => array( 'video_marks_on', 'scroll_marks_on' ),
);

/**
 * Settings whose stored value may legitimately be EMPTY, checked against a save
 * that omits them.
 *
 * The round-trip alone cannot see this one. `sanitize()` falls back for a key the
 * POST does not carry, and if that fallback is the shipped default then a setting
 * the owner deliberately emptied comes back on — while the comparison still
 * passes on every site whose stored value happens to equal the default, which is
 * most of them. Every tab carries these through hidden today, so this is a guard
 * on the fallback rather than on the tabs.
 */
$may_be_empty = array( 'event_video_milestones', 'event_scroll_thresholds', 'event_downloads', 'event_social_hosts' );

/**
 * The one setting stored as a nested array, and the tab that owns its editor.
 *
 * `field_targets` is a row per entry in BW_Lead_AI_Merge_Tags::available_tags() —
 * a set that varies per site, since it includes that site's custom dimensions and
 * its enabled interaction types. So "did the tab render its editor" cannot be a
 * count of six any more; it is "did it render a row for every tag this site has".
 *
 * Checked because the round-trip comparison alone cannot see it. An empty mapping
 * is not stored, so a screen that rendered NO rows at all would post nothing,
 * sanitize would return exactly what was stored, and the tab would pass while
 * being blank.
 *
 * Tab => the nested setting it must post a complete set of rows for.
 */
$nested_editors = array(
	'targets' => 'field_targets',
);

/** Render a tab and return the HTML it produced. */
function bwlai_render_tab( $tab ) {
	$_GET['page'] = BW_Lead_AI_Admin::PAGE_SLUG;
	$_GET['tab']  = $tab;
	unset( $_GET['wizard'] );
	ob_start();
	BW_Lead_AI_Admin::instance()->render_page();
	return (string) ob_get_clean();
}

/**
 * Pull the inputs a browser would submit for our option out of rendered HTML.
 * Only successful controls: unchecked boxes are omitted, exactly as a form posts.
 */
function bwlai_scrape_post( $html ) {
	$option = BW_LEAD_AI_OPTION;
	$post   = array();

	// <textarea> and <select> hold their value as content, not as an attribute, so
	// they need the closing tag captured too.
	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;
		}

		// A disabled control is not a successful control: the browser never sends
		// it. The row editors depend on this — each mapping section renders BOTH a
		// row editor and the textarea behind "Edit as text", with whichever one is
		// not in use disabled, so exactly one of them posts. A scraper that ignored
		// `disabled` would post both and test a state no browser can produce.
		// Matched as an attribute, not a substring, for the same reason as `checked`.
		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 ) ) {
			// Match the ATTRIBUTE, not the substring: field names like
			// `event_types_checked[...]` contain "checked" and made every box on
			// the Interactions tab look ticked.
			if ( ! preg_match( '/\s(checked)(=|\s|>|\/)/i', $tag ) ) {
				continue; // not submitted
			}
		}

		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 ) : '';
		}

		// Up to THREE levels: `option[field_targets][source][attr]` is the deepest
		// name the settings screens produce, and it is the one that matters most —
		// while this matched two levels only, every field_targets input was skipped,
		// so the mapping tab round-tripped a POST that contained none of the setting
		// it exists to edit and passed for it.
		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;
}

echo "Tab save round-trip\n===================\n";

foreach ( $tabs as $tab ) {
	$html = bwlai_render_tab( $tab );

	if ( false !== stripos( $html, 'critical error' ) || '' === trim( $html ) ) {
		$failures[] = sprintf( '[%s] tab did not render', $tab );
		continue;
	}

	$posted = bwlai_scrape_post( $html );
	if ( empty( $posted ) ) {
		if ( in_array( $tab, $must_post, true ) ) {
			$failures[] = sprintf( '[%s] tab rendered no settings inputs at all', $tab );
			echo sprintf( "  %-9s FAIL no option inputs — this tab is supposed to save\n", $tab );
		} else {
			echo sprintf( "  %-9s no option inputs (nothing to save) — skipped\n", $tab );
		}
		continue;
	}

	if ( isset( $owned_controls[ $tab ] ) ) {
		foreach ( $owned_controls[ $tab ] as $key ) {
			$name    = preg_quote( BW_LEAD_AI_OPTION . '[' . $key . ']', '/' );
			$pattern = '/<input\b(?![^>]*type="hidden")[^>]*\bname="' . $name . '"/i';
			if ( preg_match( $pattern, $html ) ) {
				echo sprintf( "  %-9s %s: editable control present\n", $tab, $key );
			} else {
				$failures[] = sprintf( '[%s] %s: no editable input on this tab (pass-through only?)', $tab, $key );
			}
		}
	}

	foreach ( $no_control as $key ) {
		if ( false !== strpos( $html, 'name="' . BW_LEAD_AI_OPTION . '[' . $key . ']"' ) ) {
			$failures[] = sprintf( '[%s] %s: retired setting still has a form field', $tab, $key );
			echo sprintf( "  %-9s FAIL %s still rendered as a field\n", $tab, $key );
		}
	}

	if ( isset( $row_editors[ $tab ] ) ) {
		foreach ( $row_editors[ $tab ] as $key ) {
			$expected_text = isset( $derived_on[ $tab ][ $key ] ) ? $derived_on[ $tab ][ $key ] : $original[ $key ];
			$stored        = count( BW_Lead_AI_Settings::split_pair_lines( $expected_text ) );
			$posted_rows = isset( $posted[ $key . '_key' ] ) ? count( (array) $posted[ $key . '_key' ] ) : 0;

			if ( empty( $posted[ $key . '_editor' ] ) ) {
				$failures[] = sprintf( '[%s] %s: row editor did not post its marker', $tab, $key );
			}
			if ( isset( $posted[ $key ] ) ) {
				$failures[] = sprintf( '[%s] %s: the text escape hatch posted alongside the rows', $tab, $key );
			}
			if ( $posted_rows !== $stored ) {
				$failures[] = sprintf( '[%s] %s: %d row(s) scraped, %d stored', $tab, $key, $posted_rows, $stored );
			} else {
				echo sprintf( "  %-9s %s: %d row(s) scraped\n", $tab, $key, $posted_rows );
			}
		}
	}

	if ( isset( $nested_editors[ $tab ] ) ) {
		$key      = $nested_editors[ $tab ];
		$expected = array_keys( BW_Lead_AI_Merge_Tags::available_tags( $original ) );
		// A mapping stored for something the register no longer has still gets a row,
		// so that it can be seen and cleared.
		foreach ( (array) $original[ $key ] as $stored_key => $stored_row ) {
			if ( ! empty( $stored_row['val'] ) && ! in_array( $stored_key, $expected, true ) ) {
				$expected[] = $stored_key;
			}
		}
		$rows    = isset( $posted[ $key ] ) && is_array( $posted[ $key ] ) ? $posted[ $key ] : array();
		$missing = array_diff( $expected, array_keys( $rows ) );

		if ( empty( $rows ) ) {
			$failures[] = sprintf( '[%s] %s: no rows posted — this tab owns that editor', $tab, $key );
		} elseif ( $missing ) {
			$failures[] = sprintf( '[%s] %s: no row for %s', $tab, $key, implode( ', ', $missing ) );
		} else {
			echo sprintf( "  %-9s %s: %d row(s) scraped, every available tag present\n", $tab, $key, count( $rows ) );
		}
		foreach ( $rows as $row_key => $row ) {
			if ( ! is_array( $row ) || ! array_key_exists( 'attr', $row ) || ! array_key_exists( 'val', $row ) ) {
				$failures[] = sprintf( '[%s] %s[%s]: row is missing attr or val', $tab, $key, $row_key );
			}
		}
	}

	$saved   = BW_Lead_AI_Settings::instance()->sanitize( $posted );

	// A save must settle. Sanitizing the same POST twice is the cheap half; the
	// expensive half — that the SECOND save writes what the first one did — is what
	// the comparison below proves, since it asserts the output equals what was
	// stored and therefore what the next render would produce.
	$again = BW_Lead_AI_Settings::instance()->sanitize( $posted );
	if ( maybe_serialize( $saved ) !== maybe_serialize( $again ) ) {
		$failures[] = sprintf( '[%s] sanitize is not idempotent for the same POST', $tab );
	}

	$changed = array();
	foreach ( $retired as $key ) {
		if ( array_key_exists( $key, $saved ) ) {
			$changed[] = $key . ' (retired, but the sanitizer still writes it)';
		}
	}
	foreach ( $original as $key => $value ) {
		if ( in_array( $key, $retired, true ) ) {
			continue;
		}
		if ( isset( $derived_on[ $tab ][ $key ] ) ) {
			$value = $derived_on[ $tab ][ $key ];
		}
		if ( ! array_key_exists( $key, $saved ) ) {
			$changed[] = $key . ' (dropped)';
			continue;
		}
		// Serialize both sides: a (string) cast collapses every array to "Array",
		// which would hide a change to an array-valued setting such as field_targets.
		if ( maybe_serialize( $value ) !== maybe_serialize( $saved[ $key ] ) ) {
			$changed[] = sprintf( '%s: %s -> %s', $key, var_export( $value, true ), var_export( $saved[ $key ], true ) );
		}
	}

	if ( empty( $changed ) ) {
		echo sprintf( "  %-9s OK (%d fields posted, nothing else moved)\n", $tab, count( $posted ) );
	} else {
		echo sprintf( "  %-9s CHANGED %d setting(s):\n", $tab, count( $changed ) );
		foreach ( $changed as $line ) {
			echo '      ' . $line . "\n";
			$failures[] = sprintf( '[%s] %s', $tab, $line );
		}
	}
}

// --- an emptied setting stays emptied -----------------------------------
// Driven straight through sanitize(), because the shape being guarded against
// is one no tab produces: a POST with the key absent entirely. See $may_be_empty.
//
// The emptied state is staged with a FILTER, not a write. This file is read-only
// by design — settings-portability.php is the one test here that writes — and a
// round-trip test that starts mutating the option it is checking would be able
// to corrupt the very thing it exists to protect.
echo "\nemptied settings survive a save that omits them\n";
foreach ( $may_be_empty as $key ) {
	$empty_one = function ( $value ) use ( $key ) {
		if ( is_array( $value ) ) {
			$value[ $key ] = '';
		}
		return $value;
	};
	add_filter( 'option_' . BW_LEAD_AI_OPTION, $empty_one );
	$after = BW_Lead_AI_Settings::instance()->sanitize( array( 'debug' => '0' ) );
	remove_filter( 'option_' . BW_LEAD_AI_OPTION, $empty_one );

	if ( '' === $after[ $key ] ) {
		echo sprintf( "  %-25s OK (stayed empty)\n", $key );
	} else {
		echo sprintf( "  %-25s RESURRECTED as %s\n", $key, var_export( $after[ $key ], true ) );
		$failures[] = sprintf( '[fallback] %s came back as %s after a save that omitted it', $key, var_export( $after[ $key ], true ) );
	}
}

echo "\n";
if ( empty( $failures ) ) {
	echo "PASS: every tab saves without disturbing another tab's settings.\n";
	exit( 0 );
}
echo 'FAIL: ' . count( $failures ) . " unintended change(s).\n";
exit( 1 );
