<?php

namespace Drupal\nodehive_apidoc\Controller;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Html;
use Drupal\Core\Database\Connection;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use GuzzleHttp\ClientInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;

/**
 * Controller for the NodeHive API explorer.
 */
class ApiDocController extends ControllerBase {

  /**
   * Request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected RequestStack $requestStack;

  /**
   * Module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected ModuleHandlerInterface $nodehiveModuleHandler;

  /**
   * Entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected EntityTypeManagerInterface $nodehiveEntityTypeManager;

  /**
   * Database connection.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected Connection $database;

  /**
   * HTTP client.
   *
   * @var \GuzzleHttp\ClientInterface
   */
  protected ClientInterface $httpClient;

  /**
   * Private tempstore factory.
   *
   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
   */
  protected PrivateTempStoreFactory $privateTempStoreFactory;

  /**
   * The time service.
   *
   * @var \Drupal\Component\Datetime\TimeInterface
   */
  protected TimeInterface $time;

  /**
   * Constructs a new ApiDocController.
   *
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   Request stack.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   Module handler.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   Entity type manager.
   * @param \Drupal\Core\Database\Connection $database
   *   Database connection.
   * @param \GuzzleHttp\ClientInterface $http_client
   *   HTTP client.
   * @param \Drupal\Core\TempStore\PrivateTempStoreFactory $private_tempstore_factory
   *   Private tempstore factory.
   * @param \Drupal\Component\Datetime\TimeInterface $time
   *   The time service.
   */
  public function __construct(
    RequestStack $request_stack,
    ModuleHandlerInterface $module_handler,
    EntityTypeManagerInterface $entity_type_manager,
    Connection $database,
    ClientInterface $http_client,
    PrivateTempStoreFactory $private_tempstore_factory,
    TimeInterface $time,
  ) {
    $this->requestStack = $request_stack;
    $this->nodehiveModuleHandler = $module_handler;
    $this->nodehiveEntityTypeManager = $entity_type_manager;
    $this->database = $database;
    $this->httpClient = $http_client;
    $this->privateTempStoreFactory = $private_tempstore_factory;
    $this->time = $time;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('request_stack'),
      $container->get('module_handler'),
      $container->get('entity_type.manager'),
      $container->get('database'),
      $container->get('http_client'),
      $container->get('tempstore.private'),
      $container->get('datetime.time'),
    );
  }

  /**
   * Builds the in-app NodeHive API explorer.
   *
   * @return array
   *   The render array.
   */
  public function build(): array {
    $request = $this->requestStack->getCurrentRequest();
    $base_url = $request->getSchemeAndHttpHost();

    $build = [];
    $build['shell'] = [
      '#type' => 'container',
      '#attributes' => [
        'id' => 'nodehive-api-explorer',
        'class' => ['nh-api-explorer'],
      ],
      'loading' => [
        '#markup' => '<p class="nh-api-explorer__loading">' . $this->t('Loading NodeHive API explorer...') . '</p>',
      ],
      'noscript' => [
        '#type' => 'html_tag',
        '#tag' => 'noscript',
        '#value' => '<p>' . $this->t('This page requires JavaScript for interactive API testing.') . '</p>',
      ],
    ];

    $build['#attached']['library'][] = 'nodehive_apidoc/explorer';
    $build['#attached']['drupalSettings']['nodehiveApiExplorer'] = [
      'baseUrl' => $base_url,
      'endpointGroups' => $this->getEndpointGroups(),
      'liveEndpoints' => $this->getLiveEndpoints(),
      'paths' => [
        'preferred' => '/nodehive/api-explorer',
        'legacy' => '/nodehive/api-docs',
      ],
    ];

    return $build;
  }

  /**
   * Returns static endpoint definitions used by the explorer UI.
   *
   * @return array
   *   Endpoint groups and endpoints.
   */
  protected function getEndpointGroups(): array {
    return [
      [
        'id' => 'public',
        'title' => (string) $this->t('NodeHive Public API'),
        'description' => (string) $this->t('Read-only endpoints for menus, spaces, and published content.'),
        'endpoints' => [
          [
            'id' => 'public-index',
            'label' => 'API index',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1',
            'description' => (string) $this->t('Lists available languages, spaces, and API URLs from current configuration.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'public-menu',
            'label' => 'Menu tree',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1/menu/{menu_id}',
            'description' => (string) $this->t('Returns a structured menu tree for a menu ID.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'public-space',
            'label' => 'Space context',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1/space/{space_id}',
            'description' => (string) $this->t('Returns metadata, menus, and content types for a space.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'public-space-nodes',
            'label' => 'Space nodes',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1/space/{space_id}/nodes',
            'description' => (string) $this->t('Returns paginated nodes for a space with optional filters.'),
            'default_query' => [
              'limit' => 5,
              'page' => 0,
            ],
            'default_headers' => [],
          ],
          [
            'id' => 'public-node',
            'label' => 'Node by ID',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1/node/{node_id}',
            'description' => (string) $this->t('Returns full node details.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'public-node-uuid',
            'label' => 'Node by UUID',
            'method' => 'GET',
            'path_template' => '/nodehive-api/v1/node/uuid/{uuid}',
            'description' => (string) $this->t('Returns full node details using UUID.'),
            'default_query' => [],
            'default_headers' => [],
          ],
        ],
      ],
      [
        'id' => 'admin',
        'title' => (string) $this->t('NodeHive Admin API'),
        'description' => (string) $this->t('Tool discovery and execution endpoints for administrator workflows.'),
        'endpoints' => [
          [
            'id' => 'admin-index',
            'label' => 'Admin API index',
            'method' => 'GET',
            'path_template' => '/api/nodehive/admin/v1',
            'description' => (string) $this->t('Returns admin API metadata and endpoint list.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'admin-tools-list',
            'label' => 'List tools',
            'method' => 'GET',
            'path_template' => '/api/nodehive/admin/v1/tools',
            'description' => (string) $this->t('Returns tools accessible for the current user and role.'),
            'default_query' => [],
            'default_headers' => [],
          ],
          [
            'id' => 'admin-tools-execute',
            'label' => 'Execute tool',
            'method' => 'POST',
            'path_template' => '/api/nodehive/admin/v1/tools/execute',
            'description' => (string) $this->t('Executes a tool by ID with JSON arguments.'),
            'default_query' => [],
            'default_headers' => [
              'Content-Type' => 'application/json',
            ],
            'default_body' => [
              'tool_id' => '{tool_id}',
              'arguments' => [],
            ],
          ],
        ],
      ],
    ];
  }

  /**
   * Returns live endpoint URLs for loading current API configuration.
   *
   * @return array
   *   Live endpoint URLs.
   */
  protected function getLiveEndpoints(): array {
    return [
      'publicIndex' => $this->routeToAbsolute('nodehive_public_api.index'),
      'adminTools' => $this->routeToAbsolute('nodehive_admin_api.tools_list'),
      'authConfig' => $this->routeToAbsolute('nodehive_apidoc.auth_config'),
      'execute' => $this->routeToAbsolute('nodehive_apidoc.execute'),
    ];
  }

  /**
   * Returns current auth-related config for the API explorer.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   JSON response with API key and OAuth consumer metadata.
   */
  public function authConfig(): JsonResponse {
    $warnings = [];
    $payload = [
      'api_keys' => $this->loadApiKeyConfig($warnings),
      'oauth_consumers' => $this->loadOauthConsumerConfig($warnings),
      'warnings' => $warnings,
    ];

    $response = new JsonResponse($payload);
    $response->headers->set('Cache-Control', 'no-store, private');
    return $response;
  }

  /**
   * Executes an API request and returns an HTMX-ready response fragment.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Explorer request payload.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   HTML fragment with response metadata and JSON payload.
   */
  public function execute(Request $request): Response {
    $method = strtoupper(trim((string) $request->request->get('method', 'GET')));
    $allowed_methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
    if (!in_array($method, $allowed_methods, TRUE)) {
      return $this->buildExplorerResponse([
        'error' => (string) $this->t('Unsupported HTTP method: @method', ['@method' => $method]),
      ], TRUE);
    }

    $path = trim((string) $request->request->get('path', ''));
    if ($path === '') {
      return $this->buildExplorerResponse([
        'error' => (string) $this->t('Path is required.'),
      ], TRUE);
    }

    $query_result = $this->parseQueryParamsRaw((string) $request->request->get('query', ''));
    if (!$query_result['ok']) {
      return $this->buildExplorerResponse(['error' => $query_result['error']], TRUE);
    }

    $headers_result = $this->parseHeadersRaw((string) $request->request->get('headers', ''));
    if (!$headers_result['ok']) {
      return $this->buildExplorerResponse(['error' => $headers_result['error']], TRUE);
    }

    $body_result = $this->parseJsonBodyRaw((string) $request->request->get('body', ''), 'Body');
    if (!$body_result['ok']) {
      return $this->buildExplorerResponse(['error' => $body_result['error']], TRUE);
    }

    $auth_result = $this->buildAuthHeadersFromRequest($request);
    if (!$auth_result['ok']) {
      return $this->buildExplorerResponse(['error' => $auth_result['error']], TRUE);
    }

    $headers = $headers_result['value'];
    foreach ($auth_result['headers'] as $name => $value) {
      $headers[$name] = $value;
    }

    $body_text = NULL;
    if (!in_array($method, ['GET', 'HEAD'], TRUE) && $body_result['value'] !== NULL) {
      $body_text = json_encode($body_result['value'], JSON_UNESCAPED_SLASHES);
      if ($body_text === FALSE) {
        return $this->buildExplorerResponse([
          'error' => (string) $this->t('Unable to encode JSON request body.'),
        ], TRUE);
      }
      if (!isset($headers['Content-Type'])) {
        $headers['Content-Type'] = 'application/json';
      }
    }

    try {
      $url = $this->resolveAbsoluteUrl($request, $path);
      $url = $this->addQueryParamsToUrl($url, $query_result['value']);
    }
    catch (\Throwable $e) {
      return $this->buildExplorerResponse([
        'error' => $e->getMessage(),
      ], TRUE);
    }

    $request_payload = [
      'method' => $method,
      'url' => $url,
      'headers' => $headers,
      'body' => $body_text,
      'auth' => $auth_result['label'],
    ];

    $options = [
      'headers' => $headers,
      'http_errors' => FALSE,
      'allow_redirects' => FALSE,
      'verify' => FALSE,
    ];
    if ($body_text !== NULL) {
      $options['body'] = $body_text;
    }

    $started_at = microtime(TRUE);

    try {
      $upstream_response = $this->httpClient->request($method, $url, $options);
      $duration_ms = (int) round((microtime(TRUE) - $started_at) * 1000);
      $raw_body = (string) $upstream_response->getBody();

      $parsed_body = json_decode($raw_body, TRUE);
      if (json_last_error() !== JSON_ERROR_NONE) {
        $parsed_body = $raw_body;
      }

      $status_code = $upstream_response->getStatusCode();
      $payload = [
        'request' => $request_payload,
        'response' => [
          'status' => $status_code,
          'statusText' => $upstream_response->getReasonPhrase(),
          'duration_ms' => $duration_ms,
        ],
        'data' => $parsed_body,
      ];

      return $this->buildExplorerResponse($payload, $status_code >= 400);
    }
    catch (\Throwable $e) {
      $duration_ms = (int) round((microtime(TRUE) - $started_at) * 1000);

      return $this->buildExplorerResponse([
        'request' => $request_payload,
        'response' => [
          'duration_ms' => $duration_ms,
        ],
        'error' => $e->getMessage(),
      ], TRUE);
    }
  }

  /**
   * Builds the HTML fragment swapped into the response panel via HTMX.
   *
   * @param array $payload
   *   Response payload.
   * @param bool $is_error
   *   Whether this result should be styled as an error.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   HTML fragment response.
   */
  protected function buildExplorerResponse(array $payload, bool $is_error = FALSE): Response {
    $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    if ($json === FALSE) {
      $json = "{\n  \"error\": \"Unable to render response payload.\"\n}";
      $is_error = TRUE;
    }

    $meta = [];
    if (!empty($payload['response']['status'])) {
      $meta[] = $payload['response']['status'] . ' ' . ($payload['response']['statusText'] ?? '');
    }
    if (isset($payload['response']['duration_ms'])) {
      $meta[] = $payload['response']['duration_ms'] . ' ms';
    }

    $html = '';
    if (!empty($meta)) {
      $html .= '<div class="nh-api-response-meta' . ($is_error ? ' is-error' : '') . '">' . Html::escape(trim(implode(' | ', $meta))) . '</div>';
    }

    $html .= '<pre class="nh-api-response' . ($is_error ? ' is-error' : '') . '">' . Html::escape($json) . '</pre>';

    $response = new Response($html, 200);
    $response->headers->set('Content-Type', 'text/html; charset=UTF-8');
    $response->headers->set('Cache-Control', 'no-store, private');
    return $response;
  }

  /**
   * Parses query input from JSON or loose key/value syntax.
   *
   * @param string $raw
   *   Raw query input.
   *
   * @return array
   *   Parse result: ok, value, error.
   */
  protected function parseQueryParamsRaw(string $raw): array {
    $input = trim($raw);
    if ($input === '') {
      return ['ok' => TRUE, 'value' => []];
    }

    $decoded_json = json_decode($input, TRUE);
    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_json) && $this->isAssociativeArray($decoded_json)) {
      return ['ok' => TRUE, 'value' => $decoded_json];
    }

    $normalized = ltrim($input, '?');
    $segments = preg_split('/[&\n,]+/', $normalized) ?: [];
    $query = [];

    foreach ($segments as $segment) {
      $segment = trim($segment);
      if ($segment === '') {
        continue;
      }

      if (str_starts_with($segment, '[') && str_ends_with($segment, ']')) {
        $segment = trim(substr($segment, 1, -1));
      }

      $separator = strpos($segment, '=');
      if ($separator === FALSE) {
        $separator = strpos($segment, ':');
      }

      if ($separator === FALSE) {
        $bare_key = $this->normalizeLooseToken(urldecode($segment));
        if ($bare_key !== '' && $bare_key !== '[]') {
          $query[$bare_key] = '1';
        }
        continue;
      }

      $key = $this->normalizeLooseToken(urldecode(substr($segment, 0, (int) $separator)));
      $value = urldecode(substr($segment, (int) $separator + 1));
      if ($key === '' || $key === '[]') {
        continue;
      }

      if (array_key_exists($key, $query)) {
        if (!is_array($query[$key])) {
          $query[$key] = [$query[$key]];
        }
        $query[$key][] = $value;
      }
      else {
        $query[$key] = $value;
      }
    }

    return ['ok' => TRUE, 'value' => $query];
  }

  /**
   * Parses headers from JSON, loose header lines, or raw API key input.
   *
   * @param string $raw
   *   Raw header input.
   *
   * @return array
   *   Parse result: ok, value, error.
   */
  protected function parseHeadersRaw(string $raw): array {
    $input = trim($raw);
    if ($input === '') {
      return ['ok' => TRUE, 'value' => []];
    }

    $decoded_json = json_decode($input, TRUE);
    if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_json) && $this->isAssociativeArray($decoded_json)) {
      $headers = [];
      foreach ($decoded_json as $name => $value) {
        $header_name = trim((string) $name);
        if ($header_name === '') {
          continue;
        }
        if (!$this->isValidHeaderName($header_name)) {
          return [
            'ok' => FALSE,
            'error' => (string) $this->t('Invalid header name: @name', ['@name' => $header_name]),
          ];
        }
        $headers[$header_name] = is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_SLASHES);
      }
      return ['ok' => TRUE, 'value' => $headers];
    }

    $headers = [];
    $lines = preg_split('/\n+/', $input) ?: [];

    foreach ($lines as $line) {
      $line = trim($line);
      if ($line === '') {
        continue;
      }

      if (str_starts_with($line, '[') && str_ends_with($line, ']')) {
        $line = trim(substr($line, 1, -1));
      }

      if (preg_match('/^nhk_[A-Za-z0-9]+$/i', $line)) {
        $headers['Authorization'] = 'Bearer ' . $line;
        continue;
      }

      $separator = strpos($line, ':');
      if ($separator === FALSE) {
        $separator = strpos($line, '=');
      }
      if ($separator === FALSE) {
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('Headers must be JSON, Header: value lines, or a raw nhk_ API key.'),
        ];
      }

      $name = $this->normalizeLooseToken(substr($line, 0, (int) $separator));
      $value = $this->normalizeLooseToken(substr($line, (int) $separator + 1));
      if ($name === '') {
        continue;
      }

      $lower_name = strtolower($name);
      if (in_array($lower_name, ['key', 'api_key', 'apikey', 'token', 'bearer'], TRUE)) {
        $headers['Authorization'] = preg_match('/^bearer\s+/i', $value) ? $value : ('Bearer ' . $value);
        continue;
      }
      if ($lower_name === 'authorization' && preg_match('/^nhk_/i', $value)) {
        $headers['Authorization'] = 'Bearer ' . $value;
        continue;
      }
      if ($lower_name === 'x_api_key') {
        $headers['X-API-Key'] = $value;
        continue;
      }
      if (!$this->isValidHeaderName($name)) {
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('Invalid header name: @name', ['@name' => $name]),
        ];
      }

      $headers[$name] = $value;
    }

    return ['ok' => TRUE, 'value' => $headers];
  }

  /**
   * Parses request body JSON input.
   *
   * @param string $raw
   *   Raw body input.
   * @param string $label
   *   Context label for errors.
   *
   * @return array
   *   Parse result: ok, value, error.
   */
  protected function parseJsonBodyRaw(string $raw, string $label): array {
    $input = trim($raw);
    if ($input === '') {
      return ['ok' => TRUE, 'value' => NULL];
    }

    $decoded = json_decode($input, TRUE);
    if (json_last_error() !== JSON_ERROR_NONE) {
      return [
        'ok' => FALSE,
        'error' => (string) $this->t('Invalid JSON in @label.', ['@label' => $label]),
      ];
    }

    return ['ok' => TRUE, 'value' => $decoded];
  }

  /**
   * Builds auth headers from explorer auth controls.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Incoming explorer request.
   *
   * @return array
   *   Parse result: ok, headers, label, error.
   */
  protected function buildAuthHeadersFromRequest(Request $request): array {
    $mode = trim((string) $request->request->get('auth_mode', 'none'));

    if ($mode === '' || $mode === 'none') {
      return [
        'ok' => TRUE,
        'headers' => [],
        'label' => 'No auth',
      ];
    }

    if ($mode === 'api_key') {
      $api_key = $this->normalizeApiKeyValue((string) $request->request->get('auth_api_key', ''));
      if ($api_key === '') {
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('API key is empty.'),
        ];
      }
      return [
        'ok' => TRUE,
        'headers' => [
          'Authorization' => 'Bearer ' . $api_key,
        ],
        'label' => 'API key',
      ];
    }

    if ($mode === 'oauth_client_credentials') {
      $token_url = trim((string) $request->request->get('auth_token_url', '/oauth/token'));
      $client_id = trim((string) $request->request->get('auth_client_id', ''));
      $client_secret = trim((string) $request->request->get('auth_client_secret', ''));
      $scope = trim((string) $request->request->get('auth_scope', ''));

      if ($client_id === '' || $client_secret === '') {
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('OAuth client_id and client_secret are required.'),
        ];
      }

      $token_result = $this->requestOauthToken($request, $token_url, $client_id, $client_secret, $scope);
      if (!$token_result['ok']) {
        return [
          'ok' => FALSE,
          'error' => $token_result['error'],
        ];
      }

      return [
        'ok' => TRUE,
        'headers' => [
          'Authorization' => 'Bearer ' . $token_result['access_token'],
        ],
        'label' => 'OAuth client_credentials',
      ];
    }

    return [
      'ok' => FALSE,
      'error' => (string) $this->t('Unsupported auth mode: @mode', ['@mode' => $mode]),
    ];
  }

  /**
   * Performs an OAuth client_credentials token request.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Incoming explorer request.
   * @param string $token_url
   *   Token endpoint URL.
   * @param string $client_id
   *   OAuth client ID.
   * @param string $client_secret
   *   OAuth client secret.
   * @param string $scope
   *   Optional scope.
   *
   * @return array
   *   Token result payload.
   */
  protected function requestOauthToken(
    Request $request,
    string $token_url,
    string $client_id,
    string $client_secret,
    string $scope = '',
  ): array {
    try {
      $absolute_token_url = $this->resolveAbsoluteUrl($request, $token_url);
      $form_params = [
        'grant_type' => 'client_credentials',
        'client_id' => $client_id,
        'client_secret' => $client_secret,
      ];
      if ($scope !== '') {
        $form_params['scope'] = $scope;
      }

      $response = $this->httpClient->request('POST', $absolute_token_url, [
        'headers' => [
          'Content-Type' => 'application/x-www-form-urlencoded',
          'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret),
        ],
        'form_params' => $form_params,
        'http_errors' => FALSE,
        'allow_redirects' => FALSE,
        'verify' => FALSE,
      ]);

      $status = $response->getStatusCode();
      $raw = (string) $response->getBody();
      $decoded = json_decode($raw, TRUE);

      if ($status >= 400) {
        $message = 'HTTP ' . $status;
        if (is_array($decoded)) {
          if (!empty($decoded['error_description'])) {
            $message = (string) $decoded['error_description'];
          }
          elseif (!empty($decoded['error'])) {
            $message = (string) $decoded['error'];
          }
        }
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('OAuth token request failed: @message', ['@message' => $message]),
        ];
      }

      if (!is_array($decoded) || empty($decoded['access_token'])) {
        return [
          'ok' => FALSE,
          'error' => (string) $this->t('OAuth token response does not contain access_token.'),
        ];
      }

      return [
        'ok' => TRUE,
        'access_token' => (string) $decoded['access_token'],
      ];
    }
    catch (\Throwable $e) {
      return [
        'ok' => FALSE,
        'error' => (string) $this->t('OAuth token request failed: @message', ['@message' => $e->getMessage()]),
      ];
    }
  }

  /**
   * Resolves a path to an absolute URL for this host.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Current request.
   * @param string $path
   *   Relative or absolute path.
   *
   * @return string
   *   Absolute URL.
   */
  protected function resolveAbsoluteUrl(Request $request, string $path): string {
    $input = trim($path);
    if ($input === '') {
      throw new \InvalidArgumentException('Path is required.');
    }

    if (preg_match('#^https?://#i', $input)) {
      return $input;
    }

    if (!str_starts_with($input, '/')) {
      $input = '/' . $input;
    }

    return rtrim($request->getSchemeAndHttpHost(), '/') . $input;
  }

  /**
   * Adds query params to URL while preserving duplicate keys.
   *
   * @param string $url
   *   Base URL.
   * @param array $query
   *   Query values.
   *
   * @return string
   *   URL with appended query string.
   */
  protected function addQueryParamsToUrl(string $url, array $query): string {
    if (empty($query)) {
      return $url;
    }

    $parts = parse_url($url);
    if ($parts === FALSE) {
      return $url;
    }

    $pairs = [];
    foreach ($query as $key => $value) {
      $encoded_key = rawurlencode((string) $key);

      if (is_array($value)) {
        if ($this->isAssociativeArray($value)) {
          $pairs[] = $encoded_key . '=' . rawurlencode((string) json_encode($value, JSON_UNESCAPED_SLASHES));
          continue;
        }
        foreach ($value as $item) {
          if ($item === NULL || $item === '') {
            continue;
          }
          $pairs[] = $encoded_key . '=' . rawurlencode((string) $item);
        }
        continue;
      }

      if (is_object($value)) {
        $pairs[] = $encoded_key . '=' . rawurlencode((string) json_encode($value, JSON_UNESCAPED_SLASHES));
        continue;
      }

      if ($value === NULL || $value === '') {
        continue;
      }

      $pairs[] = $encoded_key . '=' . rawurlencode((string) $value);
    }

    if (empty($pairs)) {
      return $url;
    }

    $existing = '';
    if (!empty($parts['query'])) {
      $existing = $parts['query'];
    }
    $query_string = $existing === '' ? implode('&', $pairs) : ($existing . '&' . implode('&', $pairs));

    $rebuilt = '';
    if (!empty($parts['scheme'])) {
      $rebuilt .= $parts['scheme'] . '://';
    }
    if (!empty($parts['user'])) {
      $rebuilt .= $parts['user'];
      if (!empty($parts['pass'])) {
        $rebuilt .= ':' . $parts['pass'];
      }
      $rebuilt .= '@';
    }
    if (!empty($parts['host'])) {
      $rebuilt .= $parts['host'];
    }
    if (!empty($parts['port'])) {
      $rebuilt .= ':' . $parts['port'];
    }
    $rebuilt .= $parts['path'] ?? '';
    $rebuilt .= '?' . $query_string;
    if (!empty($parts['fragment'])) {
      $rebuilt .= '#' . $parts['fragment'];
    }

    return $rebuilt;
  }

  /**
   * Normalizes loose token input by removing wrapper punctuation.
   *
   * @param string $token
   *   Raw token.
   *
   * @return string
   *   Clean token value.
   */
  protected function normalizeLooseToken(string $token): string {
    return trim(preg_replace('/^[\[\(\{\'"`]+|[\]\)\}\'"`]+$/', '', trim($token)) ?? '');
  }

  /**
   * Normalizes API key input.
   *
   * @param string $api_key
   *   Raw key value.
   *
   * @return string
   *   API key without "Bearer " prefix.
   */
  protected function normalizeApiKeyValue(string $api_key): string {
    $cleaned = $this->normalizeLooseToken($api_key);
    if (preg_match('/^bearer\s+/i', $cleaned)) {
      return trim((string) preg_replace('/^bearer\s+/i', '', $cleaned));
    }
    return $cleaned;
  }

  /**
   * Checks whether an array has non-sequential keys.
   *
   * @param array $value
   *   Array value.
   *
   * @return bool
   *   TRUE when array is associative.
   */
  protected function isAssociativeArray(array $value): bool {
    if ($value === []) {
      return TRUE;
    }
    return array_keys($value) !== range(0, count($value) - 1);
  }

  /**
   * Validates HTTP header names.
   *
   * @param string $name
   *   Header name.
   *
   * @return bool
   *   TRUE when valid.
   */
  protected function isValidHeaderName(string $name): bool {
    return (bool) preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name);
  }

  /**
   * Loads API key metadata for explorer auto-fill.
   *
   * Full key values are intentionally not persisted and therefore unavailable
   * except for one-time tempstore values immediately after creation.
   *
   * @param array $warnings
   *   Collector for non-fatal warnings.
   *
   * @return array
   *   API key metadata.
   */
  protected function loadApiKeyConfig(array &$warnings): array {
    $items = [];

    if (!$this->nodehiveModuleHandler->moduleExists('nodehive_api_key_management')) {
      return $items;
    }

    if (
      !$this->currentUser()->hasPermission('view api keys')
      && !$this->currentUser()->hasPermission('administer api keys')
    ) {
      $warnings[] = (string) $this->t('Missing permission to view API keys.');
      return $items;
    }

    if (!$this->database->schema()->tableExists('nodehive_api_keys')) {
      $warnings[] = (string) $this->t('API key storage table not found.');
      return $items;
    }

    $records = $this->database
      ->select('nodehive_api_keys', 'k')
      ->fields('k', ['id', 'name', 'key_prefix', 'uid', 'status', 'changed'])
      ->condition('status', 1)
      ->orderBy('changed', 'DESC')
      ->range(0, 50)
      ->execute()
      ->fetchAll();

    foreach ($records as $record) {
      $items[] = [
        'id' => (int) $record->id,
        'name' => (string) $record->name,
        'prefix' => (string) $record->key_prefix,
        'uid' => (int) $record->uid,
        'status' => (int) $record->status,
        'changed' => (int) $record->changed,
      ];
    }

    // One-time key values are available directly after creation.
    try {
      $tempstore = $this->privateTempStoreFactory->get('nodehive_api_key_management');
      $new_key = $tempstore->get('new_key');
      $new_key_id = $tempstore->get('new_key_id');

      if (is_string($new_key) && str_starts_with($new_key, 'nhk_')) {
        $matched = FALSE;
        foreach ($items as &$item) {
          if (!empty($new_key_id) && (int) $item['id'] === (int) $new_key_id) {
            $item['one_time_value'] = $new_key;
            $matched = TRUE;
            break;
          }
        }
        unset($item);

        if (!$matched) {
          array_unshift($items, [
            'id' => !empty($new_key_id) ? (int) $new_key_id : 0,
            'name' => (string) $this->t('Recently created API key'),
            'prefix' => substr($new_key, 0, 10),
            'uid' => (int) $this->currentUser()->id(),
            'status' => 1,
            'changed' => $this->time->getRequestTime(),
            'one_time_value' => $new_key,
          ]);
        }
      }
    }
    catch (\Exception $e) {
      $warnings[] = (string) $this->t('Unable to read one-time API key values.');
    }

    return $items;
  }

  /**
   * Loads OAuth consumer metadata for explorer auto-fill.
   *
   * @param array $warnings
   *   Collector for non-fatal warnings.
   *
   * @return array
   *   OAuth consumer metadata.
   */
  protected function loadOauthConsumerConfig(array &$warnings): array {
    $items = [];

    if (!$this->nodehiveModuleHandler->moduleExists('consumers')) {
      return $items;
    }

    try {
      $consumer_storage = $this->nodehiveEntityTypeManager->getStorage('consumer');
      $consumer_ids = $consumer_storage
        ->getQuery()
        ->accessCheck(TRUE)
        ->sort('changed', 'DESC')
        ->range(0, 50)
        ->execute();

      $consumers = $consumer_storage->loadMultiple($consumer_ids);
      foreach ($consumers as $consumer) {
        $client_id = $consumer->hasField('client_id') ? (string) $consumer->get('client_id')->value : '';
        if ($client_id === '') {
          continue;
        }

        $grant_types = [];
        if ($consumer->hasField('grant_types')) {
          foreach ($consumer->get('grant_types')->getValue() as $item) {
            if (!empty($item['value'])) {
              $grant_types[] = (string) $item['value'];
            }
          }
        }

        $items[] = [
          'id' => (int) $consumer->id(),
          'label' => (string) $consumer->label(),
          'client_id' => $client_id,
          'token_url' => '/oauth/token',
          'supports_client_credentials' => in_array('client_credentials', $grant_types, TRUE),
          'is_confidential' => $consumer->hasField('confidential') ? (bool) $consumer->get('confidential')->value : NULL,
        ];
      }
    }
    catch (\Exception $e) {
      $warnings[] = (string) $this->t('Unable to load OAuth consumers.');
    }

    return $items;
  }

  /**
   * Converts a route name to an absolute URL and gracefully handles failures.
   *
   * @param string $route_name
   *   Route name.
   * @param array $parameters
   *   Route parameters.
   *
   * @return string|null
   *   Absolute URL if available.
   */
  protected function routeToAbsolute(string $route_name, array $parameters = []): ?string {
    try {
      return Url::fromRoute($route_name, $parameters, ['absolute' => TRUE])->toString();
    }
    catch (\Exception $e) {
      $this->getLogger('nodehive_apidoc')->warning('Unable to build route @route for API explorer: @message', [
        '@route' => $route_name,
        '@message' => $e->getMessage(),
      ]);
      return NULL;
    }
  }

}
