<?php

declare(strict_types=1);

namespace Drupal\flowdrop_interrupt\Controller;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\flowdrop\Controller\Api\ApiResponseTrait;
use Drupal\flowdrop_interrupt\DTO\ExecutionStamp;
use Drupal\flowdrop_interrupt\Enum\InterruptType;
use Drupal\flowdrop_interrupt\Message\InwardSignalMessage;
use Drupal\flowdrop_interrupt\Service\InterruptManagerInterface;
use Drupal\flowdrop_interrupt\Service\InterruptMessageBusInterface;
use Drupal\flowdrop_pipeline\Entity\FlowDropPipelineInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

/**
 * HTTP endpoints for Cancel/Pause/Resume inward signals on pipelines.
 *
 * Pipeline-oriented surface — the URL identifies the pipeline being acted
 * on, not the resulting interrupt. Authorization is owner-or-permission:
 * the pipeline's `uid` always grants; otherwise the caller needs
 * `cancel any flowdrop workflow` / `pause any flowdrop workflow`.
 *
 * @internal
 */
final class PipelineSignalController extends ControllerBase {

  use ApiResponseTrait;

  public function __construct(
    protected readonly InterruptManagerInterface $interruptManager,
    protected readonly InterruptMessageBusInterface $messageBus,
  ) {}

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('flowdrop_interrupt.manager'),
      $container->get('flowdrop_interrupt.message_bus'),
    );
  }

  /**
   * POST /flowdrop/api/pipelines/{id}/cancel.
   *
   * Access is enforced by {@see self::accessCancel()} at the routing layer.
   */
  public function cancel(string $pipeline_id, Request $request): JsonResponse {
    return $this->createInwardSignal($pipeline_id, $request, InterruptType::Cancel);
  }

  /**
   * POST /flowdrop/api/pipelines/{id}/pause.
   *
   * Access is enforced by {@see self::accessPause()} at the routing layer.
   */
  public function pause(string $pipeline_id, Request $request): JsonResponse {
    return $this->createInwardSignal($pipeline_id, $request, InterruptType::Pause);
  }

  /**
   * POST /flowdrop/api/pipelines/{id}/resume.
   *
   * Resolves the active Pause interrupt for the pipeline. Returns 409 if
   * no Pause is currently pending. Access is enforced by
   * {@see self::accessResume()} at the routing layer.
   */
  public function resume(string $pipeline_id, Request $request): JsonResponse {
    $pipeline = $this->loadPipeline($pipeline_id);
    if ($pipeline === NULL) {
      return $this->errorResponse('Pipeline not found', Response::HTTP_NOT_FOUND);
    }
    if ($this->isTerminal($pipeline)) {
      return $this->errorResponse(
        sprintf('Pipeline is in a terminal state (%s); cannot resume.', $pipeline->getStatus()),
        Response::HTTP_CONFLICT,
      );
    }

    $signal = $this->interruptManager->findPendingInwardSignal($pipeline_id);
    if ($signal === NULL || $signal->getType() !== InterruptType::Pause) {
      return $this->errorResponse('No active pause signal for this pipeline', Response::HTTP_CONFLICT);
    }

    $payload = $this->decodeJsonRequest($request);
    $userId = $this->currentUser()->isAuthenticated() ? (int) $this->currentUser()->id() : NULL;

    try {
      $resolved = $this->interruptManager->resolveInterrupt(
        (string) ($signal->uuid() ?? ''),
        ['resumed_at' => time(), 'reason' => $payload['reason'] ?? NULL],
        $userId,
      );
      if ($resolved === NULL) {
        return $this->errorResponse('Pause signal could not be resolved', Response::HTTP_INTERNAL_SERVER_ERROR);
      }
      return $this->successResponse($resolved->toApiArray());
    }
    catch (\InvalidArgumentException $e) {
      return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT);
    }
  }

  /**
   * Creates and persists an inward Cancel/Pause interrupt.
   *
   * Access is enforced by the route-level `_custom_access` callback before
   * this method runs.
   */
  private function createInwardSignal(
    string $pipeline_id,
    Request $request,
    InterruptType $type,
  ): JsonResponse {
    $pipeline = $this->loadPipeline($pipeline_id);
    if ($pipeline === NULL) {
      return $this->errorResponse('Pipeline not found', Response::HTTP_NOT_FOUND);
    }

    // Reject signals against terminal pipelines (completed / failed /
    // cancelled). The orchestrator won't poll them again, so any signal
    // would just sit pending forever.
    if ($this->isTerminal($pipeline)) {
      return $this->errorResponse(
        sprintf('Pipeline is in a terminal state (%s); cannot signal.', $pipeline->getStatus()),
        Response::HTTP_CONFLICT,
      );
    }

    // Reject duplicate inward signals on the same pipeline so a frantic
    // operator clicking Cancel twice doesn't create two signals.
    if ($this->interruptManager->findPendingInwardSignal($pipeline_id) !== NULL) {
      return $this->errorResponse(
        'An inward signal is already pending for this pipeline',
        Response::HTTP_CONFLICT,
      );
    }

    $payload = $this->decodeJsonRequest($request);
    $reason = isset($payload['reason']) ? (string) $payload['reason'] : NULL;
    $currentUser = $this->currentUser();
    $initiatorUid = $currentUser->isAuthenticated() ? (int) $currentUser->id() : NULL;

    $workflow = $pipeline->getWorkflow();
    $workflowId = $workflow !== NULL ? (string) ($workflow->id() ?? '') : '';

    $interrupt = $this->messageBus->dispatch(
      new InwardSignalMessage(
        type: $type,
        message: $this->buildMessage($type, $reason),
        targetPipelineId: $pipeline_id,
        initiatorUid: $initiatorUid,
        reason: $reason,
      ),
      // Inward signals have no originating node — use the type as a marker.
      new ExecutionStamp(
        workflowId: $workflowId,
        nodeId: 'signal:' . $type->value,
      ),
    );

    return $this->successResponse($interrupt->toApiArray(), Response::HTTP_ACCEPTED);
  }

  /**
   * Route access callback: cancel.
   *
   * Drupal calls this from `_custom_access` on the cancel route so all
   * access logic for pipeline signals lives at the routing layer (the
   * controller actions never re-check). Owner of the pipeline is always
   * allowed; otherwise the `cancel any flowdrop workflow` or admin
   * permissions grant.
   */
  public function accessCancel(AccountInterface $account, string $pipeline_id): AccessResult {
    return $this->buildSignalAccess($account, $pipeline_id, 'cancel any flowdrop workflow');
  }

  /**
   * Route access callback: pause.
   */
  public function accessPause(AccountInterface $account, string $pipeline_id): AccessResult {
    return $this->buildSignalAccess($account, $pipeline_id, 'pause any flowdrop workflow');
  }

  /**
   * Route access callback: resume.
   *
   * Resume is gated on the same permission as pause — resuming a paused
   * pipeline is the same operational right as pausing one.
   */
  public function accessResume(AccountInterface $account, string $pipeline_id): AccessResult {
    return $this->buildSignalAccess($account, $pipeline_id, 'pause any flowdrop workflow');
  }

  /**
   * Shared access logic for the three signal routes.
   */
  private function buildSignalAccess(AccountInterface $account, string $pipeline_id, string $permission): AccessResult {
    $pipeline = $this->loadPipeline($pipeline_id);
    if ($pipeline === NULL) {
      // Returning neutral with a cacheable dependency on the route lets
      // the access result invalidate when the pipeline is created.
      return AccessResult::neutral()->addCacheContexts(['url.path']);
    }
    // Owner-of-pipeline always grants.
    if (method_exists($pipeline, 'getOwnerId')) {
      $ownerId = $pipeline->getOwnerId();
      if ($ownerId !== NULL && (int) $account->id() === (int) $ownerId) {
        return AccessResult::allowed()->cachePerUser()->addCacheableDependency($pipeline);
      }
    }
    // Permission fallbacks.
    $allowed = $account->hasPermission($permission)
      || $account->hasPermission('administer flowdrop interrupts')
      || $account->hasPermission('administer flowdrop');
    return AccessResult::allowedIf($allowed)
      ->cachePerPermissions()
      ->addCacheableDependency($pipeline);
  }

  /**
   * Returns TRUE when the pipeline is in a terminal state.
   *
   * A pipeline can no longer accept a Cancel/Pause/Resume signal in this
   * state. Drupal's pipeline status enum doesn't itself expose a "terminal"
   * check, so we enumerate the three end states here. Keep this in sync if
   * new end-states are added.
   */
  private function isTerminal(FlowDropPipelineInterface $pipeline): bool {
    return $pipeline->isCompleted() || $pipeline->isFailed() || $pipeline->isCancelled();
  }

  /**
   * Loads a pipeline by ID or UUID.
   */
  private function loadPipeline(string $pipeline_id): ?FlowDropPipelineInterface {
    $storage = $this->entityTypeManager()->getStorage('flowdrop_pipeline');
    $pipeline = $storage->load($pipeline_id);
    if ($pipeline instanceof FlowDropPipelineInterface) {
      return $pipeline;
    }
    $entities = $storage->loadByProperties(['uuid' => $pipeline_id]);
    $pipeline = reset($entities) ?: NULL;
    return $pipeline instanceof FlowDropPipelineInterface ? $pipeline : NULL;
  }

  /**
   * Builds a human-readable message stored on the signal entity.
   */
  private function buildMessage(InterruptType $type, ?string $reason): string {
    $base = $type === InterruptType::Cancel
      ? 'Cancel signal raised'
      : 'Pause signal raised';
    if ($reason !== NULL && $reason !== '') {
      $base .= ': ' . $reason;
    }
    return $base;
  }

}
