<?php
/**
 * Resumable filesystem enumeration.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Detection\Filesystem;

use LocalGuardScanner\Domain\Scan\InspectionScope;
use RuntimeException;

/**
 * Walks a root breadth-first with a compact persistent cursor.
 */
final class FileEnumerator {
	/** @var PathNormalizer */
	private $normalizer;

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

	/**
	 * @param string              $root       Allowed root.
	 * @param array<string,mixed> $cursor     Previous cursor or empty array.
	 * @param int                 $max_entries Maximum directory entries to visit.
	 * @param string[]            $excluded   Relative directory prefixes.
	 * @return FileBatch
	 */
	public function next_batch( $root, array $cursor, $max_entries, array $excluded = array() ) {
		$root = rtrim( $this->normalizer->normalize( $root ), '/' );
		if ( ! is_dir( $root ) ) {
			throw new RuntimeException( 'Scan root is not a readable directory.' );
		}
		$queue     = isset( $cursor['queue'] ) && is_array( $cursor['queue'] ) ? $this->validate_queue( $cursor['queue'] ) : array( '' );
		$current   = isset( $cursor['current'] ) && is_string( $cursor['current'] ) ? $this->validate_relative_cursor( $cursor['current'] ) : null;
		$after     = isset( $cursor['after'] ) && is_string( $cursor['after'] ) ? $this->validate_entry_cursor( $cursor['after'] ) : '';
		$visited   = 0;
		$items     = array();
		$omissions = array();
		$limit     = max( 1, (int) $max_entries );
		$scope     = new InspectionScope( $excluded );

		while ( $visited < $limit && ( null !== $current || ! empty( $queue ) ) ) {
			if ( null === $current ) {
				$current = (string) array_shift( $queue );
				$after   = '';
			}
			$directory = $root . ( '' === $current ? '' : '/' . $current );
			$entries   = @scandir( $directory ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			if ( false === $entries ) {
				$omissions['unreadable_directory'] = isset( $omissions['unreadable_directory'] ) ? $omissions['unreadable_directory'] + 1 : 1;
				$current                           = null;
				continue;
			}
			$advanced = false;
			foreach ( $entries as $name ) {
				if ( '.' === $name || '..' === $name || ( '' !== $after && strcmp( $name, $after ) <= 0 ) ) {
					continue;
				}
				$after    = $name;
				$advanced = true;
				++$visited;
				$relative = ltrim( $current . '/' . $name, '/' );
				$path     = $root . '/' . $relative;
				$stat     = @lstat( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( false === $stat ) {
					$omissions['unreadable_metadata'] = isset( $omissions['unreadable_metadata'] ) ? $omissions['unreadable_metadata'] + 1 : 1;
					if ( $visited >= $limit ) {
						break;
					}
					continue;
				}
				$is_link = is_link( $path );
				if ( false !== strpos( $relative, '\\' ) ) {
					$omissions['unsafe_path_name'] = isset( $omissions['unsafe_path_name'] ) ? $omissions['unsafe_path_name'] + 1 : 1;
					if ( $visited >= $limit ) {
						break;
					}
					continue;
				}
				if ( is_dir( $path ) && ! $is_link ) {
					if ( ! $scope->excludes( $relative ) ) {
						$queue[] = $relative;
					} else {
						$omissions['excluded_directory'] = isset( $omissions['excluded_directory'] ) ? $omissions['excluded_directory'] + 1 : 1;
					}
				} else {
					$items[] = new FileInventoryItem(
						$path,
						$relative,
						array(
							'size'    => (int) $stat['size'],
							'mtime'   => (int) $stat['mtime'],
							'ctime'   => (int) $stat['ctime'],
							'mode'    => (int) $stat['mode'],
							'is_link' => $is_link,
							'dev'     => (int) $stat['dev'],
							'ino'     => (int) $stat['ino'],
						)
					);
				}
				if ( $visited >= $limit ) {
					break;
				}
			}
			if ( ! $advanced || $visited < $limit ) {
				$current = null;
				$after   = '';
			}
		}

		$done = null === $current && empty( $queue );
		return new FileBatch(
			$items,
			array(
				'queue'   => $queue,
				'current' => $current,
				'after'   => $after,
			),
			$done,
			$omissions
		);
	}

	/**
	 * @param mixed[] $queue Stored queue.
	 * @return string[]
	 */
	private function validate_queue( array $queue ) {
		if ( count( $queue ) > 100000 ) {
			throw new RuntimeException( 'Filesystem cursor queue is too large.' );
		}
		$validated = array();
		foreach ( $queue as $path ) {
			if ( ! is_string( $path ) ) {
				throw new RuntimeException( 'Filesystem cursor contains an invalid path.' );
			}
			$validated[] = $this->validate_relative_cursor( $path );
		}
		return $validated;
	}

	/** @return string */
	private function validate_relative_cursor( string $path ): string {
		if ( strlen( $path ) > 4096 || 0 === strpos( $path, '/' ) || false !== strpos( $path, '\\' ) ) {
			throw new RuntimeException( 'Filesystem cursor path is unsafe.' );
		}
		try {
			$normalized = $this->normalizer->normalize( $path );
		} catch ( \InvalidArgumentException $ignored_exception ) {
			throw new RuntimeException( 'Filesystem cursor path is unsafe.' );
		}
		if ( $normalized !== $path ) {
			throw new RuntimeException( 'Filesystem cursor path is not canonical.' );
		}
		return $path;
	}

	/** @return string */
	private function validate_entry_cursor( string $entry ): string {
		if ( '' === $entry ) {
			return '';
		}
		if ( strlen( $entry ) > 1024 || basename( $entry ) !== $entry || '.' === $entry || '..' === $entry ) {
			throw new RuntimeException( 'Filesystem entry cursor is unsafe.' );
		}
		return $entry;
	}
}
