<?php declare(strict_types = 1);

namespace SlevomatCodingStandard\Sniffs\ControlStructures;

use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHP_CodeSniffer\Util\Tokens;
use SlevomatCodingStandard\Helpers\FixerHelper;
use SlevomatCodingStandard\Helpers\IdentificatorHelper;
use SlevomatCodingStandard\Helpers\SniffSettingsHelper;
use SlevomatCodingStandard\Helpers\TernaryOperatorHelper;
use SlevomatCodingStandard\Helpers\TokenHelper;
use function array_key_exists;
use function count;
use function in_array;
use function min;
use function preg_split;
use function sprintf;
use function strtolower;
use function substr_count;
use function trim;
use const PREG_SPLIT_DELIM_CAPTURE;
use const T_BOOLEAN_AND;
use const T_BOOLEAN_OR;
use const T_CLOSE_PARENTHESIS;
use const T_DOUBLE_COLON;
use const T_INLINE_THEN;
use const T_IS_IDENTICAL;
use const T_IS_NOT_IDENTICAL;
use const T_NULL;
use const T_NULLSAFE_OBJECT_OPERATOR;
use const T_OBJECT_OPERATOR;
use const T_OPEN_PARENTHESIS;
use const T_SEMICOLON;
use const T_STRING;

class RequireNullSafeObjectOperatorSniff implements Sniff
{

	public const CODE_REQUIRED_NULL_SAFE_OBJECT_OPERATOR = 'RequiredNullSafeObjectOperator';

	private const OPERATOR_REGEXP = '~(::|->|\?->)~';

	public ?bool $enable = null;

	/**
	 * @return array<int, (int|string)>
	 */
	public function register(): array
	{
		return [
			T_IS_IDENTICAL,
			T_IS_NOT_IDENTICAL,
		];
	}

	public function process(File $phpcsFile, int $identicalPointer): int
	{
		$this->enable = SniffSettingsHelper::isEnabledByPhpVersion($this->enable, 80000);

		if (!$this->enable) {
			return $identicalPointer + 1;
		}

		$tokens = $phpcsFile->getTokens();

		[$pointerBeforeIdentical, $pointerAfterIdentical] = $this->getIdenticalData($phpcsFile, $identicalPointer);

		if ($tokens[$pointerBeforeIdentical]['code'] !== T_NULL && $tokens[$pointerAfterIdentical]['code'] !== T_NULL) {
			return $identicalPointer + 1;
		}

		[$identificatorStartPointer, $identificatorEndPointer, $conditionStartPointer] = $this->getConditionData(
			$phpcsFile,
			$pointerBeforeIdentical,
			$pointerAfterIdentical,
		);

		if ($identificatorStartPointer === null || $identificatorEndPointer === null) {
			return $identicalPointer + 1;
		}

		$isYoda = $tokens[$pointerBeforeIdentical]['code'] === T_NULL;
		$identificator = IdentificatorHelper::getContent($phpcsFile, $identificatorStartPointer, $identificatorEndPointer);

		$pointerAfterCondition = TokenHelper::findNextEffective(
			$phpcsFile,
			($isYoda ? $identificatorEndPointer : $pointerAfterIdentical) + 1,
		);

		$allowedBooleanCondition = $tokens[$identicalPointer]['code'] === T_IS_NOT_IDENTICAL ? T_BOOLEAN_AND : T_BOOLEAN_OR;
		if ($tokens[$pointerAfterCondition]['code'] === $allowedBooleanCondition) {
			return $this->checkNextCondition($phpcsFile, $identicalPointer, $conditionStartPointer, $identificator, $pointerAfterCondition);
		}

		if ($tokens[$pointerAfterCondition]['code'] === T_INLINE_THEN) {
			$this->checkTernaryOperator($phpcsFile, $identicalPointer, $conditionStartPointer, $identificator, $pointerAfterCondition);
			return $pointerAfterCondition + 1;
		}

		return $identicalPointer + 1;
	}

