<?php

/**
 * Detects file-scope class parents that are not defined in the artifact.
 *
 * @package LolaCore\Builder\Safety\Rules
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Builder\Safety\Rules;

use LolaCore\Builder\Safety\SafetyContext;
use LolaCore\Builder\Safety\SafetyRuleInterface;

if (! defined('ABSPATH')) {
	exit;
}

/**
 * Blocks extends/implements of symbols unavailable when the file is loaded.
 */
class UnknownParentClassAtFileScopeRule implements SafetyRuleInterface {

	/** @var array<int, string> */
	private const GLOBAL_SYMBOL_ALLOWLIST = array(
		'ArrayAccess',
		'Closure',
		'Countable',
		'DateTime',
		'DateTimeImmutable',
		'Error',
		'Exception',
		'Iterator',
		'JsonSerializable',
		'Serializable',
		'stdClass',
		'Throwable',
		'Traversable',
		'WP_Admin_Bar',
		'WP_Block_Type',
		'WP_CLI_Command',
		'WP_Customize_Control',
		'WP_Customize_Panel',
		'WP_Customize_Section',
		'WP_Customize_Setting',
		'WP_Error',
		'WP_List_Table',
		'WP_Post',
		'WP_Query',
		'WP_REST_Controller',
		'WP_User',
		'WP_Widget',
	);

	public function id(): string {
		return 'unknown_parent_class_at_file_scope';
	}

	/**
	 * @inheritDoc
	 */
	public function analyze( string $php, SafetyContext $context ): array {
		unset($context);

		$declaredSymbols       = $this->collectDeclaredSymbols($php);
		$fileScopeParents      = $this->collectFileScopeParents($php);
		$hasWcSettingsRequire  = str_contains($php, 'class-wc-settings-page.php');
		$findings              = array();

		foreach ($fileScopeParents as $declaration) {
			foreach ($declaration['parents'] as $parentInfo) {
				$parent = $parentInfo['name'];
				if ($this->isAllowedParent($parent, $declaredSymbols, $hasWcSettingsRequire)) {
					continue;
				}

				$findings[] = array(
					'severity' => 'fatal',
					'code'     => $this->id(),
					'message'  => sprintf(
						/* translators: 1: class name, 2: relation (extends/implements), 3: parent class or interface name */
						__('Class "%1$s" %2$s "%3$s", which is not available when this file loads. Wrap the declaration in a hook callback, guard it with class_exists(), or require the parent class before use.', 'lolacore'),
						$declaration['class'],
						$parentInfo['relation'],
						$parent
					),
					'line'     => $declaration['line'],
				);
			}
		}

		return $findings;
	}

	/**
	 * @param array<int, string> $declaredSymbols
	 */
	private function isAllowedParent( string $parent, array $declaredSymbols, bool $hasWcSettingsRequire ): bool {
		$normalized = ltrim($parent, '\\');
		$shortName  = $this->shortClassName($normalized);

		if (in_array($shortName, array( 'self', 'parent', 'static' ), true)) {
			return true;
		}

		if ($hasWcSettingsRequire && $shortName === 'WC_Settings_Page') {
			return true;
		}

		if (in_array($shortName, $declaredSymbols, true)) {
			return true;
		}

		if (in_array($shortName, self::GLOBAL_SYMBOL_ALLOWLIST, true)) {
			return true;
		}

		return in_array($normalized, self::GLOBAL_SYMBOL_ALLOWLIST, true);
	}

	private function shortClassName( string $className ): string {
		$pos = strrpos($className, '\\');

		return $pos === false ? $className : substr($className, $pos + 1);
	}

	/**
	 * @return array<int, string>
	 */
	private function collectDeclaredSymbols( string $php ): array {
		$tokens  = token_get_all($php);
		$symbols = array();
		$depth   = 0;
		$count   = count($tokens);

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

			if ($token === '{') {
				++$depth;
				continue;
			}

			if ($token === '}') {
				$depth = max(0, $depth - 1);
				continue;
			}

			if ($depth !== 0 || ! is_array($token)) {
				continue;
			}

			if (! in_array($token[0], array( T_CLASS, T_INTERFACE, T_TRAIT ), true)) {
				continue;
			}

			$name = $this->readSymbolName($tokens, $i + 1);
			if ($name !== null) {
				$symbols[] = $name;
			}
		}

