<?php

declare(strict_types=1);

namespace Drupal\flowdrop_workflow\Diagnostic;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\flowdrop\Constants\ReservedName;
use Drupal\flowdrop\DTO\ValidationError;
use Drupal\flowdrop\DTO\ValidationWarning;
use Drupal\flowdrop\Service\FlowDropNodeProcessorPluginManager;
use Drupal\flowdrop\DTO\JsApi\Edge as TransportEdge;
use Drupal\flowdrop\Utility\EdgePortClassifier;
use Drupal\flowdrop_node_type\FlowDropNodeTypeInterface;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveEdge;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNode;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNodeConfigKey;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\ReplaceNodeType;
use Drupal\flowdrop_workflow\Diagnostic\Mutation\SetNodeConfig;
use Drupal\flowdrop_workflow\Service\WorkflowValidatorInterface;

/**
 * Diagnoses a workflow via the validator and offers per-rule remedies.
 *
 * A thin adapter: detection is the validator's, single-sourced; this class owns
 * only the validator-code → finding → remedy → mutation translation. That
 * mapping table lives here as one cohesive seam between detection and
 * remediation. Findings carry stable locators (a node id or edge id, never an
 * array index) so a selection survives the render→submit gap.
 *
 * Repairable codes and their remedies:
 * - R1 (plugin missing): Replace node type / Remove node.
 * - R6 (config required/invalid): Reset to default / Remove node.
 * - R7 (edge to a not-exposed port): Expose the port / Remove edge.
 * - R8 (edge endpoint missing): Remove edge.
 * - unknown-config-key warning: Remove the unknown key.
 *
 * Other codes (R4 exposure integrity, R5 structural limits) are surfaced as
 * findings without a remedy — informational until a fix is designed for them.
 *
 * @internal
 */
final class ValidatorDiagnostic implements WorkflowDiagnosticInterface {

  use StringTranslationTrait;

  public const ID = 'validator';

  /**
   * Synthetic code for the validator's "unknown config key" warning.
   */
  private const CODE_CONFIG_UNKNOWN = 'W_CONFIG_UNKNOWN';

  /**
   * Memoised config defaults keyed by node type id.
   *
   * @var array<string, array<string, mixed>>
   */
  private array $defaultsCache = [];

  /**
   * Memoised list of available node types (id => label).
   *
   * @var array<string, string>|null
   */
  private ?array $availableTypes = NULL;

  public function __construct(
    private readonly WorkflowValidatorInterface $validator,
    private readonly NodeMetadataResolver $metadataResolver,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly FlowDropNodeProcessorPluginManager $pluginManager,
  ) {}

  /**
   * {@inheritdoc}
   */
  public function id(): string {
    return self::ID;
  }

  /**
   * {@inheritdoc}
   */
  public function label(): TranslatableMarkup {
    return $this->t('Structural validation');
  }

  /**
   * {@inheritdoc}
   */
  public function diagnose(DiagnosticContext $context): array {
    $workflow = $context->workflow();
    $result = $this->validator->validate(
      $workflow->getNodes(),
      $workflow->getEdges(),
      $workflow->getInputPorts(),
      $workflow->getOutputPorts(),
    );

    $findings = [];
    foreach ($result->getErrors() as $error) {
      $findings[] = $this->errorToFinding($error, $context);
    }
    foreach ($result->getWarnings() as $warning) {
      $finding = $this->warningToFinding($warning, $context);
      if ($finding !== NULL) {
        $findings[] = $finding;
      }
    }
    return $findings;
  }

  /**
   * {@inheritdoc}
   */
  public function buildMutations(Finding $finding, string $remedyId, array $params, DiagnosticContext $context): array {
    return match ($remedyId) {
      'remove_node' => $this->buildRemoveNode($finding),
      'remove_edge' => $this->buildRemoveEdge($finding),
      'remove_config_key' => $this->buildRemoveConfigKey($finding),
      'replace_node_type' => $this->buildReplaceNodeType($finding, $params),
      'reset_config' => $this->buildResetConfig($finding, $context),
      'expose_port' => $this->buildExposePort($finding, $context),
      default => [],
    };
  }

