<?php

namespace Storeplugin\PDFInvoice\Framework\Abstracts;

\defined('ABSPATH') || exit;

use Storeplugin\PDFInvoice\Framework\Containers\Dice;
use Storeplugin\PDFInvoice\Framework\Utils\AdminNotice;
use Storeplugin\PDFInvoice\Framework\Utils\Utils;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Storeplugin\PDFInvoice\Config;
use ReflectionClass;
use RuntimeException;

/**
 * Handles automatic discovery and registration of service classes.
 *
 * @since 1.0.0
 */
abstract class Autowire
{
	/**
	 * The classes from the project files.
	 *
	 * @var array
	 */
	private array $classes = [];

	/**
	 * Cached class metadata (instantiable, interfaces).
	 *
	 * @var array<string, array{instantiable: bool, interfaces: array<string, string>}>|null
	 */
	private ?array $classMetadata = null;

	/**
	 * The dependency injection container instance.
	 *
	 * @var Dice
	 */
	protected Dice $container;

	/**
	 * Constructor for initializing the PSR-4 prefixes and plugin namespace.
	 *
	 * @param array  $psr4Prefixes An associative array of PSR-4 prefixes mapping namespaces to directory paths.
	 * @param string $pluginNamespace The base namespace for the plugin, used to resolve class locations.
	 * @throws RuntimeException If PSR-4 prefixes are invalid or namespace cannot be resolved.
	 */
	public function __construct(array $psr4Prefixes, string $pluginNamespace = '')
	{
		if (empty($psr4Prefixes)) {
			throw new RuntimeException('PSR-4 prefixes array cannot be empty.');
		}

		try {
			$this->classes = $this->getCachedOrFetchClasses(Config::pluginNamespace($pluginNamespace), $psr4Prefixes);
		} catch (\Throwable $e) {
			Utils::logError('Error fetching classes', $e);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new RuntimeException(\sprintf('Failed to discover plugin classes: %s', \esc_html($e->getMessage())), 0, $e);
		}
	}

	/**
	 * Execute registration of services classes.
	 *
	 * @return void
	 */
	abstract public function register(): void;

	/**
	 * Set the dependency injection container instance.
	 *
	 * @param Dice $container The dependency injection container instance.
	 * @return static Returns the current Autowire instance for method chaining.
	 * @throws RuntimeException If the container is already set.
	 */
	public function container(Dice $container): self
	{
		if (isset($this->container)) {
			throw new RuntimeException('Dependency injection container is already set.');
		}

		$this->container = $container;
		return $this;
	}

	/**
	 * Register a service class based on implemented interfaces and execute it with a method.
	 *
	 * @param string $interface The interface to check for.
	 * @param string $method The method to call on registered classes.
	 * @param bool   $shared Whether the class should be shared (singleton).
	 * @return void
	 * @throws RuntimeException If container is not set.
	 * @since 1.0.0
	 */
	protected function initRegisterClass(string $interface, string $method, bool $shared = true): void
	{
		if (!isset($this->container)) {
			throw new RuntimeException('Dependency injection container is not set. Call container() method first.');
		}

		$classes = $this->buildClassMetadata();
		foreach ($classes as $class => $meta) {
			if (!isset($meta['interfaces'][$interface])) {
				continue;
			}

			if (!$meta['instantiable']) {
				continue;
			}

			$this->createClassInstance($class, $method, $shared);
		}
	}

	/**
	 * Execute classes filtered by directory and create instances using the container.
	 *
	 * @param string|array|null $dir A directory or list of directories to match against.
	 * @param bool              $include Whether to include (true) or exclude (false) matching classes.
	 * @param string|null       $method Optional method to call on each instantiated class.
	 * @param bool              $shared Whether the class should be shared (singleton).
	 * @return void
	 * @throws RuntimeException If container is not set.
	 * @since 1.0.0
	 */
	protected function initDirClass(
		$dir = null,
		?string $interface = null,
		?string $method = null,
		bool $include = true,
		bool $shared = true
	): void {
		if (!isset($this->container)) {
			throw new RuntimeException('Dependency injection container is not set. Call container() method first.');
		}

		$filteredClasses = $this->getDirClass($dir, $include);
		$metadata = $this->buildClassMetadata();

		foreach ($filteredClasses as $class) {
			if (!isset($metadata[$class])) {
				continue;
			}

			if ($interface && !isset($metadata[$class]['interfaces'][$interface])) {
				continue;
			}

			if (!$metadata[$class]['instantiable']) {
				continue;
			}

			$this->createClassInstance($class, $method, $shared);
		}
	}

