<?php
/**
 * Module Registry
 *
 * Discovers and caches module metadata from meta.json files.
 * Uses transient caching to avoid filesystem scans on every request.
 * Designed for high performance with 500+ modules.
 *
 * @package WP_PowerSuite\Core
 * @since 1.0.0
 */

namespace WP_PowerSuite\Core;

/**
 * Module Registry Class
 *
 * Handles module discovery, metadata caching, and efficient lookups.
 * Scans filesystem only when cache is expired or invalidated.
 *
 * @since 1.0.0
 */
class ModuleRegistry {

	/**
	 * Module Registry Instance
	 *
	 * Singleton instance of the ModuleRegistry class.
	 *
	 * @var ModuleRegistry|null
	 * @since 1.0.0
	 */
	private static ?ModuleRegistry $instance = null;

	/**
	 * Module Metadata Cache
	 *
	 * Request-level cache of all module metadata.
	 *
	 * @var array<string, array>|null
	 * @since 1.0.0
	 */
	private ?array $metadata_cache = null;

	/**
	 * Cache Transient Key
	 *
	 * WordPress transient key for module metadata cache.
	 *
	 * @var string
	 * @since 1.0.0
	 */
	private string $cache_key;

	/**
	 * Cache TTL
	 *
	 * Time-to-live for the cache in seconds (1 hour).
	 *
	 * @var int
	 * @since 1.0.0
	 */
	private int $cache_ttl = 3600; // 1 hour

	/**
	 * Modules Directory
	 *
	 * Absolute path to the modules directory.
	 *
	 * @var string
	 * @since 1.0.0
	 */
	private string $modules_dir = '';

	/**
	 * Initialization Flag
	 *
	 * Tracks whether the registry has been initialized.
	 *
	 * @var bool
	 * @since 1.0.0
	 */
	private bool $initialized = false;