  /**
   * Translates one validation error into a finding with its remedies.
   *
   * @param \Drupal\flowdrop\DTO\ValidationError $error
   *   The validator error.
   * @param \Drupal\flowdrop_workflow\Diagnostic\DiagnosticContext $context
   *   The working set.
   *
   * @return \Drupal\flowdrop_workflow\Diagnostic\Finding
   *   The finding.
   */
  private function errorToFinding(ValidationError $error, DiagnosticContext $context): Finding {
    $code = (string) $error->getCode();
    $message = $error->getMessage();

    return match ($code) {
      WorkflowValidatorInterface::CODE_PLUGIN_MISSING => $this->pluginMissingFinding($error, $message),
      WorkflowValidatorInterface::CODE_CONFIG_REQUIRED,
      WorkflowValidatorInterface::CODE_CONFIG_INVALID => $this->configFinding($error, $code, $message, $context),
      WorkflowValidatorInterface::CODE_EDGE_TARGET_NOT_EXPOSED,
      WorkflowValidatorInterface::CODE_EDGE_SOURCE_NOT_EXPOSED => $this->edgeExposureFinding($error, $code, $message, $context),
      WorkflowValidatorInterface::CODE_EDGE_SOURCE_MISSING,
      WorkflowValidatorInterface::CODE_EDGE_TARGET_MISSING => $this->edgeMissingFinding($error, $code, $message, $context),
      default => $this->informationalFinding($error, $code, $message, $context),
    };
  }

  /**
   * R1: a node's executor plugin no longer resolves.
   */
  private function pluginMissingFinding(ValidationError $error, string $message): Finding {
    $nodeId = $this->nodeIdFromLocator($error->getParameter());
    $remedies = [];
    if ($nodeId !== '') {
      $choices = $this->availableNodeTypes();
      if ($choices !== []) {
        $remedies[] = new RemedyOption(
          'replace_node_type',
          $this->t('Replace node type'),
          $this->t('Re-anchor this node to an installed node type. Its config is kept and re-checked.'),
          FALSE,
          ['choices' => $choices],
        );
      }
      $remedies[] = $this->removeNodeRemedy();
    }
    return new Finding(
      self::ID,
      WorkflowValidatorInterface::CODE_PLUGIN_MISSING,
      Severity::Error,
      'node.' . ($nodeId !== '' ? $nodeId : '?'),
      $message,
      ['node_id' => $nodeId],
      $remedies,
    );
  }

  /**
   * R6: a node's config is missing a required key or violates its schema.
   */
  private function configFinding(ValidationError $error, string $code, string $message, DiagnosticContext $context): Finding {
    $ref = $this->parseNodeRef($error->getParameter(), $context);
    $nodeId = $ref['node_id'];
    $key = $ref['key'];

    $remedies = [];
    if ($nodeId !== '' && $key !== NULL && $key !== '') {
      $typeId = $context->nodeTypeId($nodeId);
      $hasDefault = array_key_exists($key, $this->configDefaults($typeId));
      // A required-missing key can only be remedied by a reset when a default
      // exists to supply it; an invalid value can always be reset (set to the
      // default, or removed when there is none).
      if ($code === WorkflowValidatorInterface::CODE_CONFIG_INVALID || $hasDefault) {
        $remedies[] = new RemedyOption(
          'reset_config',
          $this->t('Reset to default'),
          $hasDefault
            ? $this->t('Reset "@key" to its default value.', ['@key' => $key])
            : $this->t('Remove the invalid "@key" value.', ['@key' => $key]),
        );
      }
    }
    if ($nodeId !== '') {
      $remedies[] = $this->removeNodeRemedy();
    }

    return new Finding(
      self::ID,
      $code,
      Severity::Error,
      $error->getParameter(),
      $message,
      ['node_id' => $nodeId, 'key' => $key],
      $remedies,
    );
  }