	private function checkTernaryOperator(
		File $phpcsFile,
		int $identicalPointer,
		int $conditionStartPointer,
		string $identificator,
		int $inlineThenPointer
	): void
	{
		$tokens = $phpcsFile->getTokens();

		$ternaryOperatorStartPointer = TernaryOperatorHelper::getStartPointer($phpcsFile, $inlineThenPointer);

		$searchStartPointer = $ternaryOperatorStartPointer;
		do {
			$booleanOperatorPointer = TokenHelper::findNext($phpcsFile, Tokens::BOOLEAN_OPERATORS, $searchStartPointer, $inlineThenPointer);
			if ($booleanOperatorPointer === null) {
				break;
			}

			$identicalPointer = TokenHelper::findNext(
				$phpcsFile,
				[T_IS_IDENTICAL, T_IS_NOT_IDENTICAL],
				$searchStartPointer,
				$booleanOperatorPointer,
			);

			if ($identicalPointer === null) {
				return;
			}

			$pointerAfterIdentical = TokenHelper::findNextEffective($phpcsFile, $identicalPointer + 1);

			if ($tokens[$pointerAfterIdentical]['code'] !== T_NULL) {
				return;
			}

			$searchStartPointer = $booleanOperatorPointer + 1;

		} while (true);

		$pointerBeforeCondition = TokenHelper::findPreviousEffective($phpcsFile, $conditionStartPointer - 1);
		if (in_array($tokens[$pointerBeforeCondition]['code'], [T_BOOLEAN_AND, T_BOOLEAN_OR], true)) {
			$previousIdenticalPointer = TokenHelper::findPreviousLocal(
				$phpcsFile,
				[T_IS_IDENTICAL, T_IS_NOT_IDENTICAL],
				$pointerBeforeCondition,
			);

			if ($previousIdenticalPointer !== null) {
				[$pointerBeforePreviousIdentical, $pointerAfterPreviousIdentical] = $this->getIdenticalData(
					$phpcsFile,
					$previousIdenticalPointer,
				);

				[$previousIdentificatorStartPointer, $previousIdentificatorEndPointer] = $this->getConditionData(
					$phpcsFile,
					$pointerBeforePreviousIdentical,
					$pointerAfterPreviousIdentical,
				);

				if ($previousIdentificatorStartPointer !== null && $previousIdentificatorEndPointer !== null) {
					$previousIdentificator = IdentificatorHelper::getContent(
						$phpcsFile,
						$previousIdentificatorStartPointer,
						$previousIdentificatorEndPointer,
					);

					if (!self::areIdentificatorsCompatible($previousIdentificator, $identificator)) {
						return;
					}
				}
			}
		}

		$defaultInElse = $tokens[$identicalPointer]['code'] === T_IS_NOT_IDENTICAL;
		$inlineElsePointer = TernaryOperatorHelper::getElsePointer($phpcsFile, $inlineThenPointer);
		$inlineElseEndPointer = TernaryOperatorHelper::getEndPointer($phpcsFile, $inlineThenPointer, $inlineElsePointer);

		if ($defaultInElse) {
			$nextIdentificatorPointers = $this->getNextIdentificator($phpcsFile, $inlineThenPointer);

			if ($nextIdentificatorPointers === null) {
				return;
			}

			[$nextIdentificatorStartPointer, $nextIdentificatorEndPointer] = $nextIdentificatorPointers;

			$nextIdentificator = IdentificatorHelper::getContent($phpcsFile, $nextIdentificatorStartPointer, $nextIdentificatorEndPointer);

			if (!$this->areIdentificatorsCompatible($identificator, $nextIdentificator)) {
				return;
			}

			if (TokenHelper::findNextEffective($phpcsFile, $nextIdentificatorEndPointer + 1) !== $inlineElsePointer) {
				return;
			}

			$identificatorDifference = $this->getIdentificatorDifference(
				$phpcsFile,
				$identificator,
				$nextIdentificatorStartPointer,
				$nextIdentificatorEndPointer,
			);

			$firstPointerInElse = TokenHelper::findNextEffective($phpcsFile, $inlineElsePointer + 1);

			$defaultContent = TokenHelper::getContent($phpcsFile, $firstPointerInElse, $inlineElseEndPointer);

			$conditionEndPointer = $inlineElseEndPointer;

		} else {
			$nullPointer = TokenHelper::findNextEffective($phpcsFile, $inlineThenPointer + 1);

			if ($tokens[$nullPointer]['code'] !== T_NULL) {
				return;
			}

			if (TokenHelper::findNextEffective($phpcsFile, $nullPointer + 1) !== $inlineElsePointer) {
				return;
			}

			$nextIdentificatorPointers = $this->getNextIdentificator($phpcsFile, $inlineElsePointer);

			if ($nextIdentificatorPointers === null) {
				return;
			}

			[$nextIdentificatorStartPointer, $nextIdentificatorEndPointer] = $nextIdentificatorPointers;

			if ($nextIdentificatorEndPointer !== $inlineElseEndPointer) {
				return;
			}

			$nextIdentificator = IdentificatorHelper::getContent($phpcsFile, $nextIdentificatorStartPointer, $nextIdentificatorEndPointer);

			if (!$this->areIdentificatorsCompatible($identificator, $nextIdentificator)) {
				return;
			}

			$identificatorDifference = $this->getIdentificatorDifference(
				$phpcsFile,
				$identificator,
				$nextIdentificatorStartPointer,
				$nextIdentificatorEndPointer,
			);

			$defaultContent = trim(TokenHelper::getContent($phpcsFile, $inlineThenPointer + 1, $inlineElsePointer - 1));

			$conditionEndPointer = $nextIdentificatorEndPointer;
		}

		$fix = $phpcsFile->addFixableError('Operator ?-> is required.', $identicalPointer, self::CODE_REQUIRED_NULL_SAFE_OBJECT_OPERATOR);

		if (!$fix) {
			return;
		}

		$conditionContent = sprintf('%s?%s', $identificator, $identificatorDifference);
		if (strtolower($defaultContent) !== 'null') {
			$conditionContent .= sprintf(' ?? %s', $defaultContent);
		}

		$phpcsFile->fixer->beginChangeset();

		FixerHelper::change($phpcsFile, $conditionStartPointer, $conditionEndPointer, $conditionContent);

		$phpcsFile->fixer->endChangeset();
	}