	/**
	 * Create an instance of a class using the container with error handling.
	 *
	 * @param string      $class The fully-qualified class name to instantiate.
	 * @param string|null $method Optional method to call on the instantiated class.
	 * @param bool        $shared Whether the class should be shared (singleton).
	 * @return void
	 * @since 1.0.0
	 */
	private function createClassInstance(string $class, ?string $method = null, bool $shared = true): void
	{
		try {
			$rules = [
				$class => [
					'shared' => $shared,
				],
			];

			if ($method !== null) {
				$rules[$class]['call'] = [[$method, []]];
			}

			$this->container
				->addRules($rules)
				->create($class);
		} catch (\ReflectionException $e) {
			Utils::logError("Reflection error for service {$class}", $e);
			$this->addServiceErrorNotice($class, $e, 'could not be instantiated (Reflection Error)');
		} catch (\InvalidArgumentException $e) {
			Utils::logError("Invalid argument for service {$class}", $e);
			$this->addServiceErrorNotice($class, $e, 'has invalid configuration');
		} catch (\Throwable $e) {
			Utils::logError("Failed to register service {$class}", $e);
			$this->addServiceErrorNotice($class, $e, 'failed to register');
		}
	}

	/**
	 * Add an admin notice for service errors.
	 *
	 * @param string     $class The class name that failed.
	 * @param \Throwable $exception The exception that was thrown.
	 * @param string     $context Context message (e.g., "could not be instantiated").
	 * @return void
	 * @since 1.0.0
	 */
	private function addServiceErrorNotice(string $class, \Throwable $exception, string $context): void
	{
		$message = \sprintf(
			'Service <code>%s</code> %s.<br><small>%s</small>',
			\esc_html($class),
			\esc_html($context),
			\esc_html($exception->getMessage())
		);

		AdminNotice::warning($message, true);
	}

	/**
	 * Get classes that implement a specific interface.
	 *
	 * @param string $interface The interface to check for.
	 * @return array Array of class names implementing the interface.
	 * @since 1.0.0
	 */
	protected function getRegisterClass(string $interface): array
	{
		$classes = [];
		$classMetadatas = $this->buildClassMetadata();

		foreach ($classMetadatas as $class => $meta) {
			if (isset($meta['interfaces'][$interface])) {
				$classes[] = $class;
			}
		}

		return $classes;
	}

	/**
	 * Filters stored class names by directory.
	 *
	 * @param string|array|null $dir A directory or list of directories to match against.
	 * @param bool              $include Whether to include (true) or exclude (false) matching classes.
	 * @return array Filtered list of class names.
	 * @since 1.0.0
	 */
	protected function getDirClass($dir = null, bool $include = true): array
	{
		if ($dir === null) {
			return $this->classes;
		}

		$directories = \is_array($dir) ? $dir : (\is_string($dir) ? [$dir] : []);
		if (empty($directories)) {
			return [];
		}

		return \array_values(\array_filter($this->classes, function ($class) use ($directories, $include) {
			foreach ($directories as $directory) {
				if (\strpos($class, $directory) !== false) {
					return $include;
				}
			}
			return !$include;
		}));
	}

	/**
	 * Build class metadata with memoization - runs reflection only once per request.
	 *
	 * @return array<string, array{instantiable: bool, interfaces: array<string, string>}>
	 */
	protected function buildClassMetadata(): array
	{
		if ($this->classMetadata !== null) {
			return $this->classMetadata;
		}

		$this->classMetadata = [];

		foreach ($this->classes as $class) {
			try {
				if (!\class_exists($class)) {
					continue;
				}
			} catch (\Error) {
				// Parent class unavailable (e.g. WP_List_Table on frontend) — skip.
				continue;
			}

			$reflection = new ReflectionClass($class);
			$this->classMetadata[$class] = [
				'instantiable' => $reflection->isInstantiable(),
				'interfaces' => \array_flip(\class_implements($class) ?: []),
			];
		}

		return $this->classMetadata;
	}

	/**
	 * Get cached class list or fetch from filesystem if cache is invalid.
	 *
	 * @param string $namespaceName The namespace to search for classes.
	 * @param array  $psr4Prefixes The array of PSR-4 prefixes mapping namespaces to directories.
	 * @return array Array of fully-qualified class names.
	 * @throws RuntimeException If the directory cannot be read or iterated.
	 */
	private function getCachedOrFetchClasses(string $namespaceName, array $psr4Prefixes): array
	{
		if (Utils::isDebugMode()) {
			return $this->fetchClassesInNamespace($namespaceName, $psr4Prefixes);
		}

		$namespaceWithSlash = $this->getNamespaceWithSlash($namespaceName);
		$pathToNamespace = $psr4Prefixes[$namespaceWithSlash][0] ?? '';

		if (empty($pathToNamespace) || !\is_dir($pathToNamespace)) {
			return $this->fetchClassesInNamespace($namespaceName, $psr4Prefixes);
		}

		// Generate cache key based on namespace, path, and version
		$cacheKey = self::buildCacheKey($namespaceName, $pathToNamespace);

		// Get cached class list.
		$cached = \get_transient($cacheKey);

		if (\is_array($cached) && isset($cached['classes']) && \is_array($cached['classes'])) {
			return $cached['classes'];
		}

		// Cache miss or invalidated - scan filesystem
		$classes = $this->fetchClassesInNamespace($namespaceName, $psr4Prefixes);

		// Store class list in cache (cache key already includes plugin version).
		\set_transient($cacheKey, [
			'classes' => $classes,
		], Config::cacheExpiration());

		return $classes;
	}