  /**
   * R7: an edge addresses a port that is not exposed on its endpoint node.
   */
  private function edgeExposureFinding(ValidationError $error, string $code, string $message, DiagnosticContext $context): Finding {
    $index = $this->edgeIndex($error->getParameter());
    $edge = $context->edges()[$index] ?? [];
    $edge = is_array($edge) ? $edge : [];
    $edgeId = $this->edgeRef($index, $edge);

    $isTarget = $code === WorkflowValidatorInterface::CODE_EDGE_TARGET_NOT_EXPOSED;
    $side = $isTarget ? ReservedName::PORT_INPUT : ReservedName::PORT_OUTPUT;
    $transportEdge = TransportEdge::fromArray($edge);
    $nodeId = $isTarget ? $transportEdge->getTarget() : $transportEdge->getSource();
    $handle = $isTarget ? $transportEdge->getTargetHandle() : $transportEdge->getSourceHandle();
    $port = EdgePortClassifier::portName($handle, $side);

    $remedies = [];
    if ($nodeId !== '' && $port !== '') {
      $remedies[] = new RemedyOption(
        'expose_port',
        $this->t('Expose the port'),
        $this->t('Mark the @side port "@port" exposed on node "@node" so the edge is valid.', [
          '@side' => $side,
          '@port' => $port,
          '@node' => $nodeId,
        ]),
      );
    }
    $remedies[] = $this->removeEdgeRemedy();

    return new Finding(
      self::ID,
      $code,
      Severity::Error,
      'edge.' . $edgeId,
      $message,
      ['edge_id' => $edgeId, 'node_id' => $nodeId, 'side' => $side, 'port' => $port],
      $remedies,
    );
  }

  /**
   * R8: an edge endpoint references a node that no longer exists.
   */
  private function edgeMissingFinding(ValidationError $error, string $code, string $message, DiagnosticContext $context): Finding {
    $index = $this->edgeIndex($error->getParameter());
    $edge = $context->edges()[$index] ?? [];
    $edgeId = $this->edgeRef($index, is_array($edge) ? $edge : []);

    return new Finding(
      self::ID,
      $code,
      Severity::Error,
      'edge.' . $edgeId,
      $message,
      ['edge_id' => $edgeId],
      [$this->removeEdgeRemedy()],
    );
  }

  /**
   * Any other validator code: surface it, no remedy yet.
   */
  private function informationalFinding(ValidationError $error, string $code, string $message, DiagnosticContext $context): Finding {
    return new Finding(
      self::ID,
      $code !== '' ? $code : 'UNKNOWN',
      Severity::Error,
      $this->stableLocator($error->getParameter(), $context),
      $message,
      [],
      [],
    );
  }

  /**
   * Translates an "unknown config key" warning into a removable finding.
   *
   * @param \Drupal\flowdrop\DTO\ValidationWarning $warning
   *   The validator warning.
   * @param \Drupal\flowdrop_workflow\Diagnostic\DiagnosticContext $context
   *   The working set.
   *
   * @return \Drupal\flowdrop_workflow\Diagnostic\Finding|null
   *   The finding, or NULL when the warning is not one we remediate.
   */
  private function warningToFinding(ValidationWarning $warning, DiagnosticContext $context): ?Finding {
    $ref = $this->parseNodeRef($warning->getParameter(), $context);
    $nodeId = $ref['node_id'];
    $key = $ref['key'];
    if ($nodeId === '' || $key === NULL || $key === '') {
      return NULL;
    }
    return new Finding(
      self::ID,
      self::CODE_CONFIG_UNKNOWN,
      Severity::Warning,
      $warning->getParameter(),
      $warning->getMessage(),
      ['node_id' => $nodeId, 'key' => $key],
      [
        new RemedyOption(
          'remove_config_key',
          $this->t('Remove the unknown key'),
          $this->t('Delete config "@key"; the runtime ignores it anyway.', ['@key' => $key]),
        ),
      ],
    );
  }

  /* --------------------------------------------------------------------- */
  /* Mutation builders.                                                    */
  /* --------------------------------------------------------------------- */