	/**
	 * Get Module Registry Instance
	 *
	 * Returns the singleton instance of the ModuleRegistry class.
	 *
	 * @since 1.0.0
	 * @return ModuleRegistry ModuleRegistry instance.
	 */
	public static function getInstance(): ModuleRegistry {
		if ( self::$instance === null ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor
	 *
	 * Private constructor to enforce singleton pattern.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {
		$this->cache_key   = DataRegistry::MODULE_MANIFEST_TRANSIENT;
		$this->modules_dir = defined( 'WP_POWERSUITE_MODULES_DIR' )
			? WP_POWERSUITE_MODULES_DIR
			: '';
	}

	/**
	 * Prevent Cloning
	 *
	 * Prevents cloning of the singleton instance.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	private function __clone() {
		// Prevent cloning
	}

	/**
	 * Prevent Unserialization
	 *
	 * Prevents unserialization of the singleton instance.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function __wakeup(): void {
		throw new \Exception( 'Cannot unserialize singleton' );
	}

	/**
	 * Initialize Module Registry
	 *
	 * Initializes the registry and loads module metadata from cache
	 * or filesystem if cache is expired.
	 *
	 * @since 1.0.0
	 * @param bool $force_refresh Force refresh of cache (ignore cache).
	 * @return void
	 */
	public function init( bool $force_refresh = false ): void {
		if ( $this->initialized && ! $force_refresh ) {
			return;
		}

		// Only load metadata if we're in admin or if explicitly requested
		// This prevents filesystem scans on frontend when no modules are enabled
		if ( ! is_admin() && ! $force_refresh ) {
			// Prefer transient on frontend to avoid filesystem scans.
			$cached_metadata = get_transient( $this->cache_key );
			if ( $cached_metadata !== false && is_array( $cached_metadata ) ) {
				$this->metadata_cache = $cached_metadata;
				$this->initialized = true;
				return;
			}
			// Cold cache: must discover modules or enabled features never load on frontend.
			$this->loadMetadata( false );
			$this->initialized = true;
			return;
		}

		$this->loadMetadata( $force_refresh );

		$this->initialized = true;
	}


	/**
	 * Load Module Metadata
	 *
	 * Loads module metadata from cache or scans filesystem if cache
	 * is expired or missing.
	 *
	 * @since 1.0.0
	 * @param bool $force_refresh Force refresh (ignore cache).
	 * @return void
	 */
	private function loadMetadata( bool $force_refresh = false ): void {
		if ( ! $force_refresh && $this->metadata_cache !== null ) {
			return;
		}

		// Drop stale transients when the shipped modules manifest changes
		// (e.g. a new module added via git without a plugin version bump).
		if ( ! $force_refresh && $this->isManifestCacheStale() ) {
			$force_refresh = true;
			DataRegistry::clearCaches();
		}

		// Try to get from transient cache
		if ( ! $force_refresh ) {
			$cached_metadata = get_transient( $this->cache_key );
			if ( $cached_metadata !== false && is_array( $cached_metadata ) ) {
				$this->metadata_cache = $cached_metadata;
				return;
			}
		}

		// Cache expired or missing - prefer shipped build manifest over filesystem scan.
		$manifest_metadata = $this->loadFromManifest();
		if ( $manifest_metadata !== null ) {
			$this->metadata_cache = $manifest_metadata;
		} else {
			$this->metadata_cache = $this->scanModulesDirectory();
		}

		if ( ! empty( $this->metadata_cache ) ) {
			set_transient( $this->cache_key, $this->metadata_cache, $this->cache_ttl );
			$this->storeManifestSignature();
		}
	}

	/**
	 * Current signature of the build-time modules manifest (mtime + size + count).
	 */
	private function getManifestSignature(): string {
		$path = defined( 'WP_POWERSUITE_MODULES_MANIFEST' )
			? WP_POWERSUITE_MODULES_MANIFEST
			: '';
		if ( $path === '' || ! is_readable( $path ) ) {
			return '';
		}
		$mtime = (int) filemtime( $path );
		$size  = (int) filesize( $path );
		return $mtime . ':' . $size;
	}

	/**
	 * Whether the cached module list predates the current modules-manifest.json.
	 */
	private function isManifestCacheStale(): bool {
		$sig = $this->getManifestSignature();
		if ( $sig === '' ) {
			return false;
		}
		$stored = get_option( DataRegistry::MODULE_MANIFEST_SIG_OPTION, '' );
		if ( ! is_string( $stored ) || $stored === '' ) {
			// No signature yet — treat as stale so the first load after upgrade rebuilds.
			return true;
		}
		return $stored !== $sig;
	}

	/**
	 * Persist the current manifest signature after a successful cache rebuild.
	 */
	private function storeManifestSignature(): void {
		$sig = $this->getManifestSignature();
		if ( $sig === '' ) {
			return;
		}
		update_option( DataRegistry::MODULE_MANIFEST_SIG_OPTION, $sig, false );
	}

	/**
	 * Scan Modules Directory
	 *
	 * Scans the modules directory for meta.json files and extracts
	 * module metadata. This is only called when cache is expired.
	 *
	 * @since 1.0.0
	 * @return array<string, array> Array of module metadata, keyed by module ID.
	 */
	private function scanModulesDirectory(): array {
		$metadata = [];

		// Verify modules directory exists
		if ( empty( $this->modules_dir ) || ! is_dir( $this->modules_dir ) ) {
			return $metadata;
		}

		// Scan for module directories
		$module_dirs = $this->getModuleDirectories();

		foreach ( $module_dirs as $module_dir ) {
			$meta_file = $module_dir . DIRECTORY_SEPARATOR . 'meta.json';

			if ( ! file_exists( $meta_file ) || ! is_readable( $meta_file ) ) {
				continue;
			}

			// Read and parse meta.json
			$meta_content = file_get_contents( $meta_file );
			if ( $meta_content === false ) {
				continue;
			}

			$module_meta = json_decode( $meta_content, true );
			if ( ! is_array( $module_meta ) || json_last_error() !== JSON_ERROR_NONE ) {
				continue;
			}

			if ( empty( $module_meta['id'] ) ) {
				continue;
			}

			$module_id = sanitize_key( $module_meta['id'] );

			$module_meta['_directory'] = $module_dir;
			$module_meta['_meta_file'] = $meta_file;

			// Normalize metadata structure
			$metadata[ $module_id ] = $this->normalizeMetadata( $module_meta );
		}

		return $metadata;
	}

	/**
	 * Load module metadata from the build-time manifest when available.
	 *
	 * @return array<string, array>|null Normalized metadata keyed by module ID, or null if unavailable.
	 */
	private function loadFromManifest(): ?array {
		$path = defined( 'WP_POWERSUITE_MODULES_MANIFEST' )
			? WP_POWERSUITE_MODULES_MANIFEST
			: '';

		if ( $path === '' || ! is_readable( $path ) ) {
			return null;
		}

		$raw = file_get_contents( $path );
		if ( ! is_string( $raw ) || $raw === '' ) {
			return null;
		}

		$payload = json_decode( $raw, true );
		if ( ! is_array( $payload ) || empty( $payload['modules'] ) || ! is_array( $payload['modules'] ) ) {
			return null;
		}

		if ( empty( $this->modules_dir ) || ! is_dir( $this->modules_dir ) ) {
			return null;
		}

		$metadata = [];

		foreach ( $payload['modules'] as $module_id => $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$meta = $entry['meta'] ?? $entry;
			if ( ! is_array( $meta ) || empty( $meta['id'] ) ) {
				continue;
			}

			$module_id = sanitize_key( (string) ( $meta['id'] ?? $module_id ) );
			if ( $module_id === '' ) {
				continue;
			}

			$relative_dir = isset( $entry['relative_dir'] ) ? (string) $entry['relative_dir'] : $module_id;
			$relative_dir = trim( str_replace( [ '\\', '..' ], [ '/', '' ], $relative_dir ), '/' );
			if ( $relative_dir === '' ) {
				$relative_dir = $module_id;
			}

			$module_dir = $this->modules_dir . str_replace( '/', DIRECTORY_SEPARATOR, $relative_dir );
			$meta_file  = $module_dir . DIRECTORY_SEPARATOR . 'meta.json';

			if ( ! is_dir( $module_dir ) || ! is_readable( $meta_file ) ) {
				continue;
			}

			$meta['_directory'] = $module_dir;
			$meta['_meta_file'] = $meta_file;

			$metadata[ $module_id ] = $this->normalizeMetadata( $meta );
		}

		return $metadata !== [] ? $metadata : null;
	}

	/**
	 * Get Module Directories
	 *
	 * Recursively scans the modules directory to find all module
	 * directories (directories containing meta.json).
	 *
	 * @since 1.0.0
	 * @return array<string> Array of module directory paths.
	 */
	private function getModuleDirectories(): array {
		$directories = [];

		if ( ! is_dir( $this->modules_dir ) ) {
			return $directories;
		}

		// Use RecursiveDirectoryIterator for efficient scanning
		try {
			$iterator = new \RecursiveIteratorIterator(
				new \RecursiveDirectoryIterator(
					$this->modules_dir,
					\RecursiveDirectoryIterator::SKIP_DOTS
				),
				\RecursiveIteratorIterator::SELF_FIRST
			);

			foreach ( $iterator as $file ) {
				if ( $file->isFile() && $file->getFilename() === 'meta.json' ) {
					$directories[] = $file->getPath();
				}
			}
		} catch ( \Exception $e ) {
			error_log(
				sprintf(
					'WP PowerSuite: Error scanning modules directory: %s',
					$e->getMessage()
				)
			);
		}

		return $directories;
	}

	/**
	 * Normalize Module Metadata
	 *
	 * Normalizes and validates module metadata structure to ensure
	 * consistent format across all modules.
	 *
	 * @since 1.0.0
	 * @param array<string, mixed> $meta Raw metadata from meta.json.
	 * @return array<string, mixed> Normalized metadata.
	 */
	private function normalizeMetadata( array $meta ): array {
		$normalized = [
			'id'          => sanitize_key( $meta['id'] ?? '' ),
			'name'        => sanitize_text_field( $meta['name'] ?? '' ),
			'version'     => sanitize_text_field( $meta['version'] ?? '1.0.0' ),
			'description' => sanitize_textarea_field( $meta['description'] ?? '' ),
			'author'      => sanitize_text_field( $meta['author'] ?? '' ),
			'category'    => sanitize_text_field( $meta['category'] ?? 'uncategorized' ),
			'tags'        => $this->sanitizeArray( $meta['tags'] ?? [], 'sanitize_key' ),
			'context'     => $this->normalizeContext( $meta['context'] ?? 'both' ),
			'dependencies' => $this->sanitizeArray( $meta['dependencies'] ?? [], 'sanitize_key' ),
			'conflicts'   => $this->sanitizeArray( $meta['conflicts'] ?? [], 'sanitize_key' ),
			'class'       => sanitize_text_field( $meta['class'] ?? '' ),
			'namespace'   => sanitize_text_field( $meta['namespace'] ?? '' ),
			'requires'    => [
				'php'      => sanitize_text_field( $meta['requires']['php'] ?? '8.1' ),
				'wordpress' => sanitize_text_field( $meta['requires']['wordpress'] ?? '6.0' ),
			],
			'favorite'    => (bool) ( $meta['favorite'] ?? false ),
			'is_pro'      => (bool) ( $meta['is_pro'] ?? false ),
			'is_freemium' => (bool) ( $meta['is_freemium'] ?? false ),
			'has_advanced_settings' => (bool) ( $meta['has_advanced_settings'] ?? false ),
			'has_custom_page'        => (bool) ( $meta['has_custom_page'] ?? false ),
			'boot'        => $this->normalizeBootTier( $meta['boot'] ?? 'critical' ),
			'_directory'  => $meta['_directory'] ?? '',
			'_meta_file'  => $meta['_meta_file'] ?? '',
		];

		return $normalized;
	}

	/**
	 * Normalize Context
	 *
	 * Normalizes context value to ensure it's a valid context string.
	 *
	 * @since 1.0.0
	 * @param string|array<string> $context Context value(s).
	 * @return string|array<string> Normalized context.
	 */
	private function normalizeContext( string|array $context ): array {
		$valid_contexts = [ 'admin', 'frontend', 'login', 'both', 'ajax', 'rest', 'cron' ];

		if ( ! is_array( $context ) ) {
			$context = [ $context ];
		}

		$normalized = array_values(
			array_filter(
				$context,
				static function ( $ctx ) use ( $valid_contexts ) {
					return is_string( $ctx ) && in_array( $ctx, $valid_contexts, true );
				}
			)
		);

		return $normalized !== [] ? $normalized : [ 'both' ];
	}

	/**
	 * Normalize module boot tier for deferred initialization.
	 *
	 * @param mixed $boot Boot tier from meta.json.
	 */
	private function normalizeBootTier( mixed $boot ): string {
		$tier = is_string( $boot ) ? sanitize_key( $boot ) : 'critical';
		$valid = [ 'critical', 'standard', 'display', 'admin' ];

		return in_array( $tier, $valid, true ) ? $tier : 'critical';
	}

	/**
	 * Sanitize Array
	 *
	 * Sanitizes an array of values using the specified sanitization function.
	 *
	 * @since 1.0.0
	 * @param array<mixed> $array Array to sanitize.
	 * @param string $sanitize_function Sanitization function name.
	 * @return array<string> Sanitized array.
	 */
	private function sanitizeArray( array $array, string $sanitize_function ): array {
		if ( ! function_exists( $sanitize_function ) ) {
			return [];
		}

		return array_filter(
			array_map( $sanitize_function, $array ),
			function( $value ) {
				return ! empty( $value );
			}
		);
	}

	/**
	 * Get All Modules
	 *
	 * Returns metadata for all discovered modules.
	 *
	 * @since 1.0.0
	 * @return array<string, array> Array of module metadata, keyed by module ID.
	 */
	public function getAllModules(): array {
		$this->ensureInitialized();
		return $this->metadata_cache ?? [];
	}

	/**
	 * Get Modules by Category
	 *
	 * Returns modules filtered by category. Efficient lookup using
	 * cached metadata.
	 *
	 * @since 1.0.0
	 * @param string $category Category to filter by.
	 * @return array<string, array> Array of module metadata, keyed by module ID.
	 */
	public function getModulesByCategory( string $category ): array {
		$this->ensureInitialized();

		// Sanitize but preserve spaces in multi-word category names
		$category = sanitize_text_field( $category );
		$modules  = [];

		foreach ( $this->metadata_cache ?? [] as $module_id => $metadata ) {
			if ( isset( $metadata['category'] ) && $metadata['category'] === $category ) {
				$modules[ $module_id ] = $metadata;
			}
		}

		return $modules;
	}

	/**
	 * Get Favorite Modules
	 *
	 * Returns modules marked as favorites. Efficient lookup using
	 * cached metadata.
	 *
	 * @since 1.0.0
	 * @return array<string, array> Array of favorite module metadata, keyed by module ID.
	 */
	public function getFavorites(): array {
		$this->ensureInitialized();

		$favorites = [];

		foreach ( $this->metadata_cache ?? [] as $module_id => $metadata ) {
			if ( ! empty( $metadata['favorite'] ) ) {
				$favorites[ $module_id ] = $metadata;
			}
		}

		return $favorites;
	}

	/**
	 * Get Module Metadata
	 *
	 * Returns metadata for a specific module by ID.
	 *
	 * @since 1.0.0
	 * @param string $module_id Module ID.
	 * @return array<string, mixed>|null Module metadata or null if not found.
	 */
	public function getModuleMetadata( string $module_id ): ?array {
		$this->ensureInitialized();

		$module_id = sanitize_key( $module_id );
		return $this->metadata_cache[ $module_id ] ?? null;
	}

	/**
	 * Get Module Count
	 *
	 * Returns the total number of discovered modules.
	 *
	 * @since 1.0.0
	 * @return int Number of modules.
	 */
	public function getModuleCount(): int {
		$this->ensureInitialized();
		return count( $this->metadata_cache ?? [] );
	}

	/**
	 * Get Categories
	 *
	 * Returns a list of all unique categories from discovered modules.
	 *
	 * @since 1.0.0
	 * @return array<string> Array of category names.
	 */
	public function getCategories(): array {
		$this->ensureInitialized();

		$categories = [];

		foreach ( $this->metadata_cache ?? [] as $metadata ) {
			if ( ! empty( $metadata['category'] ) ) {
				$categories[ $metadata['category'] ] = true;
			}
		}

		return ModuleCategories::sort( array_keys( $categories ) );
	}

	/**
	 * Refresh Cache
	 *
	 * Forces a refresh of the module metadata cache by scanning
	 * the filesystem and updating the transient.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public function refreshCache(): void {
		$this->metadata_cache = null;

		DataRegistry::clearCaches();

		// Reload metadata
		$this->loadMetadata( true );
	}


	/**
	 * Ensure Initialized
	 *
	 * Ensures the registry is initialized before performing operations.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	private function ensureInitialized(): void {
		if ( ! $this->initialized ) {
			$this->init();
		}
	}

	/**
	 * Set Cache TTL
	 *
	 * Sets the cache time-to-live in seconds.
	 *
	 * @since 1.0.0
	 * @param int $ttl Cache TTL in seconds.
	 * @return void
	 */
	public function setCacheTtl( int $ttl ): void {
		$this->cache_ttl = max( 60, $ttl ); // Minimum 1 minute
	}

	/**
	 * Get Cache TTL
	 *
	 * Returns the current cache time-to-live in seconds.
	 *
	 * @since 1.0.0
	 * @return int Cache TTL in seconds.
	 */
	public function getCacheTtl(): int {
		return $this->cache_ttl;
	}

	/**
	 * Check if Registry is Initialized
	 *
	 * Returns whether the registry has been initialized.
	 *
	 * @since 1.0.0
	 * @return bool True if initialized, false otherwise.
	 */
	public function isInitialized(): bool {
		return $this->initialized;
	}
}

