<?php

declare(strict_types=1);

namespace Drupal\flowdrop_workflow\Form;

use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Form\FormStateInterface;
use Drupal\flowdrop_node_type\Service\NodeMetadataResolver;
use Drupal\flowdrop_workflow\Entity\FlowDropWorkflow;
use Drupal\flowdrop_workflow\FlowDropWorkflowInterface;
use Drupal\flowdrop_workflow\Service\WorkflowDriftDetectorInterface;
use Drupal\flowdrop_workflow\Service\WorkflowValidatorInterface;

/**
 * FlowDropWorkflow form.
 *
 * @property \Drupal\flowdrop_workflow\FlowDropWorkflowInterface $entity
 *
 * @internal This class is not part of the public API and may change without notice.
 */
final class FlowDropWorkflowForm extends EntityForm {

  /**
   * Returns the shared workflow validator service.
   *
   * Constructor-injected services don't survive form_state cache
   * deserialization (DependencySerializationTrait can't track them on
   * promoted properties), so we fetch from the container on demand.
   */
  private function validator(): WorkflowValidatorInterface {
    // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection (Container fetch is intentional; see method docblock.)
    return \Drupal::service('flowdrop_workflow.validator');
  }

  /**
   * Returns the drift detector service.
   */
  private function driftDetector(): WorkflowDriftDetectorInterface {
    // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection (Container fetch is intentional; see validator() docblock.)
    return \Drupal::service('flowdrop_workflow.drift_detector');
  }

  /**
   * Returns the node metadata resolver service.
   */
  private function metadataResolver(): NodeMetadataResolver {
    // @phpstan-ignore globalDrupalDependencyInjection.useDependencyInjection (Container fetch is intentional; see validator() docblock.)
    return \Drupal::service('flowdrop_node_type.metadata_resolver');
  }

  /**
   * Stored nodes re-enriched with fresh metadata for read-time display.
   *
   * The exposure UI reads ports and node names from `data.metadata`, which slim
   * storage no longer persists; re-inject them from the live node type so the
   * port pickers and labels keep rendering. User config/label are untouched.
   *
   * @return array<int, array<string, mixed>>
   *   The enriched stored nodes.
   */
  private function enrichedNodes(): array {
    return $this->metadataResolver()->enrichNodes($this->entity->getNodes());
  }

  /**
   * {@inheritdoc}
   */
  public function form(array $form, FormStateInterface $form_state): array {
    if (!$this->entity instanceof FlowDropWorkflowInterface) {
      return [];
    }

    $form = parent::form($form, $form_state);

    $form['label'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Label'),
      '#maxlength' => 255,
      '#default_value' => $this->entity->label(),
      '#required' => TRUE,
    ];

    $form['id'] = [
      '#type' => 'machine_name',
      '#default_value' => $this->entity->id(),
      '#machine_name' => [
        'exists' => [FlowDropWorkflow::class, 'load'],
      ],
      '#disabled' => !$this->entity->isNew(),
    ];

    $form['status'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('Enabled'),
      '#default_value' => $this->entity->status(),
    ];

    $form['description'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Description'),
      '#default_value' => $this->entity->getDescription(),
      '#maxlength' => 1000,
      '#maxlength_js' => TRUE,
    ];

    if (!$this->entity->isNew()) {
      $this->buildSchemaFieldset($form, $form_state);
    }

    return $form;
  }

  /**
   * Builds the Schema fieldset (input/output exposure maps).
   */
  protected function buildSchemaFieldset(array &$form, FormStateInterface $form_state): void {
    $form['schema'] = [
      '#type' => 'details',
      '#title' => $this->t('Schema'),
      '#description' => $this->t('Declare typed inputs and outputs for callers of this workflow.'),
      '#open' => TRUE,
      '#tree' => TRUE,
    ];

    $this->buildDriftBanner($form['schema'], $form_state);
    foreach (['input' => $this->t('Input'), 'output' => $this->t('Output')] as $side => $sideLabel) {
      $form['schema'][$side] = [
        '#type' => 'fieldset',
        '#title' => $this->t('@side schema', ['@side' => $sideLabel]),
        '#tree' => TRUE,
      ];

      $form['schema'][$side]['wrapper'] = [
        '#type' => 'container',
        '#attributes' => ['id' => "edit-schema-{$side}-wrapper"],
      ];

      $this->buildExposureTable($form['schema'][$side]['wrapper'], $form_state, $side);
    }
  }

