<?php
/**
 * Public module manifest registry.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Extension;

/**
 * Reads bundled and separately installed module manifests.
 */
final class ModuleRegistry {
	private BundledModuleCatalog $bundled;
	private ExtensionRegistry $extensions;
	private bool $uses_default_bundled;
	private bool $uses_default_extensions;
	/** @var string[] */
	private array $external_errors = array();

	public function __construct( ?BundledModuleCatalog $bundled = null, ?ExtensionRegistry $extensions = null ) {
		$this->uses_default_bundled    = null === $bundled;
		$this->uses_default_extensions = null === $extensions;
		$this->bundled                 = $bundled ?? new BundledModuleCatalog();
		$this->extensions              = $extensions ?? new ExtensionRegistry();
	}

	/**
	 * Returns normalized bundled and external module manifests.
	 *
	 * @return ModuleDefinition[]
	 */
	public function definitions(): array {
		return array_merge( $this->bundled->definitions(), $this->externalDefinitions() );
	}

	/**
	 * Returns normalized manifests registered by separately installed plugins.
	 *
	 * @return ModuleDefinition[]
	 */
	public function externalDefinitions(): array {
		$this->external_errors = array();
		$definitions           = array();

		foreach ( PublicHooks::moduleDefinitions( array() ) as $index => $candidate ) {
			try {
				$definition = $candidate instanceof ModuleDefinition
					? $candidate
					: ( is_array( $candidate ) ? ModuleDefinition::fromArray( $candidate ) : null );
			} catch ( \Throwable $exception ) {
				$this->external_errors[] = sprintf(
					'Module registration failed for %1$s: %2$s',
					$this->candidateLabel( $candidate, (int) $index ),
					$this->diagnosticMessage( $exception )
				);
				continue;
			}

			if ( null === $definition ) {
				$this->external_errors[] = sprintf(
					'Module registration failed for %s: expected ModuleDefinition or array manifest.',
					$this->candidateLabel( $candidate, (int) $index )
				);
				continue;
			}

			$definitions[ $definition->slug() ] = $definition;
		}

		ksort( $definitions );

		return array_values( $definitions );
	}

	/**
	 * Returns bundled and external modules resolved for the active install.
	 */
	public function catalogReport( string $profile = '' ): ModuleCatalogReport {
		$resolver         = new ModuleProfileResolver();
		$profile          = '' === $profile ? $resolver->currentProfile() : $resolver->normalizeProfile( $profile );
		$definitions      = $this->definitionsForProfile( $profile );
		$extensions       = $this->extensionsForProfile( $profile );
		$extension_report = $extensions->catalogReport( $profile );
		$all_extensions   = $extensions->definitions();
		$by_slug          = array();
		$errors           = array_merge( $extension_report->errors(), $this->external_errors );
		$extensions_by_id = array();
		foreach ( $all_extensions as $extension ) {
			$extensions_by_id[ $extension->id() ] = $extension;
		}

		foreach ( $definitions as $definition ) {
			if ( ! $definition instanceof ModuleDefinition ) {
				$errors[] = 'Module catalog contains a non-manifest entry.';
				continue;
			}

			if ( isset( $by_slug[ $definition->slug() ] ) ) {
				$errors[] = 'Duplicate module ID: ' . $definition->slug();
				continue;
			}

			foreach ( $this->definitionValidationErrors( $definition ) as $error ) {
				$errors[] = $error;
			}

			$by_slug[ $definition->slug() ] = $definition;
		}
		$errors = array_merge( $errors, ( new ModuleIdentityGuard() )->errors( array_values( $by_slug ) ) );

		foreach ( $extensions_by_id as $extension ) {
			foreach ( $extension->modules() as $module_id ) {
				if ( ! isset( $by_slug[ $module_id ] ) ) {
					$errors[] = 'Extension ' . $extension->id() . ' declares unknown module ' . $module_id . '.';
				}
			}
		}
		foreach ( $by_slug as $definition ) {
			$owner = $extensions_by_id[ $definition->sourcePackage() ] ?? null;
			if ( ! $owner instanceof ExtensionDefinition ) {
				$errors[] = 'Module ' . $definition->slug() . ' belongs to unknown Extension ' . $definition->sourcePackage() . '.';
			}
		}

		$selected_extension_ids = array_map(
			static fn ( ExtensionDefinition $extension ): string => $extension->id(),
			$extension_report->definitions()
		);
		$declared_by_selected   = array();
		if ( $this->uses_default_extensions && ! defined( 'SCHEDULED_CHANGES_PACKAGED_MODULE_PROFILE' ) ) {
			foreach ( $by_slug as $module_id => $definition ) {
				if ( in_array( $definition->sourcePackage(), $selected_extension_ids, true ) ) {
					$declared_by_selected[ $module_id ] = $definition;
				}
			}
		} else {
			foreach ( $extension_report->definitions() as $extension ) {
				foreach ( $extension->modules() as $module_id ) {
					if ( isset( $by_slug[ $module_id ] ) ) {
						$declared_by_selected[ $module_id ] = $by_slug[ $module_id ];
					}
				}
			}
		}
		$profile_candidates     = array_filter(
			$declared_by_selected,
			static fn ( ModuleDefinition $definition ): bool => $resolver->includes( $definition, $profile )
		);
		$selected_module_ids    = PublicHooks::moduleProfileSelection( array_keys( $profile_candidates ), $profile, array_values( $profile_candidates ) );
		$included               = array();
		foreach ( $selected_module_ids as $module_id ) {
			if ( ! isset( $by_slug[ $module_id ] ) ) {
				$errors[] = 'Selected module ' . $module_id . ' is not registered.';
				continue;
			}

			$definition = $by_slug[ $module_id ];
			if ( ! isset( $declared_by_selected[ $module_id ] ) || ! in_array( $definition->sourcePackage(), $selected_extension_ids, true ) ) {
				$errors[] = 'Module ' . $module_id . ' was selected from an omitted Extension ' . $definition->sourcePackage() . '.';
				continue;
			}

			$included[ $module_id ] = $definition;
		}

		$included = $this->includeDependencies( $included, $declared_by_selected );

		foreach ( $included as $definition ) {
			foreach ( $definition->dependencies() as $dependency ) {
				if ( ! isset( $included[ $dependency ] ) ) {
					$errors[] = sprintf(
						'Module %1$s requires missing dependency %2$s.',
						$definition->slug(),
						$dependency
					);
				}
			}
		}
		foreach ( $this->resourceOwnershipErrors( $included ) as $error ) {
			$errors[] = $error;
		}

		$sorted = $this->sortDefinitions( $included, $errors );

		return new ModuleCatalogReport( $sorted, $errors );
	}