		return array_values(array_unique($symbols));
	}

	/**
	 * @return array<int, array{class: string, parents: array<int, array{relation: string, name: string}>, line: int}>
	 */
	private function collectFileScopeParents( string $php ): array {
		$tokens       = token_get_all($php);
		$declarations = array();
		$depth        = 0;
		$count        = count($tokens);

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

			if ($token === '{') {
				++$depth;
				continue;
			}

			if ($token === '}') {
				$depth = max(0, $depth - 1);
				continue;
			}

			if ($depth !== 0 || ! is_array($token) || $token[0] !== T_CLASS) {
				continue;
			}

			$line = (int) $token[2];
			$next = $this->skipWhitespace($tokens, $i + 1);

			if ($next >= $count || ( is_array($tokens[ $next ]) && $tokens[ $next ][0] === T_NEW )) {
				continue;
			}

			$className = $this->readSymbolName($tokens, $next);
			if ($className === null) {
				continue;
			}

			$parents = array();
			for ($j = $next; $j < $count; $j++) {
				$current = $tokens[ $j ];

				if ($current === '{' || $current === ';') {
					break;
				}

				if (is_array($current) && $current[0] === T_EXTENDS) {
					$parent = $this->readSymbolName($tokens, $this->skipWhitespace($tokens, $j + 1));
					if ($parent !== null) {
						$parents[] = array(
							'relation' => 'extends',
							'name'     => $parent,
						);
					}
				}

				if (is_array($current) && $current[0] === T_IMPLEMENTS) {
					foreach ($this->readSymbolList($tokens, $this->skipWhitespace($tokens, $j + 1)) as $iface) {
						$parents[] = array(
							'relation' => 'implements',
							'name'     => $iface,
						);
					}
				}
			}

			if ($parents !== array()) {
				$declarations[] = array(
					'class'   => $className,
					'parents' => $parents,
					'line'    => $line,
				);
			}
		}

		return $declarations;
	}

	/**
	 * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
	 */
	private function skipWhitespace( array $tokens, int $index ): int {
		$count = count($tokens);

		while ($index < $count && is_array($tokens[ $index ]) && $tokens[ $index ][0] === T_WHITESPACE) {
			++$index;
		}

		return $index;
	}

	/**
	 * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
	 */
	private function readSymbolName( array $tokens, int $index ): ?string {
		$index = $this->skipWhitespace($tokens, $index);
		if ($index >= count($tokens)) {
			return null;
		}

		$parts = array();
		while ($index < count($tokens)) {
			$token = $tokens[ $index ];

			if ($token === '\\') {
				$parts[] = '\\';
				++$index;
				continue;
			}

			if (! is_array($token) || $token[0] !== T_STRING) {
				break;
			}

			$parts[] = $token[1];
			++$index;
		}

		if ($parts === array()) {
			return null;
		}

		return implode('', $parts);
	}

	/**
	 * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
	 * @return array<int, string>
	 */
	private function readSymbolList( array $tokens, int $index ): array {
		$symbols = array();
		$count   = count($tokens);

		while ($index < $count) {
			$index = $this->skipWhitespace($tokens, $index);
			if ($index >= $count) {
				break;
			}

			$token = $tokens[ $index ];
			if ($token === '{' || $token === ';') {
				break;
			}

			if ($token === ',') {
				++$index;
				continue;
			}

			$name = $this->readSymbolName($tokens, $index);
			if ($name === null) {
				break;
			}

			$symbols[] = $name;
			$index     = $this->advancePastSymbol($tokens, $index);
		}

		return $symbols;
	}

	/**
	 * @param array<int, array{0: int, 1: string, 2: int}|string> $tokens
	 */
	private function advancePastSymbol( array $tokens, int $index ): int {
		$count = count($tokens);

		while ($index < $count) {
			$token = $tokens[ $index ];
			if ($token === '\\' || ( is_array($token) && $token[0] === T_STRING ) || ( is_array($token) && $token[0] === T_WHITESPACE )) {
				++$index;
				continue;
			}

			break;
		}

		return $index;
	}
}