	private function checkNextCondition(
		File $phpcsFile,
		int $identicalPointer,
		int $conditionStartPointer,
		string $identificator,
		int $nextConditionBooleanPointer
	): int
	{
		$nextIdentificatorPointers = $this->getNextIdentificator($phpcsFile, $nextConditionBooleanPointer);

		if ($nextIdentificatorPointers === null) {
			return $nextConditionBooleanPointer;
		}

		[$nextIdentificatorStartPointer, $nextIdentificatorEndPointer] = $nextIdentificatorPointers;

		$nextIdentificator = IdentificatorHelper::getContent($phpcsFile, $nextIdentificatorStartPointer, $nextIdentificatorEndPointer);

		if (!$this->areIdentificatorsCompatible($identificator, $nextIdentificator)) {
			return $nextIdentificatorEndPointer;
		}

		$pointerAfterNexIdentificator = TokenHelper::findNextEffective($phpcsFile, $nextIdentificatorEndPointer + 1);

		$tokens = $phpcsFile->getTokens();

		if (
			$tokens[$pointerAfterNexIdentificator]['code'] !== $tokens[$identicalPointer]['code']
			&& !in_array($tokens[$pointerAfterNexIdentificator]['code'], [T_INLINE_THEN, T_SEMICOLON], true)
		) {
			return $pointerAfterNexIdentificator;
		}

		if (!in_array($tokens[$pointerAfterNexIdentificator]['code'], [T_IS_IDENTICAL, T_IS_NOT_IDENTICAL], true)) {
			return $pointerAfterNexIdentificator;
		}

		$pointerAfterIdentical = TokenHelper::findNextEffective($phpcsFile, $pointerAfterNexIdentificator + 1);
		if ($tokens[$pointerAfterIdentical]['code'] !== T_NULL) {
			return $pointerAfterNexIdentificator;
		}

		$identificatorDifference = $this->getIdentificatorDifference(
			$phpcsFile,
			$identificator,
			$nextIdentificatorStartPointer,
			$nextIdentificatorEndPointer,
		);

		$fix = $phpcsFile->addFixableError('Operator ?-> is required.', $identicalPointer, self::CODE_REQUIRED_NULL_SAFE_OBJECT_OPERATOR);

		if (!$fix) {
			return $pointerAfterNexIdentificator;
		}

		$isConditionOfTernaryOperator = TernaryOperatorHelper::isConditionOfTernaryOperator($phpcsFile, $identicalPointer);

		$phpcsFile->fixer->beginChangeset();

		FixerHelper::change(
			$phpcsFile,
			$conditionStartPointer,
			$nextIdentificatorEndPointer,
			sprintf('%s?%s', $identificator, $identificatorDifference),
		);

		$phpcsFile->fixer->endChangeset();

		if ($isConditionOfTernaryOperator) {
			return TokenHelper::findNext($phpcsFile, T_INLINE_THEN, $identicalPointer + 1);
		}

		return $pointerAfterNexIdentificator;
	}