  /**
   * Builds the RemoveNode mutation for a node-scoped finding.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNode>
   *   The mutation, or [] when the finding carries no node id.
   */
  private function buildRemoveNode(Finding $finding): array {
    $nodeId = (string) ($finding->context['node_id'] ?? '');
    return $nodeId === '' ? [] : [new RemoveNode($nodeId)];
  }

  /**
   * Builds the RemoveEdge mutation for an edge-scoped finding.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveEdge>
   *   The mutation, or [] when the finding carries no edge id.
   */
  private function buildRemoveEdge(Finding $finding): array {
    $edgeId = (string) ($finding->context['edge_id'] ?? '');
    return $edgeId === '' ? [] : [new RemoveEdge($edgeId)];
  }

  /**
   * Builds the RemoveNodeConfigKey mutation for an unknown-key finding.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\RemoveNodeConfigKey>
   *   The mutation, or [] when the finding is incomplete.
   */
  private function buildRemoveConfigKey(Finding $finding): array {
    $nodeId = (string) ($finding->context['node_id'] ?? '');
    $key = (string) ($finding->context['key'] ?? '');
    return ($nodeId === '' || $key === '') ? [] : [new RemoveNodeConfigKey($nodeId, $key)];
  }

  /**
   * Builds the ReplaceNodeType mutation, guarding the chosen type server-side.
   *
   * @param \Drupal\flowdrop_workflow\Diagnostic\Finding $finding
   *   The plugin-missing finding being remedied.
   * @param array<string, mixed> $params
   *   The user's params, expected to carry 'node_type_id'.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\ReplaceNodeType>
   *   The mutation, or [] when the chosen type is unknown/unavailable.
   */
  private function buildReplaceNodeType(Finding $finding, array $params): array {
    $nodeId = (string) ($finding->context['node_id'] ?? '');
    $newTypeId = (string) ($params['node_type_id'] ?? '');
    // Never trust the submitted type id: it must be one of the available types
    // we offered, recomputed here, not whatever the client sent.
    if ($nodeId === '' || $newTypeId === '' || !isset($this->availableNodeTypes()[$newTypeId])) {
      return [];
    }
    return [new ReplaceNodeType($nodeId, $newTypeId)];
  }

  /**
   * Builds the reset-to-default mutation for a config key.
   *
   * Sets the key to the node type's declared default, or removes it when the
   * type declares no default.
   *
   * @param \Drupal\flowdrop_workflow\Diagnostic\Finding $finding
   *   The config finding being remedied.
   * @param \Drupal\flowdrop_workflow\Diagnostic\DiagnosticContext $context
   *   The working set.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\WorkflowMutationInterface>
   *   The mutation, or [] when the finding is incomplete.
   */
  private function buildResetConfig(Finding $finding, DiagnosticContext $context): array {
    $nodeId = (string) ($finding->context['node_id'] ?? '');
    $key = (string) ($finding->context['key'] ?? '');
    if ($nodeId === '' || $key === '') {
      return [];
    }
    $defaults = $this->configDefaults($context->nodeTypeId($nodeId));
    if (array_key_exists($key, $defaults)) {
      return [new SetNodeConfig(
        $nodeId,
        $key,
        $defaults[$key],
        sprintf('Reset config "%s" on node "%s" to its default.', $key, $nodeId),
        ),
      ];
    }
    return [new RemoveNodeConfigKey($nodeId, $key)];
  }

  /**
   * Builds the mutation that exposes the port an R7 edge addresses.
   *
   * @return list<\Drupal\flowdrop_workflow\Diagnostic\Mutation\SetNodeConfig>
   *   The mutation, or [] when the finding is incomplete.
   */
  private function buildExposePort(Finding $finding, DiagnosticContext $context): array {
    $nodeId = (string) ($finding->context['node_id'] ?? '');
    $side = (string) ($finding->context['side'] ?? '');
    $port = (string) ($finding->context['port'] ?? '');
    if ($nodeId === '' || $port === '' || $side === '') {
      return [];
    }

    $config = $context->nodeConfig($nodeId);
    $configPorts = is_array($config[ReservedName::PORTS] ?? NULL) ? $config[ReservedName::PORTS] : [];
    $newPorts = $this->withPortExposed($configPorts, $side, $port);

    return [new SetNodeConfig(
      $nodeId,
      ReservedName::PORTS,
      $newPorts,
      sprintf('Expose the %s port "%s" of node "%s".', $side, $port, $nodeId),
      ),
    ];
  }

