<?php
/**
 * Catch calls to plugin methods that do not exist.
 *
 * Twice now a screen has passed `test-plugin.sh` and then thrown a fatal the
 * moment it was actually rendered: the shared harness lints syntax and headers,
 * which cannot see that `BW_Lead_AI_Settings::handoff_param()` was never defined.
 * `php -l` is happy with any syntactically valid static call, and the plugin has
 * no PHPUnit bootstrap that would load WordPress and execute a page.
 *
 * This is the cheap 90% of that gap: parse every class in the plugin, collect the
 * methods each one defines, then flag any `BW_Lead_AI_Foo::bar(` or
 * `$this->bar(` call with no matching definition. No WordPress, no database, no
 * fixtures — it runs anywhere PHP does, in well under a second.
 *
 * What it deliberately does NOT do: resolve calls through variables, interfaces,
 * or magic __call. Those would need real static analysis. Every call this plugin
 * actually makes is a literal class name or $this, so the simple form covers it.
 *
 * Usage (no host PHP on this server, so via the same image the harness uses):
 *   docker run --rm -v "$PWD:/p" php:8.1-cli php /p/tests/check-internal-calls.php
 */

$root = dirname( __DIR__ );

/** Every .php file that is ours (vendor is third-party and out of scope). */
$files = array();
$it    = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root ) );
foreach ( $it as $file ) {
	if ( 'php' !== strtolower( $file->getExtension() ) ) {
		continue;
	}
	$path = $file->getPathname();
	if ( false !== strpos( $path, '/vendor/' ) || false !== strpos( $path, '/tests/' ) ) {
		continue;
	}
	$files[] = $path;
}
sort( $files );

/**
 * Walk the token stream once, recording which class each method belongs to and
 * every static/instance call site. Tokenising rather than regexing matters here:
 * the plugin embeds a JavaScript heredoc containing `Object.keys(...)` and
 * `state.data`, which a regex would happily mistake for PHP calls.
 */
$defined = array();   // lower class => [ lower method => true ]
$calls   = array();   // [ class, method, file, line ]
$hierarchy = array(); // lower class => lower parent

foreach ( $files as $path ) {
	$tokens = token_get_all( (string) file_get_contents( $path ) );
	$count  = count( $tokens );
	$class  = '';
	$depth  = 0;
	$class_depth = null;

	for ( $i = 0; $i < $count; $i++ ) {
		$token = $tokens[ $i ];

		if ( '{' === $token ) {
			$depth++;
			continue;
		}
		// String interpolation opens with T_CURLY_OPEN / T_DOLLAR_OPEN_CURLY_BRACES
		// but closes with a PLAIN '}' character token. Without counting the open
		// side, depth drifts negative on any file containing "{$var}" — which
		// silently ends the enclosing class early, so every method defined after it
		// looks undefined. That is exactly the false positive this check exists to
		// avoid producing.
		if ( is_array( $token ) && in_array( $token[0], array( T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES ), true ) ) {
			$depth++;
			continue;
		}
		if ( '}' === $token ) {
			$depth--;
			if ( null !== $class_depth && $depth <= $class_depth ) {
				$class       = '';
				$class_depth = null;
			}
			continue;
		}
		if ( ! is_array( $token ) ) {
			continue;
		}

		// class Foo [extends Bar]
		if ( T_CLASS === $token[0] ) {
			$name = next_meaningful( $tokens, $i );
			if ( null === $name || T_STRING !== $name[0] ) {
				continue; // anonymous class
			}
			$class       = strtolower( $name[1] );
			$class_depth = $depth;
			$after       = next_meaningful( $tokens, $name[2] );
			if ( null !== $after && T_EXTENDS === $after[0] ) {
				$parent = next_meaningful( $tokens, $after[2] );
				if ( null !== $parent && T_STRING === $parent[0] ) {
					$hierarchy[ $class ] = strtolower( $parent[1] );
				}
			}
			continue;
		}

		// function bar(
		if ( T_FUNCTION === $token[0] && '' !== $class ) {
			$name = next_meaningful( $tokens, $i );
			if ( null !== $name && T_STRING === $name[0] ) {
				$defined[ $class ][ strtolower( $name[1] ) ] = true;
			}
			continue;
		}

		// Foo::bar(  /  self::bar(  /  static::bar(
		if ( T_DOUBLE_COLON === $token[0] ) {
			$target = previous_meaningful( $tokens, $i );
			$method = next_meaningful( $tokens, $i );
			if ( null === $target || null === $method || T_STRING !== $method[0] ) {
				continue;
			}
			if ( ! is_call( $tokens, $method[2] ) ) {
				continue; // a constant, not a call
			}
			$owner = strtolower( $target[1] );
			if ( in_array( $owner, array( 'self', 'static' ), true ) ) {
				$owner = $class;
			}
			if ( 'parent' === $owner ) {
				continue;
			}
			if ( 0 === strpos( $owner, 'bw_lead_ai' ) && '' !== $owner ) {
				$calls[] = array( $owner, strtolower( $method[1] ), $path, $token[2] );
			}
			continue;
		}

		// $this->bar(
		if ( T_OBJECT_OPERATOR === $token[0] && '' !== $class ) {
			$target = previous_meaningful( $tokens, $i );
			$method = next_meaningful( $tokens, $i );
			if ( null === $target || null === $method || T_STRING !== $method[0] ) {
				continue;
			}
			if ( T_VARIABLE !== $target[0] || '$this' !== $target[1] ) {
				continue; // any other variable: we cannot know its type
			}
			if ( ! is_call( $tokens, $method[2] ) ) {
				continue; // a property, not a method
			}
			$calls[] = array( $class, strtolower( $method[1] ), $path, $token[2] );
		}
	}
}