  /**
   * Builds the drift warning banner.
   */
  protected function buildDriftBanner(array &$container, FormStateInterface $form_state): void {
    if ($this->entity->getInputPorts() === [] && $this->entity->getOutputPorts() === []) {
      return;
    }
    $drifted = $this->driftDetector()->detectDrift($this->entity);
    if ($drifted === []) {
      return;
    }
    $container['drift_warning'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['messages', 'messages--warning']],
      'message' => [
        '#markup' => $this->formatPlural(
          count($drifted),
          '1 exposed port has changed since this workflow was last published. Check "Refresh schema" and save to publish a new schema version.',
          '@count exposed ports have changed since this workflow was last published. Check "Refresh schema" and save to publish a new schema version.',
        ),
      ],
      'refresh' => [
        '#type' => 'checkbox',
        '#title' => $this->t('Refresh schema on next save'),
        '#default_value' => FALSE,
        '#tree' => FALSE,
        '#parents' => ['schema_refresh_forced'],
      ],
    ];
  }

  /**
   * Builds the exposure table for one side (input or output).
   */
  protected function buildExposureTable(array &$container, FormStateInterface $form_state, string $side): void {
    $entries = $form_state->get("schema_{$side}_entries") ?? $this->getStoredEntries($side);
    // Mirror to storage on first build so static AJAX submit handlers can
    // read/append without re-loading from the entity.
    if ($form_state->get("schema_{$side}_entries") === NULL) {
      $form_state->set("schema_{$side}_entries", $entries);
    }
    $availableNodePorts = $this->getAvailableNodePorts($side, $entries);

    $container['entries'] = [
      '#type' => 'container',
    ];

    if ($entries === []) {
      $container['entries']['empty'] = [
        '#markup' => '<p>' . $this->t('No @side ports exposed yet.', ['@side' => $side]) . '</p>',
      ];
    }
    else {
      $container['entries']['rows'] = [
        '#type' => 'table',
        '#header' => [
          $this->t('Node'),
          $this->t('Port'),
          $this->t('Type'),
          $this->t('Exposed name'),
          $this->t('Operations'),
        ],
      ];

      foreach ($entries as $index => $entry) {
        $row = [];
        $row['node'] = [
          '#markup' => $this->t('@label (@id)', [
            '@label' => $this->getNodeLabel($entry['node_id']),
            '@id' => $entry['node_id'],
          ]),
        ];
        $row['port'] = [
          '#markup' => $entry['port'],
        ];
        $row['type'] = [
          '#markup' => $this->getPortDataType($side, $entry['node_id'], $entry['port']) ?: '—',
        ];
        $row['name'] = [
          '#type' => 'textfield',
          '#title' => $this->t('Exposed name'),
          '#title_display' => 'invisible',
          '#default_value' => $entry['name'],
          '#size' => 30,
          '#maxlength' => 64,
          '#parents' => ['schema', $side, 'wrapper', 'entries', 'rows', $index, 'name'],
        ];
        $row['operations'] = [
          '#type' => 'submit',
          '#value' => $this->t('Remove'),
          '#name' => "schema_{$side}_remove_{$index}",
          '#submit' => [[static::class, 'ajaxRemoveEntry']],
          '#limit_validation_errors' => [],
          '#ajax' => [
            'callback' => [static::class, 'ajaxReplaceEntriesWrapper'],
            'wrapper' => "edit-schema-{$side}-wrapper",
          ],
          '#schema_side' => $side,
          '#schema_index' => $index,
        ];
        $container['entries']['rows'][$index] = $row;
      }
    }

    // Add-port row.
    $container['add'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['form-inline']],
    ];

    if ($availableNodePorts === []) {
      $container['add']['empty'] = [
        '#markup' => '<p>' . $this->t('All eligible ports have been exposed.') . '</p>',
      ];
      return;
    }

    $container['add']['port'] = [
      '#type' => 'select',
      '#title' => $this->t('Select a Port to expose'),
      '#options' => $availableNodePorts,
      '#empty_option' => $this->t('- Select a port -'),
    ];

    $container['add']['name'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Exposed name'),
      '#size' => 30,
      '#maxlength' => 64,
    ];

    $container['add']['expose'] = [
      '#type' => 'submit',
      '#value' => $this->t('Expose'),
      '#name' => "schema_{$side}_expose",
      '#submit' => [[static::class, 'ajaxAddEntry']],
      '#limit_validation_errors' => [
        ['schema', $side, 'wrapper', 'add'],
      ],
      '#ajax' => [
        'callback' => [static::class, 'ajaxReplaceEntriesWrapper'],
        'wrapper' => "edit-schema-{$side}-wrapper",
      ],
      '#schema_side' => $side,
    ];
  }

  /**
   * Returns the stored exposure entries for one side.
   *
   * @return list<array{name: string, node_id: string, port: string}>
   *   The stored entries.
   */
  protected function getStoredEntries(string $side): array {
    return $side === 'input' ? $this->entity->getInputPorts() : $this->entity->getOutputPorts();
  }

  /**
   * Returns eligible (node, port) pairs grouped by node label.
   *
   * Skips the synthetic 'trigger' port and any (node, port) already in the
   * entries list. Each option label is "port_name (data_type)".
   *
   * @param 'input'|'output' $side
   *   The exposure side.
   * @param list<array{node_id: string, port: string}> $entries
   *   The entries already declared on this side.
   *
   * @return array<string, array<string, string>>
   *   Map of "Node label (id)" => [ "node_id:port_id" =>
   *   "port_name (data_type)" ].
   */
  protected function getAvailableNodePorts(string $side, array $entries): array {
    $excluded = [];
    foreach ($entries as $entry) {
      $key = ($entry['node_id'] ?? '') . ':' . ($entry['port'] ?? '');
      $excluded[$key] = TRUE;
    }

    $portsKey = $side === 'input' ? 'inputs' : 'outputs';
    $grouped = [];
    foreach ($this->enrichedNodes() as $node) {
      $nodeId = $node['id'] ?? '';
      if ($nodeId === '') {
        continue;
      }
      $nodeLabel = (string) ($node['data']['label'] ?? $node['data']['metadata']['name'] ?? $nodeId);
      $groupLabel = sprintf('%s (%s)', $nodeLabel, $nodeId);

      foreach ($node['data']['metadata'][$portsKey] ?? [] as $port) {
        $portId = $port['id'] ?? '';
        if ($portId === '' || $portId === 'trigger') {
          continue;
        }
        $key = $nodeId . ':' . $portId;
        if (isset($excluded[$key])) {
          continue;
        }
        $portName = (string) ($port['name'] ?? $portId);
        $dataType = (string) ($port['dataType'] ?? '');
        $grouped[$groupLabel][$key] = $dataType !== ''
          ? sprintf('%s (%s)', $portName, $dataType)
          : $portName;
      }
    }
    return $grouped;
  }

  /**
   * Resolves a port's data type as declared on the node metadata.
   */
  protected function getPortDataType(string $side, string $nodeId, string $portId): string {
    $portsKey = $side === 'input' ? 'inputs' : 'outputs';
    foreach ($this->enrichedNodes() as $node) {
      $id = $node['id'] ?? '';
      if ($id !== $nodeId) {
        continue;
      }
      foreach ($node['data']['metadata'][$portsKey] ?? [] as $port) {
        if (($port['id'] ?? '') === $portId) {
          return (string) ($port['dataType'] ?? '');
        }
      }
      break;
    }
    return '';
  }

  /**
   * Resolves a node ID to a human-readable label.
   */
  protected function getNodeLabel(string $nodeId): string {
    foreach ($this->enrichedNodes() as $node) {
      $id = $node['id'] ?? '';
      if ($id === $nodeId) {
        return (string) ($node['data']['label'] ?? $node['data']['metadata']['name'] ?? $nodeId);
      }
    }
    return $nodeId;
  }

  /**
   * AJAX submit handler: appends a new exposure entry.
   */
  public static function ajaxAddEntry(array &$form, FormStateInterface $form_state): void {
    $triggering = $form_state->getTriggeringElement();
    $side = $triggering['#schema_side'] ?? 'input';

    $values = $form_state->getValue(['schema', $side, 'wrapper', 'add']) ?? [];
    $port = trim((string) ($values['port'] ?? ''));
    [$nodeId, $port] = explode(':', $port);
    $name = trim((string) ($values['name'] ?? '')) ?: $port;

    if ($nodeId === '' || $port === '' || $name === '') {
      $form_state->setRebuild(TRUE);
      return;
    }

    $entries = $form_state->get("schema_{$side}_entries") ?? [];
    $entries[] = ['name' => $name, 'node_id' => $nodeId, 'port' => $port];
    $form_state->set("schema_{$side}_entries", array_values($entries));
    $form_state->setRebuild(TRUE);
  }

  /**
   * AJAX submit handler: removes an exposure entry by index.
   */
  public static function ajaxRemoveEntry(array &$form, FormStateInterface $form_state): void {
    $triggering = $form_state->getTriggeringElement();
    $side = $triggering['#schema_side'] ?? 'input';
    $index = (int) ($triggering['#schema_index'] ?? -1);

    $entries = $form_state->get("schema_{$side}_entries") ?? static::loadStoredEntriesFromForm($form_state, $side);
    if (isset($entries[$index])) {
      unset($entries[$index]);
      $form_state->set("schema_{$side}_entries", array_values($entries));
    }
    $form_state->setRebuild(TRUE);
  }

  /**
   * AJAX callback: returns the entries wrapper container.
   */
  public static function ajaxReplaceEntriesWrapper(array &$form, FormStateInterface $form_state): array {
    $triggering = $form_state->getTriggeringElement();
    $side = $triggering['#schema_side'] ?? 'input';
    return $form['schema'][$side]['wrapper'];
  }

  /**
   * Reads the stored exposure list from the form's entity.
   *
   * Used as a fallback when form_state hasn't cached entries yet.
   *
   * @return list<array{name: string, node_id: string, port: string}>
   *   The stored exposure entries.
   */
  protected static function loadStoredEntriesFromForm(FormStateInterface $form_state, string $side): array {
    $form = $form_state->getCompleteForm();
    $build = $form['#build_id'] ?? '';
    if ($build === '') {
      return [];
    }
    // Best-effort: fetch from the entity if accessible via form state's
    // build callback. In practice form_state->set is populated on first
    // rebuild via buildExposureTable's initialiser path.
    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state): void {
    parent::validateForm($form, $form_state);

    if (strlen($form_state->getValue('description') ?? '') > 1000) {
      $form_state->setErrorByName('description', $this->t('Description must not exceed 1000 characters.'));
    }

    if ($this->entity->isNew()) {
      return;
    }

    // Assemble the prospective exposure maps (with name-cell overrides typed in
    // the table) so validation matches what save() will persist, then hand the
    // whole definition to the shared validator.
    $ports = [];
    foreach (['input', 'output'] as $side) {
      $entries = $form_state->get("schema_{$side}_entries") ?? $this->getStoredEntries($side);
      $rowsInput = $form_state->getValue(['schema', $side, 'wrapper', 'entries', 'rows']) ?? [];
      foreach ($entries as $index => $entry) {
        if (isset($rowsInput[$index]['name'])) {
          $entries[$index]['name'] = trim((string) $rowsInput[$index]['name']);
        }
      }
      $ports[$side] = array_values($entries);
    }

    $result = $this->validator()->validate(
      $this->entity->getNodes(),
      $this->entity->getEdges(),
      $ports['input'],
      $ports['output'],
    );
    foreach ($result->getErrors() as $error) {
      $form_state->setErrorByName(
        $this->formElementForLocator($error->getParameter()),
        $error->getMessage(),
      );
    }
  }

  /**
   * Maps a validator locator to a form element name.
   *
   * Exposure-entry problems target the matching name cell; node- and
   * workflow-level problems have no specific widget and surface as a form-level
   * error.
   */
  private function formElementForLocator(string $locator): string {
    if (str_starts_with($locator, 'schema.')) {
      [, $side, $index] = explode('.', $locator, 3);
      return "schema][{$side}][wrapper][entries][rows][{$index}][name";
    }
    return '';
  }

  /**
   * {@inheritdoc}
   */
  public function save(array $form, FormStateInterface $form_state): int {
    if (!$this->entity->isNew() && $this->entity instanceof FlowDropWorkflow) {
      // Persist exposure-map values from form_state. If form_state has no
      // cached entries (e.g. unchanged), preserve current values.
      foreach (['input', 'output'] as $side) {
        // Fall back to the stored exposure list so a plain Save (no AJAX
        // interaction first) still applies name-cell edits typed in the table.
        $entries = $form_state->get("schema_{$side}_entries") ?? $this->getStoredEntries($side);

        // Normalise entries, picking up any name overrides typed in the table.
        $rowsInput = $form_state->getValue(['schema', $side, 'wrapper', 'entries', 'rows']) ?? [];
        $normalised = [];
        foreach ($entries as $index => $entry) {
          $name = $rowsInput[$index]['name'] ?? $entry['name'];
          $normalised[] = [
            'name' => trim((string) $name),
            'node_id' => $entry['node_id'],
            'port' => $entry['port'],
          ];
        }
        if ($side === 'input') {
          $this->entity->setInputPorts($normalised === [] ? NULL : $normalised);
        }
        else {
          $this->entity->setOutputPorts($normalised === [] ? NULL : $normalised);
        }
      }

      // Honour the "Refresh schema" checkbox from the drift banner.
      if ($form_state->getValue('schema_refresh_forced')) {
        $this->entity->setSchemaRebuildForced(TRUE);
      }
    }

    $result = parent::save($form, $form_state);
    $message_args = ['%label' => $this->entity->label()];
    $this->messenger()->addStatus(
      match($result) {
        \SAVED_NEW => $this->t('Created new workflow %label.', $message_args),
        default => $this->t('Updated workflow %label.', $message_args),
      }
    );
    $form_state->setRedirectUrl($this->entity->toUrl('collection'));
    return $result;
  }

}