	/**
	 * Returns modules visible to admin diagnostics.
	 *
	 * @return ModuleDefinition[]
	 */
	public function visibleCatalogDefinitions( string $profile = '' ): array {
		$resolver = new ModuleProfileResolver();
		$profile  = '' === $profile ? $resolver->currentProfile() : $resolver->normalizeProfile( $profile );
		$report   = $this->catalogReport( $profile );

		return array_values(
			array_filter(
				$report->definitions(),
				static fn ( ModuleDefinition $definition ): bool => $resolver->visible( $definition, $profile )
			)
		);
	}

	public function forAdapterSlug( string $adapter_slug ): ?ModuleDefinition {
		$matches = array_values(
			array_filter(
				$this->definitions(),
				static fn ( ModuleDefinition $definition ): bool => $definition->adapterSlug() === $adapter_slug
			)
		);

		return $matches[0] ?? null;
	}

	/**
	 * Returns bundled and external module definitions for a distribution profile.
	 *
	 * @return ModuleDefinition[]
	 */
	private function definitionsForProfile( string $profile ): array {
		unset( $profile );
		if ( $this->uses_default_bundled && ! defined( 'SCHEDULED_CHANGES_PACKAGED_MODULE_PROFILE' ) ) {
			$bundled = ( new DevelopmentBundledModuleCatalog() )->definitions();
			$has_runtime_bundled_filter = function_exists( 'has_filter' )
				&& false !== has_filter( 'scheduled_changes_bundled_modules' )
				&& ! is_callable( $GLOBALS['scheduled_changes_test_apply_filters'] ?? null );
			if ( ! $has_runtime_bundled_filter ) {
				$bundled = PublicHooks::bundledModuleDefinitions( $bundled );
			}
		} else {
			$bundled = $this->bundled->definitions();
		}

		return array_merge( $bundled, $this->externalDefinitions() );
	}

	/**
	 * Returns the extension registry for a distribution profile.
	 */
	private function extensionsForProfile( string $profile ): ExtensionRegistry {
		unset( $profile );
		if ( $this->uses_default_extensions && ! defined( 'SCHEDULED_CHANGES_PACKAGED_MODULE_PROFILE' ) ) {
			return new ExtensionRegistry( new DevelopmentBundledExtensionCatalog() );
		}

		return $this->extensions;
	}

