<?php
/**
 * File scan application service.
 *
 * @package LocalGuardScanner
 */

namespace LocalGuardScanner\Application\Scan;

use LocalGuardScanner\Detection\DetectorEngine;
use LocalGuardScanner\Detection\Filesystem\FileContentReader;
use LocalGuardScanner\Detection\Filesystem\FileEnumerator;
use LocalGuardScanner\Detection\Filesystem\StreamingHasher;
use LocalGuardScanner\Detection\Integrity\FileTrustEvaluator;
use LocalGuardScanner\Domain\Detection\ScannableObject;
use RuntimeException;

/**
 * Converts one filesystem batch into inventory and detector evidence.
 */
final class FileScanService {
	const MAX_HASH_BYTES = 104857600;

	/** @var FileEnumerator */
	private $enumerator;
	/** @var StreamingHasher */
	private $hasher;
	/** @var FileContentReader */
	private $reader;
	/** @var DetectorEngine */
	private $engine;
	/** @var FileTrustEvaluator */
	private $trust_evaluator;

	/**
	 * @param FileEnumerator   $enumerator Enumerator.
	 * @param StreamingHasher  $hasher     Hasher.
	 * @param FileContentReader $reader     Bounded reader.
	 * @param DetectorEngine   $engine          Detector engine.
	 * @param FileTrustEvaluator|null $trust_evaluator Exact core trust evaluator.
	 */
	public function __construct( FileEnumerator $enumerator, StreamingHasher $hasher, FileContentReader $reader, DetectorEngine $engine, ?FileTrustEvaluator $trust_evaluator = null ) {
		$this->enumerator      = $enumerator;
		$this->hasher          = $hasher;
		$this->reader          = $reader;
		$this->engine          = $engine;
		$this->trust_evaluator = null !== $trust_evaluator ? $trust_evaluator : new FileTrustEvaluator();
	}

	/**
	 * @param string              $root       WordPress root.
	 * @param array<string,mixed> $cursor     Cursor.
	 * @param int                 $limit      Entry budget.
	 * @param string[]            $excluded   Excluded directories.
	 * @return FileScanBatchResult
	 */
	public function scan_batch( $root, array $cursor, $limit, array $excluded = array() ) {
		$inventory    = array();
		$evidence     = array();
		$omissions    = array();
		$remaining    = max( 1, (int) $limit );
		$next_cursor  = $cursor;
		$done         = false;
		$memory_limit = $this->memory_limit_bytes();
		$budget       = ResourceBudget::conservative( $memory_limit );

		while ( $remaining > 0 && $budget->can_continue() && ! $done ) {
			$chunk_size  = min( 5, $remaining );
			$batch       = $this->enumerator->next_batch( $root, $next_cursor, $chunk_size, $excluded );
			$next_cursor = $batch->cursor();
			$done        = $batch->done();
			$remaining  -= $chunk_size;
			$omissions   = $this->merge( $omissions, $batch->omissions() );

			foreach ( $batch->items() as $item ) {
				$metadata   = $item->metadata();
				$attributes = $metadata;
				$sha256     = null;
				if ( empty( $metadata['is_link'] ) && $metadata['size'] <= self::MAX_HASH_BYTES ) {
					try {
						$sha256 = $this->hasher->sha256( $item->path(), $metadata );
					} catch ( RuntimeException $error ) {
						$omissions = $this->increment( $omissions, 'unreadable_hash' );
					}
				} else {
					$omissions = $this->increment( $omissions, empty( $metadata['is_link'] ) ? 'hash_size_limit' : 'symlink_not_followed' );
				}
				$attributes['sha256'] = $sha256;
				$attributes['trust']  = $this->trust_evaluator->evaluate( $item->relative_path(), $sha256 );
				if ( $this->is_deep_scan_candidate( $item->relative_path() ) && $metadata['size'] <= FileContentReader::DEFAULT_MAX_BYTES && empty( $metadata['is_link'] ) ) {
					try {
						$attributes['content'] = $this->reader->read( $item->path(), FileContentReader::DEFAULT_MAX_BYTES, $metadata );
					} catch ( RuntimeException $error ) {
						$omissions = $this->increment( $omissions, 'unreadable_content' );
					}
				} elseif ( $this->is_deep_scan_candidate( $item->relative_path() ) && $metadata['size'] > FileContentReader::DEFAULT_MAX_BYTES ) {
					$omissions = $this->increment( $omissions, 'deep_scan_size_limit' );
				}

				$object       = new ScannableObject( 'file', $item->relative_path(), $attributes );
				$observations = $this->engine->inspect( $object );
				if ( ! empty( $observations ) ) {
					$evidence[ $item->relative_path() ] = $observations;
				}
				$inventory[] = array(
					'locator'      => $item->relative_path(),
					'locator_hash' => hash( 'sha256', $item->relative_path() ),
					'size_bytes'   => $metadata['size'],
					'modified_at'  => $metadata['mtime'],
					'created_at'   => isset( $metadata['ctime'] ) ? (int) $metadata['ctime'] : 0,
					'mode'         => $metadata['mode'],
					'is_link'      => $metadata['is_link'],
					'sha256'       => $sha256,
					'trust'        => $attributes['trust'],
				);
			}
		}

		return new FileScanBatchResult( $inventory, $evidence, $next_cursor, $done, $omissions );
	}

	/** @return bool */
	private function is_deep_scan_candidate( string $path ): bool {
		return 1 === preg_match( '/(?:\.php\d*|\.phtml|\.phar|\.inc|\.js|\.html?|\.htaccess)$/i', $path );
	}

	/**
	 * Increment an omission count.
	 *
	 * @param array<string,int> $counts Counts.
	 * @param string            $reason Omission reason.
	 * @return array<string,int>
	 */
	private function increment( array $counts, string $reason ): array {
		$counts[ $reason ] = isset( $counts[ $reason ] ) ? $counts[ $reason ] + 1 : 1;
		return $counts;
	}

	/**
	 * @param array<string,int> $left  Existing counts.
	 * @param array<string,int> $right New counts.
	 * @return array<string,int>
	 */
	private function merge( array $left, array $right ): array {
		foreach ( $right as $reason => $count ) {
			$left[ $reason ] = isset( $left[ $reason ] ) ? $left[ $reason ] + $count : $count;
		}
		return $left;
	}

	/** @return int */
	private function memory_limit_bytes(): int {
		$value = trim( (string) ini_get( 'memory_limit' ) );
		if ( '-1' === $value || '' === $value ) {
			return memory_get_usage( true ) + 128 * 1024 * 1024;
		}
		$unit   = strtolower( substr( $value, -1 ) );
		$number = (float) $value;
		if ( 'g' === $unit ) {
			$number *= 1024;
			$unit    = 'm';
		}
		if ( 'm' === $unit ) {
			$number *= 1024;
			$unit    = 'k';
		}
		if ( 'k' === $unit ) {
			$number *= 1024;
		}
		return max( memory_get_usage( true ) + 1, (int) $number );
	}
}