	/**
	 * @return array<int, int>|null
	 */
	private function getNextIdentificator(File $phpcsFile, int $pointerBefore): ?array
	{
		/** @var int $nextIdentificatorStartPointer */
		$nextIdentificatorStartPointer = TokenHelper::findNextEffective($phpcsFile, $pointerBefore + 1);
		$nextIdentificatorEndPointer = $this->findIdentificatorEnd($phpcsFile, $nextIdentificatorStartPointer);

		if ($nextIdentificatorEndPointer === null) {
			return null;
		}

		return [$nextIdentificatorStartPointer, $nextIdentificatorEndPointer];
	}

	private function findIdentificatorStart(File $phpcsFile, int $identificatorEndPointer): ?int
	{
		$tokens = $phpcsFile->getTokens();

		if ($tokens[$identificatorEndPointer]['code'] === T_CLOSE_PARENTHESIS) {
			$pointerBeforeParenthesisOpener = TokenHelper::findPreviousEffective(
				$phpcsFile,
				$tokens[$identificatorEndPointer]['parenthesis_opener'] - 1,
			);
			$identificatorStartPointer = IdentificatorHelper::findStartPointer($phpcsFile, $pointerBeforeParenthesisOpener);
		} else {
			$identificatorStartPointer = IdentificatorHelper::findStartPointer($phpcsFile, $identificatorEndPointer);
		}

		if ($identificatorStartPointer !== null) {
			$pointerBeforeIdentificatorStart = TokenHelper::findPreviousEffective($phpcsFile, $identificatorStartPointer - 1);

			if (in_array(
				$tokens[$pointerBeforeIdentificatorStart]['code'],
				[T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR],
				true,
			)) {
				$pointerBeforeOperator = TokenHelper::findPreviousEffective($phpcsFile, $pointerBeforeIdentificatorStart - 1);
				return $this->findIdentificatorStart($phpcsFile, $pointerBeforeOperator);
			}
		}

		return $identificatorStartPointer;
	}

	private function findIdentificatorEnd(File $phpcsFile, int $identificatorStartPointer): ?int
	{
		$tokens = $phpcsFile->getTokens();

		$identificatorEndPointer = $tokens[$identificatorStartPointer]['code'] === T_STRING
			? $identificatorStartPointer
			: IdentificatorHelper::findEndPointer($phpcsFile, $identificatorStartPointer);

		if ($identificatorEndPointer !== null) {
			$pointerAfterIdentificatorEnd = TokenHelper::findNextEffective($phpcsFile, $identificatorEndPointer + 1);

			if ($tokens[$pointerAfterIdentificatorEnd]['code'] === T_OPEN_PARENTHESIS) {
				$identificatorEndPointer = $tokens[$pointerAfterIdentificatorEnd]['parenthesis_closer'];
				$pointerAfterIdentificatorEnd = TokenHelper::findNextEffective($phpcsFile, $identificatorEndPointer + 1);
			}

			if (in_array(
				$tokens[$pointerAfterIdentificatorEnd]['code'],
				[T_DOUBLE_COLON, T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR],
				true,
			)) {
				$pointerAfterOperator = TokenHelper::findNextEffective($phpcsFile, $pointerAfterIdentificatorEnd + 1);
				return $this->findIdentificatorEnd($phpcsFile, $pointerAfterOperator);
			}
		}

		return $identificatorEndPointer;
	}

