<?php

declare(strict_types=1);

namespace Drupal\conductor;

use Drupal\conductor\Exception\ConductorCredentialsNotFoundException;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactory;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\key\KeyRepositoryInterface;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * Defines an HTTP API client for interacting with the Conductor API.
 */
final class ConductorHttpApiClient {

  public const string CACHE_KEY = 'conductor_api';

  public function __construct(
    private readonly Client $httpClient,
    private readonly HttpFoundationFactory $httpFoundationFactory,
    private readonly ConfigFactory $configFactory,
    private readonly LoggerInterface $logger,
    private readonly AccountProxyInterface $currentUser,
    private readonly CacheBackendInterface $cache,
    private readonly KeyRepositoryInterface $keyRepository,
    private readonly string $baseUrl,
  ) {}

  /**
   * Forward the request to Conductor API and returns its response.
   *
   * @param string $resource
   *   The uri to forward to Conductor API.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object from Drupal.
   *
   * @return \Symfony\Component\HttpFoundation\Response
   *   The response from Conductor API (Response or StreamedResponse).
   */
  public function forward(string $resource, Request $request): Response {
    $method = $request->getMethod();

    $headers = array_map(function ($values) {
      return $values;
    }, $request->headers->all());
    unset($headers['host']);

    // DDEV doesn't support brotli, which CloudFlare can response with when
    // there's an error.
    if (!function_exists('brotli_uncompress') && !empty($headers['accept-encoding'])) {
      $headers['accept-encoding'] = array_map(function ($value) {
        if (!empty($value)) {
          return preg_replace('/,?\s*\bbr\b\s*,?/', '', trim($value));
        }
        return $value;
      }, $headers['accept-encoding']);

      $headers['accept-encoding'] = array_filter($headers['accept-encoding']);

      if (empty($headers['accept-encoding'])) {
        unset($headers['accept-encoding']);
      }
    }

    $queryParams = $request->query->all();
    unset($queryParams['resource']);

    try {
      $credentials = $this->getCredentials();
      $options = [
        'headers' => $headers,
        'query' => [
          'apiKey' => $credentials['api_key'],
          'sig' => $this->constructSignature(),
          ...$queryParams,
        ],
        'timeout' => 30,
        'connect_timeout' => 30,
        'http_errors' => FALSE,
      ];

      // Add body for non-GET/HEAD requests.
      if (!in_array($method, ['GET', 'HEAD'], TRUE)) {
        $options['body'] = $request->getContent();
      }

      // Check if this is a streaming request
      $acceptHeader = $request->headers->get('Accept', '');
      $isStream = is_string($acceptHeader) && str_contains($acceptHeader, 'application/x-ndjson');

      if ($isStream) {
        $options['stream'] = TRUE;
        $options['decode_content'] = FALSE;
        unset($options['headers']['accept-encoding']);
        return $this->forwardAsStream($method, $this->getUri($resource), $options);
      }

      $psr7Response = $this->httpClient->request($method, $this->getUri($resource), $options);
      $response = $this->httpFoundationFactory->createResponse($psr7Response);
      return $response;
    }
    catch (ConductorCredentialsNotFoundException $e) {
      $this->logger->error($e->getMessage());
      return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNAUTHORIZED);
    }
    catch (\Exception $e) {
      $this->logger->error($e->getMessage());
      return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Forward the request as a streaming response.
   *
   * @param string $method
   *   *   The HTTP method.
   * @param string $uri
   *   The full URI to request.
   * @param array<mixed> $options
   *   The Guzzle request options.
   *
   * @return \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
   *   A streamed response that proxies NDJSON from Conductor API.
   */
  private function forwardAsStream(string $method, string $uri, array $options): Response {
    $httpClient = $this->httpClient;

    try {
      $psr7Response = $httpClient->request($method, $uri, $options);
      $statusCode = $psr7Response->getStatusCode();

      if ($statusCode >= 400) {
        $errorBody = (string) $psr7Response->getBody();
        $this->logger->error('Stream request failed with status ' . $statusCode . ': ' . $errorBody);

        return new JsonResponse([
          'error' => 'Request failed',
          'status' => $statusCode,
          'message' => $errorBody,
        ], $statusCode);
      }

      // Success - stream the response
      return new StreamedResponse(function () use ($psr7Response) {
        $body = $psr7Response->getBody();

        // Disable output buffering
        @ini_set('zlib.output_compression', '0');
        @ini_set('output_buffering', 'off');
        @ini_set('implicit_flush', '1');

        while (ob_get_level() > 0) {
          @ob_end_clean();
        }

        ignore_user_abort(FALSE);
        set_time_limit(0);

        // Read line by line
        while (!$body->eof()) {
          $line = '';

          // Read until we hit a newline
          while (($char = $body->read(1)) !== '') {
            $line .= $char;

            if ($char === "\n") {
              break;
            }
          }

          // Output the complete line immediately
          if ($line !== '') {
            echo $line;

            if (ob_get_level()) {
              @ob_flush();
            }
            flush();
          }
        }

      }, $psr7Response->getStatusCode(), [
        'Content-Type' => $psr7Response->getHeaderLine('Content-Type') ?: 'application/x-ndjson; charset=utf-8',
        'Cache-Control' => 'no-cache, no-transform',
        'Connection' => 'keep-alive',
        'X-Accel-Buffering' => 'no',
        'Transfer-Encoding' => 'chunked',
      ]);

    }
    catch (\Exception $e) {
      $this->logger->error('Stream error: ' . $e->getMessage());
      return new JsonResponse([
        'error' => $e->getMessage(),
      ], Response::HTTP_INTERNAL_SERVER_ERROR);
    }
  }

  /**
   * Obtains from the conductor API the account id.
   *
   * @return string
   *   The account id from Conductor API.
   *
   * @throws \GuzzleHttp\Exception\GuzzleException
   * @throws \Drupal\conductor\Exception\ConductorCredentialsNotFoundException
   * @throws \Exception
   */
  public function getAccountId(): string {
    $cid = \sprintf('%s:%s:user:%s', self::CACHE_KEY, 'account_id', $this->currentUser->id());
    $cached = $this->cache->get($cid);
    if ($cached && $cached->data) {
      return $cached->data;
    }

    $credentials = $this->getCredentials();

    $response = $this->httpClient->request('GET', $this->getUri('v3/accounts'), [
      'query' => [
        'apiKey' => $credentials['api_key'],
        'sig' => $this->constructSignature(),
      ],
      'http_errors' => FALSE,
    ]);
    if ($response->getStatusCode() >= 400) {
      throw new ConductorCredentialsNotFoundException('No conductor account ID found.');
    }
    $accounts = json_decode((string) $response->getBody(), TRUE);
    if (empty($accounts[0]['accountId'])) {
      throw new ConductorCredentialsNotFoundException('No conductor account ID found.');
    }

    $this->cache->set($cid, $accounts[0]['accountId'], tags: [
      self::CACHE_KEY . ":account_id",
      "user:" . $this->currentUser->id(),
    ]);

    return $accounts[0]['accountId'];
  }

  /**
   * Gets the API credentials.
   *
   * @return array
   *   Array containing 'api_key' and 'shared_secret', or empty array if not available.
   *
   * @throws \Drupal\conductor\Exception\ConductorCredentialsNotFoundException
   */
  private function getCredentials(): array {
    $config = $this->configFactory->get('conductor.settings');
    $keyId = $config->get('key_id');
    if (empty($keyId)) {
      throw new ConductorCredentialsNotFoundException("Key ID not found.");
    }

    $key = $this->keyRepository->getKey($keyId);
    if (empty($key)) {
      throw new ConductorCredentialsNotFoundException('Key not found.');
    }

    try {
      $credentials = json_decode($key->getKeyValue(), TRUE, flags: JSON_THROW_ON_ERROR);
      if (empty($credentials['api_key'])) {
        throw new ConductorCredentialsNotFoundException('API key is empty.');
      }
      if (empty($credentials['shared_secret'])) {
        throw new ConductorCredentialsNotFoundException('Shared secret is empty.');
      }
      return [
        'api_key' => $credentials['api_key'],
        'shared_secret' => $credentials['shared_secret'],
      ];
    }
    catch (\Exception) {
      throw new ConductorCredentialsNotFoundException('API credentials are not configured or unavailable.');
    }
  }

  /**
   * Construct signature for API requests to Conductor.
   *
   * @return string
   *   A signature for API requests to Conductor.
   *
   * @throws \Drupal\conductor\Exception\ConductorCredentialsNotFoundException
   */
  private function constructSignature(): string {
    $credentials = $this->getCredentials();
    $api_key = $credentials['api_key'];
    $shared_secret = $credentials['shared_secret'];

    return md5($api_key . $shared_secret . time());
  }

  /**
   * Attach the base Conductor API URL to a path.
   *
   * @param string $resource
   *   The requested resource.
   *
   * @return string
   *   A full URL.
   */
  private function getUri(string $resource): string {
    return rtrim($this->baseUrl, '/') . '/' . ltrim($resource, '/');
  }

  /**
   * Gets an array of credentials that can be added to a query object.
   *
   * @return array
   *
   * @throws \Drupal\conductor\Exception\ConductorCredentialsNotFoundException
   */
  private function getCredentialsQuery(): array {
    $credentials = $this->getCredentials();
    return [
      'apiKey' => $credentials['api_key'],
      'sig' => $this->constructSignature(),
    ];
  }

  /**
   * Returns the list of existing drafts from Conductor.
   *
   * @return array<int, array<string, mixed>>
   *   A list of drafts as associative arrays: id, draftTitle, status, createdAt, updatedAt.
   */
  public function getExistingDrafts(): array {
    try {
      $accountId = $this->getAccountId();
      $response = $this->httpClient->request('GET', $this->getUri("v3/accounts/$accountId/drafts/writing-assistant"), [
        'query' => $this->getCredentialsQuery(),
      ]);
      $data = json_decode((string) $response->getBody(), TRUE);
      return is_array($data) ? $data : [];
    }
    catch (\Exception $e) {
      $this->logger->error('Failed to retrieve drafts from Conductor: @message', ['@message' => $e->getMessage()]);
      return [];
    }
  }

  /**
   * Deletes a writing-assistant draft by UUID in Conductor.
   *
   * @return bool
   *   Whether the Draft was deleted from Conductor or not.
   */
  public function deleteDraft(string $draftId): bool {
    try {
      $accountId = $this->getAccountId();
      $response = $this->httpClient->request('DELETE', $this->getUri("v3/accounts/$accountId/drafts/$draftId/writing-assistant"), [
        'query' => $this->getCredentialsQuery(),
      ]);
      $code = $response->getStatusCode();
      if ($code >= 200 && $code < 300) {
        return TRUE;
      }
      $this->logger->info('Failed to delete draft @id: → @code, @message', [
        '@id' => $draftId,
        '@code' => (string) $code,
        '@message' => (string) $response->getBody(),
      ]);
      return FALSE;

    }
    catch (\Throwable $e) {
      $this->logger->error('Failed to delete draft @id: @message', ['@id' => $draftId, '@message' => $e->getMessage()]);
      return FALSE;
    }
  }

}
