<?php

/**
 * @author          Tassos.gr <info@tassos.gr>
 * @link            https://www.tassos.gr
 * @copyright       Copyright © 2026 Tassos All Rights Reserved
 * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/

namespace Tassos\Framework\Security;

defined('_JEXEC') or die('Restricted access');

/**
 * Resolves a client-submitted file reference to a validated absolute path.
 *
 * Supports two formats:
 * 1. FileToken (encrypted) — resolved via decryption, self-validating
 * 2. Legacy path (base64-encoded or plain text) — resolved via SafePath against allowed bases
 *
 * The caller provides an array of allowed base directories for the legacy fallback.
 * Tokens are self-contained (the base is embedded inside the token).
 */
class FileReference
{
	/**
	 * Resolve a client-submitted file reference to a validated absolute path.
	 *
	 * @param   string  $input         The client-submitted value (token, base64 path, or plain path)
	 * @param   array   $allowedBases  Absolute paths of directories allowed for legacy path resolution
	 *
	 * @return  string  The resolved absolute file path
	 *
	 * @throws  \RuntimeException  If the reference cannot be resolved safely
	 */
	public static function resolve($input, array $allowedBases)
	{
		if (empty($input) || !is_string($input))
		{
			throw new \RuntimeException('Empty file reference');
		}

		$input = trim($input);

		// 1. Try to resolve as a FileToken
		try
		{
			$fileToken = new FileToken();
			return $fileToken->read($input);
		}
		catch (\Throwable $e)
		{
			// Not a valid token — fall through to legacy resolution
		}

		// 2. Legacy resolution: try base64 decode, then plain text
		$path = self::decodeLegacyInput($input);

		// 3. Validate the decoded path against each allowed base
		return self::resolveAgainstBases($path, $allowedBases);
	}

	/**
	 * Attempt to decode a legacy file reference.
	 * Tries base64 decoding first, then falls back to using the raw input.
	 *
	 * @param   string  $input
	 *
	 * @return  string  The decoded path (relative or absolute)
	 */
	private static function decodeLegacyInput($input)
	{
		// Try base64 decode — only accept if the result looks different from input
		// and the result re-encodes to the same value (valid base64)
		$decoded = base64_decode($input, true);

		if ($decoded !== false && $decoded !== $input && base64_encode($decoded) === $input)
		{
			return $decoded;
		}

		// Not valid base64 — use as plain text
		return $input;
	}

	/**
	 * Resolve a decoded legacy path against an array of allowed base directories.
	 *
	 * For absolute paths: verify the path is within one of the allowed bases using realpath.
	 * For relative paths: try SafePath::absoluteWithin() against each base.
	 * As a fallback, treat the path as JPATH_ROOT-relative (old extensions strip JPATH_ROOT
	 * from paths, producing values like "/tmp/file.jpg" or "media/dir/file.jpg").
	 *
	 * @param   string  $path          The decoded path (may be absolute or relative)
	 * @param   array   $allowedBases  Allowed base directories (absolute paths)
	 *
	 * @return  string  The validated absolute path
	 *
	 * @throws  \RuntimeException  If the path doesn't resolve within any allowed base
	 */
	private static function resolveAgainstBases($path, array $allowedBases)
	{
		// Reject null bytes and traversal sequences early
		if (strpos($path, "\0") !== false || preg_match('#(?:^|[\\\\/])\.\.(?:[\\\\/]|$)#', $path))
		{
			throw new \RuntimeException('Invalid path');
		}

		// Handle absolute paths (from legacy CF which stores absolute temp paths)
		if (SafePath::isAbsolute($path))
		{
			foreach ($allowedBases as $base)
			{
				if (SafePath::isWithin($path, $base))
				{
					$realPath = realpath($path);

					if ($realPath === false)
					{
						throw new \RuntimeException('Path does not exist');
					}

					return $realPath;
				}
			}

			// Don't throw yet — fall through to JPATH_ROOT-relative fallback
		}
		else
		{
			// Handle relative paths
			foreach ($allowedBases as $base)
			{
				try
				{
					return SafePath::absoluteWithin($path, $base);
				}
				catch (\Throwable $e)
				{
					// Not within this base — try next
					continue;
				}
			}
		}

		// Fallback: treat as JPATH_ROOT-relative path.
		// Old extensions strip JPATH_ROOT, producing paths like "/tmp/file.jpg"
		// or "media/acfupload/tmp/file.jpg". Prepend JPATH_ROOT and recheck.
		return self::resolveAsJrootRelative($path, $allowedBases);
	}

	/**
	 * Try resolving a path as JPATH_ROOT-relative.
	 *
	 * Strips leading separators, prepends JPATH_ROOT, and validates against allowed bases.
	 *
	 * @param   string  $path          The decoded path
	 * @param   array   $allowedBases  Allowed base directories (absolute paths)
	 *
	 * @return  string  The validated absolute path
	 *
	 * @throws  \RuntimeException  If the path doesn't resolve within any allowed base
	 */
	private static function resolveAsJrootRelative($path, array $allowedBases)
	{
		$stripped = ltrim($path, '/\\');

		if (empty($stripped))
		{
			throw new \RuntimeException('Path is outside all allowed directories');
		}

		$jrootPath = \Joomla\Filesystem\Path::clean(JPATH_ROOT . DIRECTORY_SEPARATOR . $stripped);

		foreach ($allowedBases as $base)
		{
			if (SafePath::isWithin($jrootPath, $base))
			{
				$realPath = realpath($jrootPath);

				if ($realPath === false)
				{
					throw new \RuntimeException('Path does not exist');
				}

				return $realPath;
			}
		}

		throw new \RuntimeException('Path is outside all allowed directories');
	}
}
