<?php

/**
 * Validated map of relative file paths to file contents for built artifacts.
 *
 * @package LolaCore\Builder\Artifact
 * @license GPL-2.0-or-later
 */

declare(strict_types=1);

namespace LolaCore\Builder\Artifact;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Diagnostic exception messages; escaped at REST/chat presentation layer.

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

/**
 * Immutable value object describing the on-disk tree of a built artifact.
 *
 * Every path is validated on construction so downstream consumers (installer,
 * blueprint builder, safety gate) can trust the map: relative paths only, no
 * traversal segments, a small allowlist of extensions, and bounded sizes.
 * Empty file contents are allowed — the Plugin Builder has always written
 * empty js/css files when the model produced none.
 */
final class FilesMap {

	/** @var array<int, string> */
	public const ALLOWED_EXTENSIONS = array( 'php', 'js', 'css', 'json' );

	/** Per-file content ceiling. Far above any realistic built artifact. */
	public const MAX_FILE_BYTES = 2097152;

	/** Whole-tree content ceiling. */
	public const MAX_TOTAL_BYTES = 8388608;

	/** @var array<string, string> */
	private array $files;

	/**
	 * @param array<string, string> $files Validated map (relPath => content).
	 */
	private function __construct( array $files ) {
		$this->files = $files;
	}

	/**
	 * Build a validated map from raw input.
	 *
	 * @param array<mixed, mixed> $files Candidate map (relPath => content).
	 *
	 * @throws \InvalidArgumentException When a path or content is invalid.
	 */
	public static function fromArray( array $files ): self {
		$validated = array();
		$total     = 0;

		foreach ($files as $relPath => $content) {
			if (! is_string($relPath) || ! is_string($content)) {
				throw new \InvalidArgumentException(__('File map entries must be string paths mapped to string contents.', 'lolacore'));
			}

			self::assertValidRelativePath($relPath);

			if (strlen($content) > self::MAX_FILE_BYTES) {
				throw new \InvalidArgumentException(
					sprintf(
						/* translators: 1: relative file path, 2: byte limit */
						__('File "%1$s" exceeds the %2$d byte limit.', 'lolacore'),
						$relPath,
						self::MAX_FILE_BYTES
					)
				);
			}

			$total += strlen($content);
			if ($total > self::MAX_TOTAL_BYTES) {
				throw new \InvalidArgumentException(
					sprintf(
						/* translators: %d: total byte limit for the file tree */
						__('The file tree exceeds the %d byte total limit.', 'lolacore'),
						self::MAX_TOTAL_BYTES
					)
				);
			}

			$validated[ $relPath ] = $content;
		}

		return new self($validated);
	}

	/**
	 * @return array<string, string>
	 */
	public function all(): array {
		return $this->files;
	}

	/**
	 * @return array<string, string> Only the .php entries (for safety analysis).
	 */
	public function phpFiles(): array {
		return array_filter(
			$this->files,
			static fn( string $relPath ): bool => strtolower(pathinfo($relPath, PATHINFO_EXTENSION)) === 'php',
			ARRAY_FILTER_USE_KEY
		);
	}

	public function has( string $relPath ): bool {
		return array_key_exists($relPath, $this->files);
	}

	public function get( string $relPath ): string {
		return $this->files[ $relPath ] ?? '';
	}

	public function count(): int {
		return count($this->files);
	}

	public function isEmpty(): bool {
		return $this->files === array();
	}

	/**
	 * Reject anything that could escape the artifact directory or smuggle in
	 * an unexpected file type. Guards, never repairs: hostile input throws.
	 *
	 * @throws \InvalidArgumentException When the path is unsafe.
	 */
	private static function assertValidRelativePath( string $relPath ): void {
		if ($relPath === '' || trim($relPath) === '') {
			throw new \InvalidArgumentException(__('File paths must not be empty.', 'lolacore'));
		}

		if (preg_match('/[\x00-\x1F\x7F]/', $relPath) === 1) {
			throw new \InvalidArgumentException(
				sprintf(
					/* translators: %s: file path with control characters escaped */
					__('File path "%s" contains control characters.', 'lolacore'),
					addcslashes($relPath, "\0..\37\177")
				)
			);
		}

		if (str_contains($relPath, '\\')) {
			throw new \InvalidArgumentException(
				sprintf(
					/* translators: %s: file path */
					__('File path "%s" must use forward slashes only.', 'lolacore'),
					$relPath
				)
			);
		}

		if (str_starts_with($relPath, '/') || preg_match('/^[A-Za-z]:/', $relPath) === 1) {
			throw new \InvalidArgumentException(
				sprintf(
					/* translators: %s: file path */
					__('File path "%s" must be relative.', 'lolacore'),
					$relPath
				)
			);
		}

		foreach (explode('/', $relPath) as $segment) {
			if ($segment === '' || $segment === '.' || $segment === '..') {
				throw new \InvalidArgumentException(
					sprintf(
						/* translators: %s: file path */
						__('File path "%s" contains a traversal or empty segment.', 'lolacore'),
						$relPath
					)
				);
			}
		}

		$filename  = pathinfo($relPath, PATHINFO_FILENAME);
		$extension = strtolower(pathinfo($relPath, PATHINFO_EXTENSION));

		if ($filename === '') {
			throw new \InvalidArgumentException(
				sprintf(
					/* translators: %s: file path */
					__('File path "%s" has no file name.', 'lolacore'),
					$relPath
				)
			);
		}

		if (! in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
			throw new \InvalidArgumentException(
				sprintf(
					/* translators: %s: file path */
					__('File path "%s" has a disallowed extension.', 'lolacore'),
					$relPath
				)
			);
		}
	}
}