	/**
	 * @param array<string,ModuleDefinition> $included Initially selected definitions.
	 * @param array<string,ModuleDefinition> $available Available definitions.
	 * @return array<string,ModuleDefinition>
	 */
	private function includeDependencies( array $included, array $available ): array {
		$changed = true;

		while ( $changed ) {
			$changed = false;
			foreach ( $included as $definition ) {
				foreach ( $definition->dependencies() as $dependency ) {
					if ( isset( $available[ $dependency ] ) && ! isset( $included[ $dependency ] ) ) {
						$included[ $dependency ] = $available[ $dependency ];
						$changed                 = true;
					}
				}
			}
		}

		return $included;
	}

	/**
	 * @param array<string,ModuleDefinition> $definitions Definitions keyed by slug.
	 * @param string[]                       $errors Collected resolution errors.
	 * @return ModuleDefinition[]
	 */
	private function sortDefinitions( array $definitions, array &$errors ): array {
		$sorted    = array();
		$temporary = array();
		$permanent = array();
		$visit     = function ( ModuleDefinition $definition ) use ( &$visit, &$sorted, &$temporary, &$permanent, &$errors, $definitions ): void {
			$slug = $definition->slug();

			if ( isset( $permanent[ $slug ] ) ) {
				return;
			}

			if ( isset( $temporary[ $slug ] ) ) {
				$errors[] = 'Module dependency cycle includes ' . $slug . '.';
				return;
			}

			$temporary[ $slug ] = true;
			foreach ( $definition->dependencies() as $dependency ) {
				if ( isset( $definitions[ $dependency ] ) ) {
					$visit( $definitions[ $dependency ] );
				}
			}
			unset( $temporary[ $slug ] );
			$permanent[ $slug ] = true;
			$sorted[ $slug ]    = $definition;
		};

		ksort( $definitions );
		foreach ( $definitions as $definition ) {
			$visit( $definition );
		}

		return array_values( $sorted );
	}

	/**
	 * @return string[]
	 */
	private function definitionValidationErrors( ModuleDefinition $definition ): array {
		$errors = array();
		$slug   = $definition->slug();

		if ( ! $definition->capabilitiesDeclared() || array() === $definition->capabilities() ) {
			$errors[] = 'Module ' . $slug . ' must declare at least one capability.';
		}

		if ( ! $definition->packageFormDeclared() ) {
			$errors[] = 'Module ' . $slug . ' must declare a package form.';
		}

		if ( ! $definition->visibilityDeclared() ) {
			$errors[] = 'Module ' . $slug . ' must declare Modules page visibility.';
		}

		if ( ! $definition->sourcePackageDeclared() ) {
			$errors[] = 'Module ' . $slug . ' must declare an owning source package.';
		}

		if ( ! $definition->descriptionDeclared() || '' === $definition->description() ) {
			$errors[] = 'Module ' . $slug . ' must declare a Modules page description.';
		}

		if ( ! $definition->resourceOwnershipDeclared() || array() === $definition->resourceOwnership() ) {
			$errors[] = 'Module ' . $slug . ' must declare owned runtime resources.';
		}

		return $errors;
	}

	/**
	 * @param mixed $candidate
	 */
	private function candidateLabel( $candidate, int $index ): string {
		if ( $candidate instanceof ModuleDefinition ) {
			return 'module ' . $candidate->slug();
		}

		if ( is_array( $candidate ) ) {
			foreach ( array( 'slug', 'id', 'name' ) as $key ) {
				if ( is_scalar( $candidate[ $key ] ?? null ) && '' !== trim( (string) $candidate[ $key ] ) ) {
					return $key . ' ' . $this->diagnosticValue( (string) $candidate[ $key ] );
				}
			}
		}

		return 'candidate #' . $index;
	}

	private function diagnosticMessage( \Throwable $throwable ): string {
		return $this->diagnosticValue( $throwable->getMessage() );
	}

	private function diagnosticValue( string $value ): string {
		return trim( (string) preg_replace( '/[\r\n\t]+/', ' ', $value ) );
	}

	/**
	 * @param array<string,ModuleDefinition> $definitions Included definitions keyed by slug.
	 * @return string[]
	 */
	private function resourceOwnershipErrors( array $definitions ): array {
		$owners = array();
		$errors = array();

		foreach ( $definitions as $definition ) {
			foreach ( $definition->resourceOwnership() as $kind => $resources ) {
				foreach ( $resources as $resource ) {
					$key              = $kind . ':' . $resource;
					$owners[ $key ][] = $definition->slug();
				}
			}
		}

		ksort( $owners );
		foreach ( $owners as $key => $slugs ) {
			$slugs = array_values( array_unique( $slugs ) );
			sort( $slugs );

			if ( count( $slugs ) > 1 ) {
				$errors[] = sprintf(
					'Runtime resource %1$s is owned by multiple modules: %2$s.',
					$key,
					implode( ', ', $slugs )
				);
			}
		}

		return $errors;
	}
}