	/**
	 * Get namespace with trailing backslash.
	 *
	 * @param string $namespace The namespace.
	 * @return string Namespace with trailing backslash.
	 * @since 1.0.0
	 */
	private function getNamespaceWithSlash(string $namespace): string
	{
		return \rtrim($namespace, '\\') . '\\';
	}

	/**
	 * Clear the autowire class cache.
	 *
	 * @param string $namespaceName The namespace to clear cache for.
	 * @param array  $psr4Prefixes The array of PSR-4 prefixes.
	 * @return bool True if cache was cleared, false otherwise.
	 * @since 1.0.0
	 */
	public static function clearCache(string $namespaceName, array $psr4Prefixes): bool
	{
		$namespaceWithSlash = \rtrim($namespaceName, '\\') . '\\';
		$pathToNamespace = $psr4Prefixes[$namespaceWithSlash][0] ?? '';

		if (empty($pathToNamespace)) {
			return false;
		}

		$cacheKey = self::buildCacheKey($namespaceName, $pathToNamespace);

		return \delete_transient($cacheKey);
	}

	/**
	 * Build the autowire cache key.
	 *
	 * @param string $namespaceName The namespace to include in the key.
	 * @param string $pathToNamespace The namespace filesystem path.
	 * @return string
	 */
	private static function buildCacheKey(string $namespaceName, string $pathToNamespace): string
	{
		return Config::prefix() . '_autowire_classes_' . Config::version() . '_' . \md5($namespaceName . $pathToNamespace);
	}

	/**
	 * Fetch all class names in a given namespace based on PSR-4 prefixes.
	 *
	 * @param string $namespaceName The namespace to search for classes.
	 * @param array  $psr4Prefixes The array of PSR-4 prefixes mapping namespaces to directories.
	 * @return string[] Array of fully-qualified class names.
	 * @throws RuntimeException If the directory cannot be read or iterated.
	 */
	private function fetchClassesInNamespace(string $namespaceName, array $psr4Prefixes): array
	{
		$classes = [];
		$namespaceWithSlash = $this->getNamespaceWithSlash($namespaceName);
		$pathToNamespace = $psr4Prefixes[$namespaceWithSlash][0] ?? '';

		if (empty($pathToNamespace)) {
			Utils::debugLog("Namespace '{$namespaceName}' not found in PSR-4 prefixes.");
			return [];
		}

		if (!\is_dir($pathToNamespace)) {
			Utils::debugLog("Directory '{$pathToNamespace}' for namespace '{$namespaceName}' does not exist.");
			return [];
		}

		if (!\is_readable($pathToNamespace)) {
			throw new RuntimeException(\sprintf("Directory '%s' is not readable. Check file permissions.", \esc_html($pathToNamespace)));
		}

		try {
			$dirIterate = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator($pathToNamespace, RecursiveDirectoryIterator::SKIP_DOTS)
			);
		} catch (\UnexpectedValueException $e) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			throw new RuntimeException(\sprintf("Failed to iterate directory '%s': %s", \esc_html($pathToNamespace), \esc_html($e->getMessage())), 0, $e);
		}

		foreach ($dirIterate as $file) {
			try {
				if ($file->isDir() || !\preg_match('/^[A-Z][A-Za-z0-9]+\.php$/', $file->getFilename())) {
					continue;
				}

				$classes[] = $this->parseNamespaceFromFilepath(
					$file->getPathname(),
					$namespaceName,
					$pathToNamespace
				);
			} catch (\Throwable $e) {
				Utils::debugLog(\sprintf(
					'Error processing file %s: %s',
					$file->getPathname(),
					$e->getMessage()
				));
				continue;
			}
		}

		return $classes;
	}

	/**
	 * Parse the namespace from the file path based on the root namespace and its path.
	 *
	 * @param string $filepath The full path to the PHP file.
	 * @param string $rootNamespace The root namespace to prepend to the parsed namespace.
	 * @param string $rootNamespacePath The path corresponding to the root namespace in the filesystem.
	 * @return string The fully-qualified class namespace.
	 */
	private function parseNamespaceFromFilepath(string $filepath, string $rootNamespace, string $rootNamespacePath): string
	{
		$relative = \str_replace([$rootNamespacePath, DIRECTORY_SEPARATOR, '.php'], ['', '\\', ''], $filepath);
		return \rtrim($rootNamespace, '\\') . $relative;
	}
}