  /**
   * Returns a `ports` config value with one port marked exposed.
   *
   * @param array<string, mixed> $configPorts
   *   The node's existing `data.config.ports` value.
   * @param string $side
   *   ReservedName::PORT_INPUT or ::PORT_OUTPUT.
   * @param string $port
   *   The port id to expose.
   *
   * @return array{inputs: list<array<string, mixed>>, outputs: list<array<string, mixed>>}
   *   The updated ports structure.
   */
  private function withPortExposed(array $configPorts, string $side, string $port): array {
    $ports = [
      'inputs' => is_array($configPorts['inputs'] ?? NULL) ? array_values($configPorts['inputs']) : [],
      'outputs' => is_array($configPorts['outputs'] ?? NULL) ? array_values($configPorts['outputs']) : [],
    ];
    $key = $side === ReservedName::PORT_OUTPUT ? 'outputs' : 'inputs';

    $found = FALSE;
    foreach ($ports[$key] as &$entry) {
      if (is_array($entry) && ($entry['id'] ?? NULL) === $port) {
        $entry['exposed'] = TRUE;
        $found = TRUE;
        break;
      }
    }
    unset($entry);
    if (!$found) {
      $ports[$key][] = ['id' => $port, 'exposed' => TRUE];
    }
    return $ports;
  }

  /* --------------------------------------------------------------------- */
  /* Shared remedy descriptors + locator parsing.                          */
  /* --------------------------------------------------------------------- */

  /**
   * The shared "remove node" remedy.
   */
  private function removeNodeRemedy(): RemedyOption {
    return new RemedyOption(
      'remove_node',
      $this->t('Remove node'),
      $this->t('Delete this node and any edges connected to it.'),
      TRUE,
    );
  }

  /**
   * The shared "remove edge" remedy.
   */
  private function removeEdgeRemedy(): RemedyOption {
    return new RemedyOption(
      'remove_edge',
      $this->t('Remove edge'),
      $this->t('Delete this edge.'),
      TRUE,
    );
  }

  /**
   * Extracts a node id from a `node.{id}` locator (no `.config` suffix).
   */
  private function nodeIdFromLocator(string $parameter): string {
    if (!str_starts_with($parameter, 'node.')) {
      return '';
    }
    $id = substr($parameter, strlen('node.'));
    return $id === '?' ? '' : $id;
  }

  /**
   * Parses a `node.{id}[.config[.{key}]]` locator into node id + config key.
   *
   * Node ids may contain dots (e.g. "calculator.1"), so the node id is resolved
   * by matching the longest known node id rather than splitting on '.'.
   *
   * @return array{node_id: string, key: string|null}
   *   The node id (or '' when unresolved) and the config key (or NULL when the
   *   locator names no key).
   */
  private function parseNodeRef(string $parameter, DiagnosticContext $context): array {
    if (!str_starts_with($parameter, 'node.')) {
      return ['node_id' => '', 'key' => NULL];
    }
    $rem = substr($parameter, strlen('node.'));
    $nodesById = $context->nodesById();

    if (isset($nodesById[$rem])) {
      return ['node_id' => $rem, 'key' => NULL];
    }

    $best = NULL;
    foreach (array_keys($nodesById) as $nid) {
      $needle = $nid . '.config';
      if (($rem === $needle || str_starts_with($rem, $needle . '.'))
        && ($best === NULL || strlen($nid) > strlen($best))) {
        $best = $nid;
      }
    }
    if ($best === NULL) {
      // Fallback for an unresolved id (e.g. "?"): split on the first '.config'.
      $pos = strpos($rem, '.config');
      if ($pos === FALSE) {
        return ['node_id' => $rem === '?' ? '' : $rem, 'key' => NULL];
      }
      $best = substr($rem, 0, $pos);
    }

    $suffix = substr($rem, strlen($best));
    $key = NULL;
    $cfgPrefix = '.config.';
    if (str_starts_with($suffix, $cfgPrefix)) {
      $key = substr($suffix, strlen($cfgPrefix));
    }
    return ['node_id' => $best, 'key' => $key];
  }