	private function areIdentificatorsCompatible(string $first, string $second): bool
	{
		/** @var list<string> $firstParts */
		$firstParts = preg_split(self::OPERATOR_REGEXP, $first, -1, PREG_SPLIT_DELIM_CAPTURE);
		/** @var list<string> $secondParts */
		$secondParts = preg_split(self::OPERATOR_REGEXP, $second, -1, PREG_SPLIT_DELIM_CAPTURE);

		$minPartsCount = min(count($firstParts), count($secondParts));

		for ($i = 0; $i < $minPartsCount; $i++) {
			if ($firstParts[$i] === '?->' && $secondParts[$i] === '->') {
				continue;
			}

			if ($firstParts[$i] !== $secondParts[$i]) {
				return false;
			}
		}

		return array_key_exists($minPartsCount, $secondParts) && $secondParts[$minPartsCount] === '->';
	}

	private function getIdentificatorDifference(
		File $phpcsFile,
		string $identificator,
		int $nextIdentificatorStartPointer,
		int $nextIdentificatorEndPointer
	): string
	{
		$objectOperatorsCountInIdentificator = substr_count($identificator, '->');

		$tokens = $phpcsFile->getTokens();

		$objectOperatorsCountInNextIdentificator = 0;
		$differencePointer = $nextIdentificatorStartPointer;
		for ($i = $nextIdentificatorStartPointer; $i <= $nextIdentificatorEndPointer; $i++) {
			if (in_array($tokens[$i]['code'], [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR], true)) {
				$objectOperatorsCountInNextIdentificator++;
			}

			if ($objectOperatorsCountInNextIdentificator > $objectOperatorsCountInIdentificator) {
				$differencePointer = $i;
				break;
			}
		}

		return TokenHelper::getContent($phpcsFile, $differencePointer, $nextIdentificatorEndPointer);
	}

	/**
	 * @return array{0: int, 1: int}
	 */
	private function getIdenticalData(File $phpcsFile, int $identicalPointer): array
	{
		/** @var int $pointerBeforeIdentical */
		$pointerBeforeIdentical = TokenHelper::findPreviousEffective($phpcsFile, $identicalPointer - 1);
		/** @var int $pointerAfterIdentical */
		$pointerAfterIdentical = TokenHelper::findNextEffective($phpcsFile, $identicalPointer + 1);

		return [$pointerBeforeIdentical, $pointerAfterIdentical];
	}

	/**
	 * @return array{0: int|null, 1: int|null, 2: int|null}
	 */
	private function getConditionData(File $phpcsFile, int $pointerBeforeIdentical, int $pointerAfterIdentical): array
	{
		$tokens = $phpcsFile->getTokens();

		$isYoda = $tokens[$pointerBeforeIdentical]['code'] === T_NULL;

		if ($isYoda) {
			$identificatorStartPointer = $pointerAfterIdentical;
			$identificatorEndPointer = $this->findIdentificatorEnd($phpcsFile, $identificatorStartPointer);
			$conditionStartPointer = $pointerBeforeIdentical;

		} else {
			$identificatorEndPointer = $pointerBeforeIdentical;
			$identificatorStartPointer = $this->findIdentificatorStart($phpcsFile, $identificatorEndPointer);
			$conditionStartPointer = $identificatorStartPointer;
		}

		return [$identificatorStartPointer, $identificatorEndPointer, $conditionStartPointer];
	}

}