/**
 * Does $class, or anything it extends, define $method?
 *
 * Returns true when the chain leaves our code — a class extending Gravity Forms'
 * GF_Field inherits methods we cannot see, and reporting those would bury the
 * real findings in noise the reader has to learn to ignore.
 */
function resolves( $class, $method, array $defined, array $hierarchy ) {
	$seen = array();
	while ( '' !== $class && ! isset( $seen[ $class ] ) ) {
		$seen[ $class ] = true;
		if ( isset( $defined[ $class ][ $method ] ) ) {
			return true;
		}
		if ( ! isset( $hierarchy[ $class ] ) ) {
			return false; // chain ends inside our own code: genuinely undefined
		}
		$class = $hierarchy[ $class ];
		if ( ! isset( $defined[ $class ] ) ) {
			return true; // extends something external — not ours to judge
		}
	}
	return false;
}

$problems = array();
foreach ( $calls as $call ) {
	list( $class, $method, $path, $line ) = $call;
	// A class we never saw is not ours to judge — WP core, Gravity Forms, etc.
	if ( ! isset( $defined[ $class ] ) ) {
		continue;
	}
	if ( ! resolves( $class, $method, $defined, $hierarchy ) ) {
		$problems[] = sprintf( '%s:%d  %s::%s() is not defined', str_replace( dirname( __DIR__ ) . '/', '', $path ), $line, $class, $method );
	}
}

printf( "Checked %d files, %d classes, %d internal calls.\n", count( $files ), count( $defined ), count( $calls ) );

if ( empty( $problems ) ) {
	echo "OK: every internal call resolves.\n";
	exit( 0 );
}

echo "\nUndefined methods:\n";
foreach ( array_unique( $problems ) as $problem ) {
	echo '  ' . $problem . "\n";
}
exit( 1 );

// --- token helpers ------------------------------------------------------

/** The next token that is not whitespace/comment, as [id, text, index]. */
function next_meaningful( array $tokens, $from ) {
	$count = count( $tokens );
	for ( $i = $from + 1; $i < $count; $i++ ) {
		$token = $tokens[ $i ];
		if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
			continue;
		}
		if ( is_array( $token ) ) {
			return array( $token[0], $token[1], $i );
		}
		return array( null, $token, $i );
	}
	return null;
}

function previous_meaningful( array $tokens, $from ) {
	for ( $i = $from - 1; $i >= 0; $i-- ) {
		$token = $tokens[ $i ];
		if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) {
			continue;
		}
		if ( is_array( $token ) ) {
			return array( $token[0], $token[1], $i );
		}
		return array( null, $token, $i );
	}
	return null;
}

/** Is the token at $index followed by an opening parenthesis? */
function is_call( array $tokens, $index ) {
	$next = next_meaningful( $tokens, $index );
	return null !== $next && '(' === $next[1];
}