  /**
   * Extracts the zero-based index from an `edge.{index}` locator.
   */
  private function edgeIndex(string $parameter): int {
    return str_starts_with($parameter, 'edge.')
      ? (int) substr($parameter, strlen('edge.'))
      : 0;
  }

  /**
   * The stable edge reference: the edge id, or the index for an id-less edge.
   *
   * @param int $index
   *   The edge's array index.
   * @param array<string, mixed> $edge
   *   The edge.
   *
   * @return string
   *   The edge id when present, else the index as a string (the mutator
   *   recognises a numeric ref as an index fallback for hand-edited edges).
   */
  private function edgeRef(int $index, array $edge): string {
    $id = (string) ($edge['id'] ?? '');
    return $id !== '' ? $id : (string) $index;
  }

  /**
   * Rewrites a validator locator to a stable form for informational findings.
   *
   * Edge and exposure locators carry array indices; translate them to stable
   * ids/names so a finding's identity survives renumbering. Node and workflow
   * locators are already stable.
   */
  private function stableLocator(string $parameter, DiagnosticContext $context): string {
    if (str_starts_with($parameter, 'edge.')) {
      $index = $this->edgeIndex($parameter);
      $edge = $context->edges()[$index] ?? [];
      return 'edge.' . $this->edgeRef($index, is_array($edge) ? $edge : []);
    }
    if (str_starts_with($parameter, 'schema.')) {
      // schema.{side}.{index} → exposure.{side}.{name}.
      $parts = explode('.', $parameter);
      $side = $parts[1] ?? '';
      $index = (int) ($parts[2] ?? 0);
      $ports = $side === 'output' ? $context->workflow()->getOutputPorts() : $context->workflow()->getInputPorts();
      $name = (string) ($ports[$index]['name'] ?? $index);
      return 'exposure.' . $side . '.' . $name;
    }
    return $parameter;
  }

  /**
   * Config defaults for a node type id (memoised; [] when unresolvable).
   *
   * @return array<string, mixed>
   *   The default config values keyed by config key.
   */
  private function configDefaults(string $nodeTypeId): array {
    if (!array_key_exists($nodeTypeId, $this->defaultsCache)) {
      $metadata = $nodeTypeId === '' ? NULL : $this->metadataResolver->buildForNodeTypeId($nodeTypeId);
      $config = $metadata?->toArray()['config'] ?? NULL;
      $this->defaultsCache[$nodeTypeId] = is_array($config) ? $config : [];
    }
    return $this->defaultsCache[$nodeTypeId];
  }

  /**
   * Node types whose executor plugin is installed (id => label), memoised.
   *
   * The closed set of valid choices for the "replace node type" remedy — a
   * type whose plugin is itself missing is excluded, so the remedy can never
   * swap one broken type for another.
   *
   * @return array<string, string>
   *   Available node types, id => label.
   */
  private function availableNodeTypes(): array {
    if ($this->availableTypes !== NULL) {
      return $this->availableTypes;
    }
    $types = [];
    foreach ($this->entityTypeManager->getStorage('flowdrop_node_type')->loadMultiple() as $entity) {
      if (!$entity instanceof FlowDropNodeTypeInterface) {
        continue;
      }
      $plugin = $entity->getExecutorPlugin();
      if ($plugin === '' || !$this->pluginManager->hasDefinition($plugin)) {
        continue;
      }
      $types[(string) $entity->id()] = (string) $entity->label();
    }
    asort($types);
    return $this->availableTypes = $types;
  }

}
