<?php

declare(strict_types=1);

namespace Drupal\flowdrop_orchestration\Service\Orchestrator;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\flowdrop_orchestration\Attribute\Orchestrator;
use Drupal\flowdrop_orchestration\Plugin\Orchestrator\OrchestratorPluginInterface;

/**
 * Plugin manager for Orchestrator plugins using attribute-based discovery.
 *
 * This manager automatically namespaces plugin IDs with their provider module
 * to prevent ID collisions when multiple modules define plugins with the same
 * base ID. For example, a plugin with `id: "synchronous"` from module
 * `flowdrop_runtime` becomes `flowdrop_runtime:synchronous`.
 *
 * The namespacing format is: `{provider}:{original_id}`
 *
 * Supports plugin derivers via DerivativeDiscoveryDecorator, enabling modules
 * to dynamically generate multiple plugin instances from a single base plugin.
 * Derivative plugins are also namespaced with their provider to ensure
 * uniqueness across modules.
 *
 * To register a custom orchestrator, create a class that:
 * 1. Extends OrchestratorPluginBase (or implements OrchestratorPluginInterface)
 * 2. Uses the #[Orchestrator] attribute
 * 3. Is placed in your module's Plugin/Orchestrator directory
 *
 * Example:
 * @code
 * namespace Drupal\my_module\Plugin\Orchestrator;
 *
 * use Drupal\Core\StringTranslation\TranslatableMarkup;
 * use Drupal\flowdrop_orchestration\Attribute\Orchestrator;
 * use Drupal\flowdrop_orchestration\Plugin\Orchestrator\OrchestratorPluginBase;
 *
 * #[Orchestrator(
 *   id: "my_orchestrator",
 *   label: new TranslatableMarkup("My Custom Orchestrator"),
 *   description: "Custom orchestrator implementation",
 * )]
 * class MyOrchestrator extends OrchestratorPluginBase {
 *   public function orchestrate(OrchestrationRequest $request): OrchestrationResponse {
 *     // Implementation...
 *   }
 * }
 * @endcode
 *
 * The plugin ID "my_orchestrator" from module "my_module" would become
 * "my_module:my_orchestrator" after namespacing.
 *
 * @see \Drupal\flowdrop_orchestration\Attribute\Orchestrator
 * @see \Drupal\flowdrop_orchestration\Plugin\Orchestrator\OrchestratorPluginInterface
 * @see \Drupal\flowdrop_orchestration\Plugin\Orchestrator\OrchestratorPluginBase
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class OrchestratorPluginManager extends DefaultPluginManager {

  /**
   * The separator used between provider and plugin ID.
   */
  public const NAMESPACE_SEPARATOR = ":";

  /**
   * Constructs a new OrchestratorPluginManager.
   *
   * @param \Traversable $namespaces
   *   An object that implements \Traversable which contains the root paths
   *   keyed by the corresponding namespace to look for plugin implementations.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
   *   The cache backend.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   */
  public function __construct(
    \Traversable $namespaces,
    CacheBackendInterface $cache_backend,
    ModuleHandlerInterface $module_handler,
  ) {
    parent::__construct(
      "Plugin/Orchestrator",
      $namespaces,
      $module_handler,
      OrchestratorPluginInterface::class,
      Orchestrator::class
    );

    $this->setCacheBackend($cache_backend, "flowdrop_orchestrator_plugins");
    $this->alterInfo("flowdrop_orchestrator_info");
  }

  /**
   * {@inheritdoc}
   *
   * @return \Drupal\flowdrop_orchestration\Plugin\Orchestrator\OrchestratorPluginInterface
   *   The orchestrator plugin instance.
   */
  public function createInstance($plugin_id, array $configuration = []): OrchestratorPluginInterface {
    $instance = $this->getFactory()->createInstance($plugin_id, $configuration);

    if (!$instance instanceof OrchestratorPluginInterface) {
      throw new \InvalidArgumentException(sprintf(
        "Orchestrator plugin '%s' must implement OrchestratorPluginInterface.",
        $plugin_id
      ));
    }

    return $instance;
  }

  /**
   * {@inheritdoc}
   *
   * Processes plugin definitions to add provider-based namespacing.
   *
   * This method prefixes each plugin ID with its provider module name,
   * ensuring unique IDs across all contributing modules. The original
   * (non-prefixed) ID is preserved in the 'original_id' property.
   *
   * For derivative plugins (e.g., `base:derivative`), the full composite
   * ID is namespaced with the provider to ensure unique IDs across modules.
   * The derivative's ":" separator is distinct from the provider namespace.
   *
   * @param array<string, mixed> $definition
   *   The plugin definition to process.
   * @param string $plugin_id
   *   The plugin ID.
   */
  public function processDefinition(&$definition, $plugin_id): void {
    parent::processDefinition($definition, $plugin_id);

    // Store the original ID before prefixing.
    $definition["original_id"] = $plugin_id;

    // Ensure the definition has an ID set (derivatives may not have one).
    if (!isset($definition["id"]) || $definition["id"] !== $plugin_id) {
      $definition["id"] = $plugin_id;
    }

    // Prefix the ID with the provider if available.
    // Note: Derivative plugins contain ":" (e.g., "base:derivative"), but this
    // is different from our provider namespace. We check if the ID already
    // starts with the provider prefix to avoid double-namespacing.
    $provider = $definition["provider"] ?? "";
    if ($provider !== "" && !$this->isProviderNamespaced($plugin_id, $provider)) {
      $definition["id"] = $this->buildNamespacedId($provider, $plugin_id);
    }
  }

  /**
   * {@inheritdoc}
   *
   * Re-keys definitions by their namespaced IDs after processing.
   *
   * @return array<string, array<string, mixed>>
   *   The array of plugin definitions, keyed by namespaced plugin ID.
   */
  protected function findDefinitions(): array {
    $definitions = $this->getDiscovery()->getDefinitions();

    foreach ($definitions as $plugin_id => &$definition) {
      $this->processDefinition($definition, $plugin_id);
    }
    // Unset the reference to avoid PHP foreach reference bug.
    unset($definition);

    // Re-key definitions by the new namespaced ID.
    $reKeyed = [];
    foreach ($definitions as $def) {
      $namespacedId = $def["id"];
      $reKeyed[$namespacedId] = $def;
    }

    $this->alterDefinitions($reKeyed);

    // Remove plugins from non-existent providers.
    foreach ($reKeyed as $plugin_id => $plugin_definition) {
      $provider = $this->extractProviderFromDefinition($plugin_definition);
      if ($provider && !in_array($provider, ["core", "component"]) && !$this->providerExists($provider)) {
        unset($reKeyed[$plugin_id]);
      }
    }

    return $reKeyed;
  }

  /**
   * Builds a namespaced plugin ID from provider and original ID.
   *
   * @param string $provider
   *   The provider module name.
   * @param string $originalId
   *   The original plugin ID.
   *
   * @return string
   *   The namespaced ID in format "provider:original_id".
   */
  public function buildNamespacedId(string $provider, string $originalId): string {
    return $provider . self::NAMESPACE_SEPARATOR . $originalId;
  }

  /**
   * Checks if a plugin ID contains a namespace separator.
   *
   * Note: This only checks for the presence of ":" in the ID. It does NOT
   * distinguish between provider namespacing (e.g., "provider:plugin") and
   * derivative IDs (e.g., "base:derivative"). Use isProviderNamespaced() to
   * check if an ID is already prefixed with a specific provider.
   *
   * @param string $pluginId
   *   The plugin ID to check.
   *
   * @return bool
   *   TRUE if the ID contains the namespace separator.
   */
  public function isNamespaced(string $pluginId): bool {
    return str_contains($pluginId, self::NAMESPACE_SEPARATOR);
  }

  /**
   * Checks if a plugin ID is already namespaced with a specific provider.
   *
   * This method correctly distinguishes between:
   * - Provider-namespaced IDs: "my_module:plugin_id" (already namespaced)
   * - Derivative IDs: "base_plugin:derivative_id" (NOT provider-namespaced)
   *
   * @param string $pluginId
   *   The plugin ID to check.
   * @param string $provider
   *   The provider module name to check for.
   *
   * @return bool
   *   TRUE if the ID starts with the provider prefix.
   */
  public function isProviderNamespaced(string $pluginId, string $provider): bool {
    return str_starts_with($pluginId, $provider . self::NAMESPACE_SEPARATOR);
  }

  /**
   * Extracts the provider from a namespaced plugin ID.
   *
   * @param string $pluginId
   *   The namespaced plugin ID.
   *
   * @return string|null
   *   The provider name, or NULL if the ID is not namespaced.
   */
  public function getProviderFromNamespacedId(string $pluginId): ?string {
    if (!$this->isNamespaced($pluginId)) {
      return NULL;
    }
    $parts = explode(self::NAMESPACE_SEPARATOR, $pluginId, 2);
    return $parts[0];
  }

}
