<?php
/**
 * Conservative verification of previously reported file locators.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Findings;

use InvalidArgumentException;
use LocalGuardScanner\Detection\Filesystem\PathNormalizer;
use LocalGuardScanner\Domain\Scan\InspectionScope;
use Throwable;

/**
 * Confirms that a relative locator is absent without following symbolic links.
 */
final class MissingFileVerifier {
	const EXISTS     = 'exists';
	const MISSING    = 'missing';
	const UNVERIFIED = 'unverified';

	/** @var PathNormalizer */
	private $normalizer;

	/** @param PathNormalizer|null $normalizer Path normalizer. */
	public function __construct( ?PathNormalizer $normalizer = null ) {
		$this->normalizer = null === $normalizer ? new PathNormalizer() : $normalizer;
	}

	/**
	 * @param string          $root    WordPress root.
	 * @param mixed           $locator Stored relative locator.
	 * @param InspectionScope $scope   Scan exclusions.
	 * @return string One of the class result constants.
	 */
	public function verify( $root, $locator, InspectionScope $scope ) {
		$locator = $this->safe_locator( $locator );
		if ( null === $locator || $scope->excludes( $locator ) ) {
			return self::UNVERIFIED;
		}

		try {
			$canonical_root = realpath( (string) $root );
		} catch ( Throwable $error ) {
			return self::UNVERIFIED;
		}
		if ( ! is_string( $canonical_root ) || ! is_dir( $canonical_root ) || ! is_readable( $canonical_root ) || ! is_executable( $canonical_root ) ) {
			return self::UNVERIFIED;
		}

		$current  = rtrim( $canonical_root, DIRECTORY_SEPARATOR );
		$segments = explode( '/', $locator );
		foreach ( $segments as $index => $segment ) {
			$parent  = $current;
			$current = $current . DIRECTORY_SEPARATOR . $segment;
			try {
				$metadata = @lstat( $current ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A missing component is the expected signal.
			} catch ( Throwable $error ) {
				return self::UNVERIFIED;
			}
			if ( false === $metadata ) {
				return $this->is_safe_ancestor( $parent, $canonical_root ) ? self::MISSING : self::UNVERIFIED;
			}
			if ( $this->is_symbolic_link( $metadata ) ) {
				return self::EXISTS;
			}
			if ( $index < count( $segments ) - 1 && ( ! is_dir( $current ) || ! is_readable( $current ) || ! is_executable( $current ) ) ) {
				return self::UNVERIFIED;
			}
		}
		return self::EXISTS;
	}

	/**
	 * @param mixed $locator Candidate locator.
	 * @return string|null
	 */
	private function safe_locator( $locator ) {
		if ( ! is_string( $locator ) || '' === $locator || strlen( $locator ) > 4096 || preg_match( '/[\x00-\x1F\x7F]/', $locator ) ) {
			return null;
		}
		if ( '/' === $locator[0] || '\\' === $locator[0] || false !== strpos( $locator, '\\' ) || preg_match( '/^[A-Za-z]:/', $locator ) || preg_match( '/^[A-Za-z][A-Za-z0-9+.-]*:/', $locator ) ) {
			return null;
		}
		$segments = explode( '/', $locator );
		if ( in_array( '', $segments, true ) || in_array( '.', $segments, true ) || in_array( '..', $segments, true ) ) {
			return null;
		}
		try {
			$normalized = $this->normalizer->normalize( $locator );
		} catch ( InvalidArgumentException $error ) {
			return null;
		}
		return $normalized === $locator ? $locator : null;
	}

	/**
	 * @param string $ancestor Existing parent directory.
	 * @param string $root     Canonical root.
	 * @return bool
	 */
	private function is_safe_ancestor( $ancestor, $root ) {
		if ( is_link( $ancestor ) || ! is_dir( $ancestor ) || ! is_readable( $ancestor ) || ! is_executable( $ancestor ) ) {
			return false;
		}
		$canonical = realpath( $ancestor );
		if ( ! is_string( $canonical ) ) {
			return false;
		}
		$root      = rtrim( $root, DIRECTORY_SEPARATOR );
		$canonical = rtrim( $canonical, DIRECTORY_SEPARATOR );
		return $canonical === $root || 0 === strpos( $canonical . DIRECTORY_SEPARATOR, $root . DIRECTORY_SEPARATOR );
	}

	/** @param array<int|string,int> $metadata lstat result. @return bool */
	private function is_symbolic_link( array $metadata ): bool {
		$mode = isset( $metadata['mode'] ) ? (int) $metadata['mode'] : ( isset( $metadata[2] ) ? (int) $metadata[2] : 0 );
		return 0120000 === ( $mode & 0170000 );
	}
}
