<?php

declare(strict_types=1);

namespace Drupal\flowdrop_trigger\Hook;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_trigger\Entity\FlowDropTriggerConfig;
use Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface;
use Drupal\flowdrop_trigger\Service\TriggerConfigSchemaBuilder;
use Drupal\flowdrop_trigger\Service\TriggerManagerInterface;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;

/**
 * Keeps trigger configs in sync with their workflow's lifecycle.
 *
 * A trigger node and its FlowDropTriggerConfig entity are linked only by a
 * derived ID ({workflow_id}__{node_id}); the config is a separate config
 * entity that would otherwise be orphaned when its node or workflow goes away.
 * These hooks close that gap on the runtime (programmatic) delete/save paths.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class WorkflowHooks {

  /**
   * The trigger node processor's executor plugin id.
   *
   * Used to recognise which stored nodes are trigger nodes (and therefore own a
   * flowdrop_trigger_config entity), regardless of the node-type id an author
   * gave the node type.
   */
  private const TRIGGER_EXECUTOR_PLUGIN = "flowdrop_trigger:trigger";

  public function __construct(
    readonly protected TriggerManagerInterface $triggerManager,
    readonly protected NodeMetadataResolver $nodeMetadataResolver,
    readonly protected EntityTypeManagerInterface $entityTypeManager,
    readonly protected TriggerConfigSchemaBuilder $schemaBuilder,
  ) {}

  /**
   * The node config keys the in-editor trigger form owns (the "edit buffer").
   *
   * These schema-friendly fields appear under a trigger node's `data.config`
   * while the author edits them inside the FlowDrop editor. That copy is only
   * ever an in-memory edit buffer: the flowdrop_trigger_config entity stays the
   * single persisted source of truth. On save each present key is flushed into
   * the entity and then stripped from the stored node, so there is never a
   * second persisted copy to drift from.
   *
   * The condition fields are shared with the schema builder (which emits them
   * as top-level form fields) via
   * TriggerConfigSchemaBuilder::CONDITION_FIELD_KEYS; `event_type` rides
   * alongside them here as the one scalar entity field.
   *
   * Deliberately excluded: `orchestrator_settings` (and other admin-only
   * fields) never travel through the node buffer — the standalone admin form
   * is their sole owner — so save-time sync leaves them untouched.
   *
   * @return list<string>
   *   The buffered config keys.
   */
  private function bufferedConfigKeys(): array {
    return array_merge(["event_type"], TriggerConfigSchemaBuilder::CONDITION_FIELD_KEYS);
  }

  /**
   * Implements hook_ENTITY_TYPE_predelete() for flowdrop_workflow.
   *
   * Cascade-deletes every trigger config bound to the workflow. The config
   * dependency declared by FlowDropTriggerConfig covers config sync/uninstall;
   * this guarantees the same cleanup on the plain $workflow->delete() path the
   * editor API uses.
   */
  #[Hook('flowdrop_workflow_predelete')]
  public function workflowPredelete(EntityInterface $entity): void {
    if (!$entity instanceof FlowDropWorkflowInterface) {
      return;
    }

    $configs = $this->triggerManager->getTriggersForWorkflow((string) $entity->id());
    foreach ($configs as $config) {
      $config->delete();
    }
  }

  /**
   * Implements hook_ENTITY_TYPE_update() for flowdrop_workflow.
   *
   * Removes trigger configs whose node no longer exists in the saved graph
   * (the author deleted the trigger node from the canvas). Configs whose node
   * is still present are left untouched.
   */
  #[Hook('flowdrop_workflow_update')]
  public function workflowUpdate(EntityInterface $entity): void {
    if (!$entity instanceof FlowDropWorkflowInterface) {
      return;
    }

    $configs = $this->triggerManager->getTriggersForWorkflow((string) $entity->id());
    if ($configs === []) {
      return;
    }

    // Node IDs currently present in the saved graph.
    $presentNodeIds = [];
    foreach ($entity->getNodes() as $node) {
      $nodeId = is_array($node) ? ($node["id"] ?? NULL) : NULL;
      if (is_string($nodeId) && $nodeId !== "") {
        $presentNodeIds[$nodeId] = TRUE;
      }
    }

    foreach ($configs as $config) {
      if (!isset($presentNodeIds[$config->getNodeId()])) {
        $config->delete();
      }
    }
  }

  /**
   * Implements hook_ENTITY_TYPE_presave() for flowdrop_workflow.
   *
   * Bridges the in-editor config buffer to the trigger config entity. For each
   * trigger node in the saved graph:
   * - flush the buffered fields under `data.config` into the node's
   *   flowdrop_trigger_config entity (the source of truth), creating that
   *   entity first if the node was configured inline before one existed;
   * - strip the buffered keys from the stored node so the entity stays the only
   *   persisted copy and there is nothing to drift from.
   *
   * Runs before storage: FlowDropWorkflow::preSave() slims nodes first (keeping
   * `data.config` and the `data.metadata.node_type_id` anchor) and only then
   * invokes presave hooks, so the buffer is still present here, trigger nodes
   * are still identifiable, and the stripped nodes are what get written.
   *
   * Config sync is skipped so imported config round-trips verbatim.
   */
  #[Hook('flowdrop_workflow_presave')]
  public function workflowPresave(EntityInterface $entity): void {
    if (!$entity instanceof FlowDropWorkflowInterface) {
      return;
    }
    if ($entity->isSyncing()) {
      return;
    }

    $workflowId = (string) $entity->id();

    // Index this workflow's existing trigger configs by the node they belong
    // to, so we can update in place and detect which nodes still need one.
    $configByNode = [];
    foreach ($this->triggerManager->getTriggersForWorkflow($workflowId) as $config) {
      $configByNode[$config->getNodeId()] = $config;
    }

    $nodes = $entity->getNodes();
    $nodesChanged = FALSE;
    foreach ($nodes as $index => $node) {
      if (!is_array($node)) {
        continue;
      }
      $nodeId = $node["id"] ?? NULL;
      if (!is_string($nodeId) || $nodeId === "" || !$this->isTriggerNode($node)) {
        continue;
      }

      $buffer = $node["data"]["config"] ?? [];
      $buffer = is_array($buffer) ? $buffer : [];

      $config = $configByNode[$nodeId] ?? NULL;
      // A node configured inline before any entity existed needs one created.
      if ($config === NULL && $this->hasBufferedContent($buffer)) {
        $config = $this->createConfigForNode($workflowId, $nodeId, $buffer);
      }

      // Flush the buffer into the source of truth, saving only when it moved.
      if ($config !== NULL && $this->flushBufferedConfig($config, $buffer)) {
        $config->save();
      }
      // Strip the buffered copy so the entity remains the only persisted copy.
      if ($this->stripBufferedConfig($node)) {
        $nodes[$index] = $node;
        $nodesChanged = TRUE;
      }
    }

    if ($nodesChanged) {
      $entity->setNodes($nodes);
    }
  }

  /**
   * Determines whether a stored node is a trigger node.
   *
   * Keys off the executor plugin resolved from the node-type anchor rather than
   * the node-type id itself, so any node type built on the trigger processor is
   * recognised.
   *
   * @param array<string, mixed> $node
   *   The stored node.
   *
   * @return bool
   *   TRUE when the node is backed by the trigger processor.
   */
  private function isTriggerNode(array $node): bool {
    $nodeTypeId = $node["data"]["metadata"]["node_type_id"] ?? NULL;
    if (!is_string($nodeTypeId) || $nodeTypeId === "") {
      return FALSE;
    }
    return $this->nodeMetadataResolver->resolveExecutorPlugin($nodeTypeId) === self::TRIGGER_EXECUTOR_PLUGIN;
  }

  /**
   * Whether the buffer carries anything worth persisting to a new entity.
   *
   * Guards create-on-save: a trigger node the author never opened carries no
   * buffered keys, so no empty config entity is conjured for it.
   *
   * @param array<string, mixed> $buffer
   *   The node's `data.config` edit buffer.
   *
   * @return bool
   *   TRUE when a buffered key is present.
   */
  private function hasBufferedContent(array $buffer): bool {
    foreach ($this->bufferedConfigKeys() as $key) {
      if (array_key_exists($key, $buffer)) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * Creates (and persists a baseline) trigger config for a node.
   *
   * Mirrors the create endpoint: seeds the entity with defaults for the event
   * type carried in the buffer (or the fallback default) so a subsequent flush
   * only has to overlay the author's condition edits.
   *
   * @param string $workflowId
   *   The workflow id.
   * @param string $nodeId
   *   The trigger node id.
   * @param array<string, mixed> $buffer
   *   The node's `data.config` edit buffer.
   *
   * @return \Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface
   *   The created, saved trigger config.
   */
  private function createConfigForNode(string $workflowId, string $nodeId, array $buffer): FlowDropTriggerConfigInterface {
    // The event type comes from the form's validated select; getDefaultValues()
    // tolerates an unknown one by falling back, so no re-validation is needed.
    $eventType = "entity.insert";
    if (isset($buffer["event_type"]) && is_string($buffer["event_type"]) && $buffer["event_type"] !== "") {
      $eventType = $buffer["event_type"];
    }

    $defaults = $this->schemaBuilder->getDefaultValues($eventType);

    /** @var \Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface $config */
    $config = $this->entityTypeManager->getStorage("flowdrop_trigger_config")->create([
      "id" => FlowDropTriggerConfig::generateId($workflowId, $nodeId),
      "label" => sprintf("Trigger: %s", $eventType),
      "workflow_id" => $workflowId,
      "node_id" => $nodeId,
      "event_type" => $eventType,
      "conditions" => $defaults["conditions"],
      "orchestrator_settings" => $defaults["orchestrator_settings"],
      "initial_data_mapping" => [],
      "status" => TRUE,
      "weight" => 0,
    ]);
    $config->save();

    return $config;
  }

  /**
   * Flushes buffered config fields from a node into its trigger config entity.
   *
   * `event_type` maps to the scalar entity field; every condition key overlays
   * onto the entity's `conditions`. Only keys actually present in the buffer
   * are considered, and only a genuine change is applied — an absent or
   * unchanged key never overwrites the entity.
   *
   * @param \Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface $config
   *   The trigger config entity to update in place.
   * @param array<string, mixed> $buffer
   *   The node's `data.config` edit buffer.
   *
   * @return bool
   *   TRUE when the entity was mutated and must be saved.
   */
  private function flushBufferedConfig(FlowDropTriggerConfigInterface $config, array $buffer): bool {
    $changed = FALSE;

    if (array_key_exists("event_type", $buffer)
      && is_string($buffer["event_type"])
      && $buffer["event_type"] !== ""
      && $buffer["event_type"] !== $config->getEventType()) {
      $config->setEventType($buffer["event_type"]);
      $changed = TRUE;
    }

    $conditions = $config->getConditions();
    $updated = $conditions;
    foreach (TriggerConfigSchemaBuilder::CONDITION_FIELD_KEYS as $key) {
      if (array_key_exists($key, $buffer)) {
        $updated[$key] = $buffer[$key];
      }
    }
    if ($updated !== $conditions) {
      $config->setConditions($updated);
      $changed = TRUE;
    }

    return $changed;
  }

  /**
   * Strips buffered config keys from a node so the entity stays the only copy.
   *
   * @param array<string, mixed> $node
   *   The stored node, mutated in place.
   *
   * @return bool
   *   TRUE when a buffered key was removed (the node changed).
   */
  private function stripBufferedConfig(array &$node): bool {
    $buffer = $node["data"]["config"] ?? NULL;
    if (!is_array($buffer)) {
      return FALSE;
    }
    $stripped = array_diff_key($buffer, array_flip($this->bufferedConfigKeys()));
    if ($stripped === $buffer) {
      return FALSE;
    }
    $node["data"]["config"] = $stripped;
    return TRUE;
  }

}
