<?php

namespace Drupal\languagewire_translation_provider\Client;

use Drupal\languagewire_translation_provider\Adapter\PreviewSite\PreviewSiteBuildFactory;
use Drupal\languagewire_translation_provider\api\Core\Exception\RecoverableError;
use Drupal\languagewire_translation_provider\api\Core\Settings;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\InvoicingAccounts;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\ProjectTemplate as PlatformProjectTemplate;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\ProjectTemplates;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\TermBases;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\TranslationMemories;
use Drupal\languagewire_translation_provider\api\Endpoints\Account\Users;
use Drupal\languagewire_translation_provider\api\Endpoints\Files\AddFiles;
use Drupal\languagewire_translation_provider\api\Endpoints\Files\AddReferenceFiles;
use Drupal\languagewire_translation_provider\api\Endpoints\Files\DownloadFile;
use Drupal\languagewire_translation_provider\api\Endpoints\General\Services;
use Drupal\languagewire_translation_provider\api\Endpoints\General\WorkAreas;
use Drupal\languagewire_translation_provider\api\Endpoints\Project\CancelProject;
use Drupal\languagewire_translation_provider\api\Endpoints\Project\Project as EndpointProject;
use Drupal\languagewire_translation_provider\api\Endpoints\Project\StartProject;
use Drupal\languagewire_translation_provider\api\Endpoints\Project\StartQuoteProject;
use Drupal\languagewire_translation_provider\api\Libraries\Content\MediaType;
use Drupal\languagewire_translation_provider\api\Libraries\Content\Udf\UniversalDocumentFactory;
use Drupal\languagewire_translation_provider\api\Libraries\Content\UdfContent;
use Drupal\languagewire_translation_provider\api\Libraries\Document\PlatformDocument;
use Drupal\languagewire_translation_provider\api\Libraries\Files\ReferenceFileFactory;
use Drupal\languagewire_translation_provider\api\Managers\ProjectManager;
use Drupal\languagewire_translation_provider\Client\Project as PlatformProject;
use Drupal\languagewire_translation_provider\Domain\DocumentStatus;
use Drupal\languagewire_translation_provider\Domain\Project;
use Drupal\languagewire_translation_provider\Domain\ProjectStatus as ProjectStatusDomain;
use Drupal\languagewire_translation_provider\Domain\ProjectTemplate;
use Drupal\languagewire_translation_provider\Platform\AvailableServices;
use Drupal\languagewire_translation_provider\Platform\Document\DocumentBase;
use Drupal\languagewire_translation_provider\Platform\Document\InvalidDocument;
use Drupal\languagewire_translation_provider\Platform\Document\TranslatedDocument;
use Drupal\languagewire_translation_provider\Platform\Document\UploadableDocument;
use Drupal\languagewire_translation_provider\Platform\Document\UploadedDocument;

/**
 * LanguageWire Client.
*
 * @codeCoverageIgnore
 */
final class LanguageWireClient implements ClientInterface {
  /**
   * Max document title length.
   */
  private const DOCUMENT_TITLE_LENGTH = 100;

  /**
   * Retry timeout in seconds.
   */
  private const RETRY_TIMEOUT = 10;

  /**
   * Max retries to api.
   */
  private const MAX_RETRIES = 3;

  /**
   * Max api limit.
   */
  private const MAX_API_LIMIT = 400;

  /**
   * HTML preview MIME type.
   */
  private const HTML_PREVIEW_MIMETYPE = 'application/binary';

  /**
   * Client settings.
   *
   * @var \Drupal\languagewire_translation_provider\api\Core\Settings
   */
  private Settings $settings;

  /**
   * Project specification factory.
   *
   * @var \Drupal\languagewire_translation_provider\Client\ProjectSpecificationFactory
   *   Project specification factory.
   */
  private ProjectSpecificationFactory $specificationFactory;

  /**
   * Constructs a new LanguageWireClient object.
   *
   * @param \Drupal\languagewire_translation_provider\api\Core\Settings $settings
   *   Client settings.
   * @param \Drupal\languagewire_translation_provider\Client\ProjectSpecificationFactory $specificationFactory
   *   Project specification factory.
   */
  public function __construct(Settings $settings, ProjectSpecificationFactory $specificationFactory) {
    $this->settings = $settings;
    $this->specificationFactory = $specificationFactory;
  }

