<?php
// Standalone check for lookup_urls_by_column: php -d zend.assertions=1 tests/url-lookup.php
// No WordPress, no framework. A URL this stops resolving is a reference lost, which is a used file
// offered up for deletion -- so the point of this file is that the fast path never answers "no"
// where the old suffix-only path answered "yes".

function __( $s, $d = null ) { return $s; }
function is_wp_error( $t ) { return false; }

// Enough of wpdb to run the two query shapes the function builds, by reading the prepared SQL back.
class FakeWpdb {
	public $last_error = '';
	public $num_queries = 0;
	public $postmeta = 'wp_postmeta';
	public $posts = 'wp_posts';
	public $rows = array();     // candidate => id
	public $queries = array();  // every prepared statement, for asserting what was asked

	public function esc_like( $t ) { return addcslashes( $t, '_%\\' ); }

	public function prepare( $sql, ...$args ) {
		if ( count( $args ) === 1 && is_array( $args[0] ) ) $args = $args[0];
		$out = '';
		$i = 0;
		foreach ( preg_split( '/(%[sd])/', $sql, -1, PREG_SPLIT_DELIM_CAPTURE ) as $part ) {
			if ( $part === '%s' ) { $out .= "'" . str_replace( "'", "''", (string) $args[ $i++ ] ) . "'"; }
			else if ( $part === '%d' ) { $out .= (int) $args[ $i++ ]; }
			else { $out .= $part; }
		}
		return $out;
	}

	public function get_results( $sql ) {
		$this->num_queries++;
		$this->queries[] = $sql;
		$limit = preg_match( '/ LIMIT (\d+)$/', $sql, $m ) ? (int) $m[1] : PHP_INT_MAX;
		$out = array();
		if ( preg_match( '/ IN \( (.*?) \)/', $sql, $m ) ) {
			$wanted = array_map( function ( $v ) { return trim( $v, " '" ); }, explode( ',', $m[1] ) );
			foreach ( $this->rows as $candidate => $id ) {
				if ( in_array( $candidate, $wanted, true ) ) $out[] = (object) array( 'id' => $id, 'candidate' => $candidate );
				if ( count( $out ) >= $limit ) break;
			}
			return $out;
		}
		preg_match_all( "/LIKE '%([^']*)'/", $sql, $m );
		foreach ( $this->rows as $candidate => $id ) {
			foreach ( $m[1] as $suffix ) {
				if ( str_ends_with( $candidate, stripslashes( $suffix ) ) ) {
					$out[] = (object) array( 'id' => $id, 'candidate' => $candidate );
					break;
				}
			}
			if ( count( $out ) >= $limit ) break;
		}
		return $out;
	}
}

$wpdb = new FakeWpdb();
require __DIR__ . '/../classes/core.php';

$core = ( new ReflectionClass( 'Meow_WPMC_Core' ) )->newInstanceWithoutConstructor();
$lookup = new ReflectionMethod( 'Meow_WPMC_Core', 'lookup_urls_by_column' );
$lookup->setAccessible( true );
$FROM = "SELECT post_id AS id, meta_value AS candidate FROM wp_postmeta WHERE meta_key = '_wp_attached_file'";

function ask( $core, $lookup, $wpdb, $rows, $urls ) {
	$wpdb->rows = $rows;
	$wpdb->queries = array();
	global $FROM;
	return $lookup->invoke( $core, $FROM, 'meta_value', $urls );
}

// A library stores _wp_attached_file exactly as the scanner cleans a URL, so the normal case is
// answered by the equality pass alone -- no suffix scan is issued at all.
$library = array( '2020/10/a.jpg' => 11, '2020/10/b.jpg' => 22, '2021/01/c.png' => 33 );
$found = ask( $core, $lookup, $wpdb, $library, array( '2020/10/a.jpg', '2021/01/c.png' ) );
assert( $found === array( '2020/10/a.jpg' => 11, '2021/01/c.png' => 33 ), 'exact paths resolve' );
assert( count( $wpdb->queries ) === 1, 'and cost one query, not a suffix scan: ' . count( $wpdb->queries ) );
assert( strpos( $wpdb->queries[0], 'LIKE' ) === false, 'the fast path issues no LIKE' );
// The parser query guard rejects any SELECT without one, and it is right to: an unbounded read
// issued from inside a parser is exactly what it exists to catch.
assert( preg_match( '/ LIMIT \d+$/', $wpdb->queries[0] ) === 1, 'and is bounded: ' . $wpdb->queries[0] );

// Multisite stores a prefix the scanner does not have. That only the suffix pass can match, and it
// still must: this is the case the leading wildcard was there for.
$multisite = array( 'sites/2/2020/10/a.jpg' => 44 );
$found = ask( $core, $lookup, $wpdb, $multisite, array( '2020/10/a.jpg' ) );
assert( $found === array( '2020/10/a.jpg' => 44 ), 'a prefixed stored path still resolves' );
assert( count( $wpdb->queries ) === 2, 'by falling through to the suffix pass' );

// Mixed batch: the suffix pass runs, but only carries what equality could not answer.
$mixed = array( '2020/10/a.jpg' => 11, 'sites/2/2020/10/b.jpg' => 22 );
$found = ask( $core, $lookup, $wpdb, $mixed, array( '2020/10/a.jpg', '2020/10/b.jpg' ) );
assert( $found === array( '2020/10/a.jpg' => 11, '2020/10/b.jpg' => 22 ), 'mixed batch fully resolves' );
assert( substr_count( $wpdb->queries[1], 'LIKE' ) === 1, 'the suffix pass carries only the leftover' );

// A URL in no library at all is still "not found", not an accidental match.
$found = ask( $core, $lookup, $wpdb, $library, array( '1999/01/nope.jpg' ) );
assert( $found === array(), 'a genuine miss stays a miss' );

// The old behaviour is the contract: anything the suffix pass alone would have found must still be
// found. Check that over every URL against a library that mixes both storage shapes.
$both = array( '2020/10/a.jpg' => 11, 'sites/2/2020/10/b.jpg' => 22, 'nested/deep/2021/01/c.png' => 33 );
foreach ( array( '2020/10/a.jpg', '2020/10/b.jpg', '2021/01/c.png' ) as $url ) {
	$suffix_only = null;
	foreach ( $both as $candidate => $id ) { if ( str_ends_with( $candidate, $url ) ) { $suffix_only = $id; break; } }
	$now = ask( $core, $lookup, $wpdb, $both, array( $url ) );
	assert( ( isset( $now[ $url ] ) ? $now[ $url ] : null ) === $suffix_only, "no regression for $url" );
}

echo "ok\n";
