<?php
/**
 * Flagging a journey as spam, unqualified, qualified or closed.
 *
 * **Three decisions are pinned here, and each of them is one somebody would
 * reasonably reverse without realising what it costs.**
 *
 * 1. *The flag lives on the JOURNEY, not on the lead.* `BW_Lead_AI_Leads::resolve()`
 *    returns nothing without an email address, so a leads-table flag could not be
 *    applied to a spam submission with a junk address, or to anything at all on a
 *    site that does not capture identity. Spam is the case that asked for this
 *    feature, and it is precisely the case a leads-table flag cannot serve.
 *
 * 2. *Clearing is a real value.* `''` means nobody has judged this one yet, and it
 *    has to be reachable — a flag that can be set and not unset makes a mis-click
 *    permanent. Section (b) asserts the audit line clears with it, or a cleared
 *    journey would still show who called it spam.
 *
 * 3. *Setting one needs the VIEW capability, not manage.* That looks wrong at a
 *    glance and is deliberate: the split those two capabilities draw is between
 *    reading the reports and changing what the site COLLECTS, and triaging an
 *    enquiry is neither. It is the daily work of the person who was given read
 *    access. Gating it on manage would hand an admissions officer a screen full of
 *    enquiries they can read and cannot triage, which is the feature not existing.
 *
 * **This file WRITES.** It creates one journey, flags it, and deletes it, then
 * asserts the table is byte-identical to how it found it.
 *
 * Usage:
 *   srv-gw wp --project <project> -- eval-file \
 *     wp-content/plugins/bw-lead-ai/tests/lead-outcome.php
 */

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

global $wpdb;

$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 — flagging what a lead turned out to be\n";
echo "==================================================\n";

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

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

$table = BW_Lead_AI_Handoff_Store::table_name();
BW_Lead_AI_Handoff_Store::maybe_create_table();
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
$snapshot = $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A );

// One journey to flag.
$now   = time() * 1000;
$visit = array(
	'ts' => $now - 60000, 'date' => '', 'channel' => 'ZZ Outcome', 'source' => 'zz', 'medium' => 'test',
	'campaign' => '', 'term' => '', 'content' => '', 'page' => 'https://example.test/', 'custom' => array(),
);
$token = BW_Lead_AI_Handoff_Store::mint(
	array(
		'channel' => 'ZZ Outcome',
		'journey' => wp_json_encode(
			array(
				'first' => $visit, 'last' => $visit,
				'counts' => array( 'visits' => 1, 'pages' => 1, 'taggedVisits' => 0 ),
				'visits' => array( $visit ), 'views' => array(), 'events' => array(),
				'eventCounts' => array(), 'submissions' => array(),
			)
		),
	),
	60
);
BW_Lead_AI_Handoff_Store::save( $token, 'Contact', 3650, BW_Lead_AI_Handoff_Store::VIA_SUBMISSION );
$hash = BW_Lead_AI_Handoff_Store::hash_token( $token );

$stored = function () use ( $wpdb, $table, $hash ) {
	// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix.
	return $wpdb->get_row( $wpdb->prepare( "SELECT outcome, outcome_at, outcome_by FROM {$table} WHERE token_hash = %s", $hash ), ARRAY_A );
};

// =========================================================================
$section( '(a) the columns exist on an upgraded table' );
{
	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
	$columns = $wpdb->get_col( "SHOW COLUMNS FROM {$table}" );
	foreach ( array( 'outcome', 'outcome_at', 'outcome_by' ) as $column ) {
		$ok( "{$column} exists", in_array( $column, $columns, true ), implode( ',', $columns ) );
	}
	$ok(
		'the schema option matches the code',
		(int) get_option( BW_Lead_AI_Handoff_Store::SCHEMA_OPTION ) === BW_Lead_AI_Handoff_Store::SCHEMA_VERSION
	);
	$ok( 'a journey starts unjudged', '' === $stored()['outcome'], $stored()['outcome'] );
}

// =========================================================================
$section( '(b) set, change, and — the one that matters — clear' );
{
	$me = get_current_user_id();

	$ok( 'setting an outcome succeeds', true === BW_Lead_AI_Handoff_Store::set_outcome( $hash, 'spam', $me ) );
	$row = $stored();
	$ok( 'the value is stored', 'spam' === $row['outcome'], $row['outcome'] );
	$ok( 'with when', ! empty( $row['outcome_at'] ) );
	$ok( 'and who', (int) $row['outcome_by'] === $me, $row['outcome_by'] );

	$ok( 'changing it succeeds', true === BW_Lead_AI_Handoff_Store::set_outcome( $hash, 'qualified', $me ) );
	$ok( 'the new value replaced the old', 'qualified' === $stored()['outcome'], $stored()['outcome'] );

	// A flag that cannot be undone makes a mis-click permanent.
	$ok( 'clearing succeeds', true === BW_Lead_AI_Handoff_Store::set_outcome( $hash, '', $me ) );
	$row = $stored();
	$ok( 'the value is cleared', '' === $row['outcome'], $row['outcome'] );
	$ok(
		'and the audit line goes with it',
		empty( $row['outcome_at'] ) && empty( $row['outcome_by'] ),
		'a cleared journey still says who judged it'
	);
}