  /**
   * {@inheritDoc}
   */
  public function getProducts(): array {
    return array_map(
      function (array $product) {
        return Product::fromPlatformProduct($product);
      },
      (new Services($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   */
  public function getUsers(): array {
    return array_map(
      function (array $user) {
        return User::fromPlatformUser($user);
      },
      (new Users($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   */
  public function getWorkAreas(): array {
    return array_map(
      function (array $workArea) {
        return WorkArea::fromPlatformWorkArea($workArea);
      },
      (new WorkAreas($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   */
  public function getInvoicingAccounts(): array {
    return array_map(
      function (array $invoicingAccount) {
        return InvoicingAccount::fromPlatformInvoicingAccount($invoicingAccount);
      },
      (new InvoicingAccounts($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   */
  public function getTranslationMemories(): array {
    return array_map(
      function (array $translationMemory) {
        return TranslationMemory::fromPlatformTranslationMemory($translationMemory);
      },
      (new TranslationMemories($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   */
  public function getTermBases(): array {
    return array_map(
      function (array $termBase) {
        return TermBase::fromPlatformTermBase($termBase);
      },
      (new TermBases($this->settings))->execute() ?? []
    );
  }

  /**
   * {@inheritDoc}
   *
   * @return \Drupal\languagewire_translation_provider\Domain\ProjectTemplate[]
   *   Project templates.
   *
   * @throws \DateMalformedStringException
   */
  public function getProjectTemplates(): array {
    return array_filter(
      array_map(
        function (array $platformProjectTemplate) {
          $projectTemplate = new PlatformProjectTemplate($this->settings);
          $projectTemplate->setProjectTemplateId($platformProjectTemplate['id']);

          $attempts = 0;
          $projectTemplateData = NULL;
          while ($projectTemplateData === NULL) {
            try {
              $projectTemplateData = $projectTemplate->execute();
            }
            catch (\Exception $exception) {
              if (!$this->isRecoverableException($exception) || $attempts >= self::MAX_RETRIES) {
                break;
              }

              sleep(self::RETRY_TIMEOUT);
              $attempts++;
            }
          }

          return $projectTemplateData ? ProjectTemplate::fromPlatformProjectTemplate($projectTemplateData) : NULL;
        },
        (new ProjectTemplates($this->settings))->execute() ?? []
      )
    );
  }

  /**
   * Get limits.
   */
  public function getLimits(): ApiLimits {
    // @todo See if this is possible to retrieve from API
    return new ApiLimits(['maxDocumentsPerProject' => self::MAX_API_LIMIT]);
  }

  /**
   * {@inheritDoc}
   *
   * @throws \Drupal\languagewire_translation_provider\Client\RecoverableException
   */
  public function uploadDocuments(Project $project, array $uploadableDocuments): array {
    $uploadedDocuments = [];

    foreach ($uploadableDocuments as $uploadableDocument) {
      try {
        $addFile = new AddFiles($this->settings);

        $tmgmtJob = $project->getTmgmtJob();

        $content = $uploadableDocument->getDocument()->getTmgmtItem()->getRawData();
        $documentNameAppend = '_' . $tmgmtJob->jobId() . '_' . substr(md5(json_encode($content)), 0, 5);

        $maxTitleLength = $tmgmtJob->translator()->isHtmlPreviewEnabled() ? PreviewSiteBuildFactory::MAX_TITLE_LENGTH - strlen($documentNameAppend) : self::DOCUMENT_TITLE_LENGTH - strlen($documentNameAppend);
        $title = mb_substr(trim($uploadableDocument->getTitle()), 0, $maxTitleLength);

        if (preg_match('/[^\p{Latin}\s\p{P}\p{N}]/u', $title)) {
          $title = rtrim(base64_encode(substr($title, 0, intval(ceil($maxTitleLength * 3 / 4)))), '=');
        }

        // @todo Refactor to use uploadMany() method.
        $addFile->addFile(
          $project->getPlatformId(),
          // @todo Move this to a method
          $title . $documentNameAppend . '.' . MediaType::getExtensionFromType(MediaType::UDF),
          $tmgmtJob->sourceLanguage()->remoteLanguage(),
          $tmgmtJob->targetLanguage()->remoteLanguage(),
          json_decode($uploadableDocument->toPlatformDocument()->getContent()->getStream()->getContents(), TRUE)
        );

        $uploadedPlatformDocument = $addFile->execute() ?? throw $addFile->getError();

        $uploadedDocuments[] = new UploadedDocument($uploadableDocument->document(), $uploadedPlatformDocument['fileId']);
      }
      catch (\Exception $exception) {
        if ($this->isRecoverableException($exception)) {
          $this->updateDocuments($project, $uploadedDocuments);

          throw new RecoverableException(
            'A recoverable exception was caught during document upload.',
            $exception->getCode(), $exception
          );
        }
        $uploadedDocuments[] = InvalidDocument::fromUploadException($exception, $uploadableDocument->document());
      }
    }

    $this->updateDocuments($project, $uploadedDocuments);

    return $uploadedDocuments;
  }

  /**
   * {@inheritDoc}
   */
  public function startProject(Project $project): bool {
    $startProject = new StartProject($this->settings);
    $startProject->setProjectId($project->getPlatformId());
    $startProject->execute();

    return TRUE;
  }

  /**
   * {@inheritDoc}
   */
  public function startQuoteProject(Project $project): bool {
    $startQuoteProject = new StartQuoteProject($this->settings);
    $startQuoteProject->setProjectId($project->getPlatformId());
    $startQuoteProject->execute();

    return TRUE;
  }

  /**
   * {@inheritDoc}
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project.
   *
   * @throws \Exception
   */
  public function submit(Project $project): Project {
    try {
      if (strlen($project->getTitle()) >= 100) {
        throw new \Exception('Project title is too long');
      }

      $projectSpec = $this->specificationFactory->createSpecification(
        $project,
        new AvailableServices($this)
      );

      $projectManager = $this->projectManager();
      $projectManager->createProject($projectSpec->toSpecification($this)) ?: throw new \Exception('Unable to create project!');

      return Project::fromPlatformProject($project->getTmgmtJob(), $project->getId(), ProjectStatusDomain::inProgress(), $projectManager->getProject(), $project->getBundlingId());
    }
    catch (\Exception $exception) {
      if ($this->isRecoverableException($exception)) {
        throw new RecoverableException('A recoverable exception was caught during project creation', $exception->getCode(), $exception);
      }

      throw $exception;
    }
  }

  /**
   * {@inheritDoc}
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project.
   *
   * @return \Drupal\languagewire_translation_provider\Client\Project
   *   Platform project.
   *
   * @throws \Drupal\languagewire_translation_provider\Client\RecoverableException
   */
  public function getPlatformProject(Project $project): PlatformProject {

    try {
      $endpointProject = new EndpointProject($this->settings);
      $endpointProject->setProjectId($project->getPlatformId());

      $platformProject = $endpointProject->execute() ?? throw new RecoverableException('Unable to fetch project');

      // @todo implement this as a converter
      return new PlatformProject(
        $platformProject['id'],
        $platformProject['platformLink'],
        $platformProject['status'],
        Assignment::fromPlatformFiles($platformProject['files'] ?? [])
      );
    }
    catch (\Exception $exception) {
      if ($this->isRecoverableException($exception)) {
        throw new RecoverableException('A recoverable exception was caught on project fetching.', $exception->getCode(), $exception);
      }

      throw $exception;
    }
  }

  /**
   * {@inheritDoc}
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project.
   *
   * @return \Drupal\languagewire_translation_provider\Platform\Document\TranslatedDocument[]
   *   Translated documents.
   *
   * @throws \Exception
   */
  public function fetchTranslations(Project $project): array {

    $translatedDocuments = [];

    try {
      $platformProject = $this->getPlatformProject($project);

      $sourceDocuments = [];
      foreach ($platformProject->assignments() as $platformAssignment) {
        foreach ($platformAssignment->getPlatformDocumentIdPairs() as $sourceDocumentId => $targetDocumentId) {
          $currentDocument = $project->getDocumentByPlatformId($sourceDocumentId);

          if (in_array($sourceDocumentId, $sourceDocuments)) {
            continue;
          }

          $sourceDocuments[] = $sourceDocumentId;

          if (!$currentDocument) {
            continue;
          } elseif ($platformAssignment->status()->isCancelled()) {
            $currentDocument->setStatus(DocumentStatus::cancelled());
            $project->updateDocument($currentDocument);
            $project->getTmgmtJob()->addMessage("Document with ID $sourceDocumentId was cancelled.");

            continue;
          } elseif (!$targetDocumentId) {
            $currentDocument->setStatus(DocumentStatus::error());
            $project->updateDocument($currentDocument);
            $project->getTmgmtJob()->addMessage("Document with ID $sourceDocumentId has no translation.");

            continue;
          } elseif ($platformAssignment->status()->isFinished()) {
            $currentDocument->setStatus(DocumentStatus::translated());
            $project->updateDocument($currentDocument);
          }

          $translatedDocuments[] = $this->getTranslatedDocument($targetDocumentId, $project, $sourceDocumentId);
        }
      }
    }
    catch (\Exception $exception) {
      if (!$this->isRecoverableException($exception)) {
        throw $exception;
      }
    }

    $this->updateDocuments($project, $translatedDocuments);

    return $translatedDocuments;
  }

  /**
   * Cancel translation.
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project to cancel.
   */
  public function cancelTranslation(Project $project): bool {
    $endpointCancelProject = new CancelProject($this->settings);
    $endpointCancelProject->setProjectId($project->getPlatformId());
    return (bool) $endpointCancelProject->execute();
  }

  /**
   * Is recoverable exception.
   *
   * @param \Exception $exception
   *   Exception.
   */
  private function isRecoverableException(\Exception $exception): bool {
    return $exception instanceof RecoverableError
      || $exception instanceof RecoverableException;
  }

  /**
   * Project manager.
   */
  private function projectManager(): ProjectManager {
    return new ProjectManager($this->settings);
  }

  /**
   * Update documents.
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project to update documents.
   * @param \Drupal\languagewire_translation_provider\Platform\Document\UploadedDocument[] $documents
   *   Uploaded documents to update.
   */
  private function updateDocuments(project $project, array $documents): void {
    array_walk($documents, function (DocumentBase $updateDocuments) use (&$project): void {
      if ($updateDocuments instanceof UploadableDocument) {
        $updateDocuments->updateDomainDocument(FALSE);
      }
      else {
        $updateDocuments->updateDomainDocument();
      }

      $project->updateDocument($updateDocuments->getDocument());
    });
  }

  /**
   * Get translated document.
   *
   * @throws \Drupal\languagewire_translation_provider\Client\RecoverableException
   * @throws \Exception
   */
  private function getTranslatedDocument(string $targetDocumentId, Project $project, string $sourceDocumentId): TranslatedDocument {
    $downloadFile = new DownloadFile($this->settings);
    $downloadFile->setFileData($project->getPlatformId(), $targetDocumentId);

    $translatedData = $downloadFile->execute();

    $document = $project->getDocumentByPlatformId($sourceDocumentId);

    if ($translatedData === NULL || $document === NULL) {
      $errorMessage = "Could not find document by API ID $targetDocumentId."
        . " Project \"{$project->getTitle()}\" (ID: {$project->getPlatformId()}, correlation ID: {$project->getCorrelationId()}).";

      if ($document !== NULL) {
        throw new RecoverableException($errorMessage);
      }

      throw new \Exception($errorMessage);
    }

    $platformDocument = new PlatformDocument(
      $document->getId(),
      new UdfContent(UniversalDocumentFactory::create($translatedData)),
      $project->getTmgmtJob()->targetLanguage()->remoteLanguage(),
      $project->getTitle()
    );

    return TranslatedDocument::fromPlatformDocument($platformDocument, $document);
  }

  /**
   * Upload HTML previews.
   *
   * @param \Drupal\languagewire_translation_provider\Domain\Project $project
   *   Project to update html previews.
   * @param \Drupal\languagewire_translation_provider\Content\HtmlPreview\Result\HtmlPreviewDocumentResult[] $previewDocumentResults
   *   Preview document results.
   *
   * @return PlatformReferenceFile[]
   *   Platform reference files.
   */
  public function uploadHtmlPreviews(Project $project, array $previewDocumentResults): array {
    $platformReferenceFiles = [];

    foreach ($previewDocumentResults as $documentResult) {
      $path = $documentResult->getPackagePath();
      $draftReferenceFile = ReferenceFileFactory::makeFromFilePath($path, self::HTML_PREVIEW_MIMETYPE);

      $fileContent = $draftReferenceFile->getContent()->getStream()->getContents();

      if (empty($fileContent)) {
        continue;
      }

      $addReferenceFile = new AddReferenceFiles($this->settings);
      $addReferenceFile->addReferenceFile(
        $project->getPlatformId(),
        $draftReferenceFile->getFilename(),
        $fileContent
      );

      $file = $addReferenceFile->execute();

      if ($file === NULL) {
        continue;
      }

      // @todo Check return data on reference files
      $platformReferenceFiles[] = new PlatformReferenceFile(
        $file['fileId'],
        $draftReferenceFile->getFilename(),
        $documentResult->getDocumentPlatformId()
      );
    }

    return $platformReferenceFiles;
  }

}