// =========================================================================
$section( '(c) only values this site offers can be stored' );
{
	$ok( 'a value not on the register is refused', false === BW_Lead_AI_Handoff_Store::set_outcome( $hash, 'president', 1 ) );
	$ok( 'and nothing was written', '' === $stored()['outcome'], $stored()['outcome'] );
	$ok( 'a malformed hash is refused', false === BW_Lead_AI_Handoff_Store::set_outcome( 'not-a-hash', 'spam', 1 ) );
	$ok( 'a well-formed hash for no record is refused', false === BW_Lead_AI_Handoff_Store::set_outcome( str_repeat( 'f', 64 ), 'spam', 1 ) );

	// The register is the whitelist, and a site may extend it.
	$extra = function ( $list ) {
		$list['nurture'] = 'Nurture';
		return $list;
	};
	add_filter( 'bw_lead_ai_outcomes', $extra );
	$ok( 'a filtered-in value becomes storable', true === BW_Lead_AI_Handoff_Store::set_outcome( $hash, 'nurture', 1 ) );
	remove_filter( 'bw_lead_ai_outcomes', $extra );
	$ok( 'and stops being storable when the filter goes', false === BW_Lead_AI_Handoff_Store::set_outcome( $hash, 'nurture', 1 ) );
	BW_Lead_AI_Handoff_Store::set_outcome( $hash, '', 1 );
}

// =========================================================================
$section( '(d) the list offers the control, once' );
{
	$_GET     = array( 'page' => BW_Lead_AI_Admin::REPORTS_SLUG, 'view' => 'journeys' );
	$_REQUEST = $_GET;
	ob_start();
	BW_Lead_AI_Handoff_Admin::instance()->render_list();
	$html = ob_get_clean();

	$ok( 'there is an Outcome column', false !== strpos( $html, '>Outcome<' ) );
	$ok( 'and a select for this journey', false !== strpos( $html, 'name="outcome[' . $hash . ']"' ) );
	foreach ( array_keys( BW_Lead_AI_Handoff_Store::outcomes() ) as $key ) {
		$ok( "'{$key}' is offered", false !== strpos( $html, 'value="' . $key . '"' ) );
	}
	$ok( 'so is a way to unset it', false !== strpos( $html, 'Not set' ) );

	// One save for the whole page. A control that reloaded per row would turn a
	// triage pass over 25 journeys into 25 page loads.
	$ok( 'one save button for the whole table', 1 === substr_count( $html, 'name="submit"' ), substr_count( $html, 'name="submit"' ) . ' found' );
	$ok( 'it posts to admin-post', false !== strpos( $html, 'admin-post.php' ) );
	$ok( 'and carries a nonce', false !== strpos( $html, '_wpnonce' ) );

	// A nested form is invalid HTML that browsers resolve by dropping one of
	// them — silently, and the one dropped is not always the one you expect.
	$ok(
		'the outcome form does not nest inside the search form',
		! preg_match( '#<form[^>]*>(?:(?!</form>).)*<form#s', $html ),
		'a form is nested inside another'
	);
}

// =========================================================================
$section( '(e) a read-only user can triage — that is the point of it' );
{
	$probe = get_user_by( 'login', 'bwlai-outcome-probe' );
	if ( ! $probe ) {
		$id    = wp_insert_user(
			array(
				'user_login' => 'bwlai-outcome-probe',
				'user_pass'  => wp_generate_password( 32 ),
				'user_email' => 'bwlai-outcome-probe@example.invalid',
				'role'       => '',
			)
		);
		$probe = is_wp_error( $id ) ? null : get_user_by( 'id', $id );
	}

	if ( ! $probe ) {
		$ok( 'a probe user could be created', false, 'wp_insert_user failed' );
	} else {
		$probe->add_cap( BW_Lead_AI_Caps::VIEW );
		wp_set_current_user( $probe->ID );

		$ok( 'the probe cannot manage', ! BW_Lead_AI_Caps::can_manage() );
		$ok( 'but can view', BW_Lead_AI_Caps::can_view() );

		$_GET     = array( 'page' => BW_Lead_AI_Admin::REPORTS_SLUG, 'view' => 'journeys' );
		$_REQUEST = $_GET;
		ob_start();
		BW_Lead_AI_Handoff_Admin::instance()->render_list();
		$html = ob_get_clean();

		$ok( 'and is offered the control', false !== strpos( $html, 'name="outcome[' . $hash . ']"' ) );
		$ok( 'with a way to save it', false !== strpos( $html, 'admin-post.php' ) );

		wp_set_current_user( (int) $admins[0] );
		require_once ABSPATH . 'wp-admin/includes/user.php';
		wp_delete_user( $probe->ID );
		$ok( 'the probe user is removed', ! get_user_by( 'login', 'bwlai-outcome-probe' ) );
	}
}

// =========================================================================
$section( '(f) cleanup' );
{
	restore_error_handler();
	$ok( 'nothing raised a PHP notice', empty( $notices ), implode( ' | ', array_slice( $notices, 0, 3 ) ) );

	$wpdb->delete( $table, array( 'token_hash' => $hash ), array( '%s' ) );
	// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- table name from $wpdb->prefix.
	$after = $wpdb->get_results( "SELECT * FROM {$table}", ARRAY_A );
	$ok( 'the table is as it was found', md5( wp_json_encode( $after ) ) === md5( wp_json_encode( $snapshot ) ) );
}

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