<?php

declare(strict_types=1);

namespace Drupal\acquia_dam;

use Drupal\acquia_dam\Entity\ManagedFileField;
use Drupal\acquia_dam\Entity\ManagedImageField;
use Drupal\acquia_dam\Entity\MediaSourceField;
use Drupal\acquia_dam\Plugin\media\Source\Asset;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Utility\Token;
use Drupal\file\FileInterface;
use Drupal\file\FileRepositoryInterface;
use Drupal\media\MediaInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;

/**
 * The `Asset File Entity Helper` service.
 *
 * Abstracts out primarily file entity and system file related functionality.
 */
final class AssetFileEntityHelper {

  /**
   * Entity Type Manager service.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * Entity Field Manager service.
   *
   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
   */
  protected $entityFieldManager;

  /**
   * Drupal config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Drupal filesystem service.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * Drupal token service.
   *
   * @var \Drupal\Core\Utility\Token
   */
  protected $token;

  /**
   * Acquia DAM config.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $config;

  /**
   * Acquia DAM logger channel.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $loggerChannel;

  /**
   * The file repository.
   *
   * @var \Drupal\file\FileRepositoryInterface
   */
  protected $fileRepository;

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

  /**
   * AssetFileEntityHelper constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   Entity Type Manager service.
   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entityFieldManager
   *   Entity Field Manager service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Drupal config factory.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   Drupal filesystem service.
   * @param \Drupal\Core\Utility\Token $token
   *   Drupal token service.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $loggerChannel
   *   This module's own dedicated logger service.
   * @param \Drupal\file\FileRepositoryInterface $fileRepository
   *   The file repository service.
   * @param \GuzzleHttp\Client $client
   *   The HTTP client.
   */
  public function __construct(
    EntityTypeManagerInterface $entityTypeManager,
    EntityFieldManagerInterface $entityFieldManager,
    ConfigFactoryInterface $configFactory,
    FileSystemInterface $fileSystem,
    Token $token,
    LoggerChannelInterface $loggerChannel,
    FileRepositoryInterface $fileRepository,
    Client $client,
  ) {
    $this->entityTypeManager = $entityTypeManager;
    $this->entityFieldManager = $entityFieldManager;
    $this->configFactory = $configFactory;
    $this->config = $configFactory->get('acquia_dam.settings');
    $this->fileSystem = $fileSystem;
    $this->token = $token;
    $this->loggerChannel = $loggerChannel;
    $this->fileRepository = $fileRepository;
    $this->httpClient = $client;
  }

  /**
   * Returns an associated file or creates a new one.
   *
   * @todo Supply the missing paramater list in this docblock once it's final.
   * @todo pay attention to the case when large files are being saved to the disk. Use queue instead maybe?
   *
   * @return \Drupal\file\FileInterface|null
   *   The file entity or NULL on failure.
   *
   * @throws \GuzzleHttp\Exception\TransferException
   * @throws \Drupal\Core\File\Exception\FileException
   */
  public function downloadFile(
    MediaInterface $media_entity,
    Asset $source_plugin,
    array $asset_data,
    string $asset_id,
    bool $redownload = FALSE,
  ): ?FileInterface {

    if (!$this->validateMediaEntity($media_entity, $source_plugin, $asset_id)) {
      return NULL;
    }

    $this->ensureAssetMetadata($media_entity, $asset_data, $asset_id);

    if (!$redownload && !$this->shouldRedownloadAsset($media_entity, $asset_data)) {
      $existing_file = $this->getExistingFile($media_entity, $source_plugin);
      if ($existing_file) {
        return $existing_file;
      }
    }

    $file_content = $this->downloadAssetContent($asset_data, $asset_id);
    if (!$file_content) {
      return NULL;
    }

    $file_entity = $this->createOrUpdateFileEntity($media_entity, $source_plugin, $file_content);
    if (!$file_entity) {
      return NULL;
    }

    $this->updateMediaEntityReference($media_entity, $source_plugin, $file_entity, $asset_data);
    $this->handleImageDerivatives($source_plugin, $file_entity);

    return $file_entity;
  }

  /**
   * Validates the media entity has the required field.
   */
  protected function validateMediaEntity(MediaInterface $media_entity, Asset $source_plugin, string $asset_id): bool {
    $field_name = $this->getFieldName($source_plugin);

    if (!$media_entity->hasField($field_name)) {
      $this->loggerChannel->error(sprintf(
        'Media entity %s (type: %s) is missing required field %s for asset %s',
        $media_entity->id(),
        $source_plugin->getDerivativeId(),
        $field_name,
        $asset_id
      ));
      return FALSE;
    }

    return TRUE;
  }

  /**
   * Gets the appropriate field name based on the media type.
   */
  protected function getFieldName(Asset $source_plugin): string {
    return $source_plugin->getDerivativeId() === 'image'
      ? ManagedImageField::MANAGED_IMAGE_FIELD_NAME
      : ManagedFileField::MANAGED_FILE_FIELD_NAME;
  }

  /**
   * Ensures asset metadata is present on the media entity.
   */
  protected function ensureAssetMetadata(MediaInterface $media_entity, array $asset_data, string $asset_id): void {
    $asset_field = $media_entity->get(MediaSourceField::SOURCE_FIELD_NAME)->first();
    $asset_ids = $asset_field->getValue();

    if (empty($asset_ids['version_id']) || empty($asset_ids['external_id'])) {
      $field_values = [
        'asset_id' => $asset_ids['asset_id'] ?? $asset_id,
        'version_id' => $asset_data['version_id'],
        'external_id' => $asset_data['external_id'],
      ];
      $media_entity->set(MediaSourceField::SOURCE_FIELD_NAME, $field_values);
    }
  }

  /**
   * Determines if the asset should be redownloaded based on version comparison.
   */
  protected function shouldRedownloadAsset(MediaInterface $media_entity, array $asset_data): bool {
    if ($media_entity->isNew()) {
      return TRUE;
    }

    $asset_field = $media_entity->get(MediaSourceField::SOURCE_FIELD_NAME)->first();
    $local_version_id = $asset_field->getValue()['version_id'];
    $remote_version_id = $asset_data['version_id'];

    return $local_version_id !== $remote_version_id;
  }

  /**
   * Gets the existing file entity if it exists and is valid.
   */
  protected function getExistingFile(MediaInterface $media_entity, Asset $source_plugin): ?FileInterface {
    $field_name = $this->getFieldName($source_plugin);
    $field_item = $media_entity->get($field_name)->first();

    if (!$field_item || $field_item->isEmpty()) {
      return NULL;
    }

    $file_entity = $this->entityTypeManager->getStorage('file')
      ->load($field_item->getValue()['target_id']);

    return ($file_entity instanceof FileInterface) ? $file_entity : NULL;
  }

  /**
   * Downloads the asset content from the remote source.
   */
  protected function downloadAssetContent(array $asset_data, string $asset_id): ?string {
    try {
      $response = $this->httpClient->request('GET', $asset_data['_links']['download']);
    }
    catch (TransferException | FileException $e) {
      $this->loggerChannel->error(sprintf(
        'Unable to download asset file at %s: %s %s',
        $asset_data['_links']['download'],
        get_class($e),
        $e->getMessage()
      ));
      return NULL;
    }

    if ($response->getStatusCode() !== 200) {
      $this->loggerChannel->warning('Unknown error occurred during the HTTP request while fetching data of @asset_id asset.', [
        '@asset_id' => $asset_id,
      ]);
      return NULL;
    }

    return (string) $response->getBody();
  }

  /**
   * Creates or updates the file entity with the downloaded content.
   */
  protected function createOrUpdateFileEntity(MediaInterface $media_entity, Asset $source_plugin, string $file_content): ?FileInterface {
    $directory = $this->prepareFileDirectory($media_entity);
    if (!$directory) {
      return NULL;
    }

    $local_asset_file_uri = $directory . DIRECTORY_SEPARATOR . $source_plugin->getMetadata($media_entity, 'filename');
    $existing_file_entity = $this->getExistingFileEntity($media_entity, $source_plugin);

    if ($existing_file_entity instanceof FileInterface) {
      return $this->updateExistingFile($existing_file_entity, $local_asset_file_uri, $file_content, $source_plugin, $media_entity);
    }

    return $this->createNewFile($file_content, $local_asset_file_uri);
  }

  /**
   * Prepares the file directory for storing the asset.
   */
  protected function prepareFileDirectory(MediaInterface $media_entity): ?string {
    $custom_path = $this->config->get('asset_file_directory_path');

    // Get the uri_scheme from media type source configuration.
    $media_type = $this->entityTypeManager->getStorage('media_type')->load($media_entity->bundle());
    if (!$media_type) {
      return NULL;
    }
    $uri_scheme = $media_type->getSource()->getUriScheme();

    // Build the directory path with tokens.
    $directory = $uri_scheme . '://' . $this->token->replace($custom_path, ['media' => $media_entity]);

    // Prepare the directory.
    if (!$this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) {
      $this->loggerChannel->warning('Unable to prepare @dir_name directory.', [
        '@dir_name' => $directory,
      ]);
      return NULL;
    }

    return $directory;
  }

  /**
   * Gets the existing file entity for the media.
   */
  protected function getExistingFileEntity(MediaInterface $media_entity, Asset $source_plugin): ?FileInterface {
    if ($media_entity->isNew()) {
      return NULL;
    }

    $field_name = $this->getFieldName($source_plugin);
    if (!$media_entity->hasField($field_name)) {
      return NULL;
    }

    $field_item = $media_entity->get($field_name)->first();
    if (!$field_item || $field_item->isEmpty()) {
      return NULL;
    }

    $file_entity = $this->entityTypeManager->getStorage('file')
      ->load($field_item->getValue()['target_id']);

    return ($file_entity instanceof FileInterface) ? $file_entity : NULL;
  }

  /**
   * Updates an existing file entity with new content.
   */
  protected function updateExistingFile(FileInterface $file_entity, string $file_uri, string $content, Asset $source_plugin, MediaInterface $media_entity): ?FileInterface {
    $directory_path = $this->fileSystem->dirname($file_uri);
    $this->fileSystem->prepareDirectory($directory_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);

    if (file_put_contents($file_uri, $content) === FALSE) {
      $this->loggerChannel->warning('Unable to write file data to @file_path location.', [
        '@file_path' => $file_uri,
      ]);
      return NULL;
    }

    $file_entity->setFileUri($file_uri);
    $file_entity->setFilename($source_plugin->getMetadata($media_entity, 'filename'));
    $file_entity->save();

    return $file_entity;
  }

  /**
   * Creates a new file entity.
   */
  protected function createNewFile(string $content, string $file_uri): ?FileInterface {
    $this->fileRepository->writeData($content, $file_uri, FileSystemInterface::EXISTS_REPLACE);

    $file_entity = $this->fileRepository->loadByUri($file_uri);
    if (!$file_entity) {
      $this->loggerChannel->warning('Unable to load the newly created file at @file_path location.', [
        '@file_path' => $file_uri,
      ]);
      return NULL;
    }

    return $file_entity;
  }

  /**
   * Updates the media entity with file reference and metadata.
   */
  protected function updateMediaEntityReference(MediaInterface $media_entity, Asset $source_plugin, FileInterface $file_entity, array $asset_data): void {
    $field_name = $this->getFieldName($source_plugin);
    $values = ['target_id' => $file_entity->id()];

    $asset_description = $this->getAssetDescription($asset_data, $source_plugin, $media_entity);

    switch ($field_name) {
      case ManagedFileField::MANAGED_FILE_FIELD_NAME:
        $values['display'] = 0;
        $values['description'] = $asset_description;
        break;

      case ManagedImageField::MANAGED_IMAGE_FIELD_NAME:
        $values['alt'] = $asset_description;
        $values['title'] = $asset_description;
        $values['width'] = (int) $asset_data['file_properties']['image_properties']['width'] ?: NULL;
        $values['height'] = (int) $asset_data['file_properties']['image_properties']['height'] ?: NULL;
        break;

      default:
        $this->loggerChannel->error('No field found on %media_item_name to reference the managed file/image.', [
          '%media_item_name' => $media_entity->getName(),
        ]);
        return;
    }

    $media_entity->set($field_name, $values);
  }

  /**
   * Gets the asset description from metadata or filename.
   */
  protected function getAssetDescription(array $asset_data, Asset $source_plugin, MediaInterface $media_entity): string {
    if (!empty($asset_data['metadata']['fields']['description'])) {
      return reset($asset_data['metadata']['fields']['description']);
    }

    return $source_plugin->getMetadata($media_entity, 'filename');
  }

  /**
   * Handles image derivative clearing for image assets.
   */
  protected function handleImageDerivatives(Asset $source_plugin, FileInterface $file_entity): void {
    if ($source_plugin->getDerivativeId() === 'image') {
      $this->clearFileDerivatives($file_entity);
    }
  }

  /**
   * Clear all image style derivatives for a file entity.
   *
   * This ensures that when an asset file is replaced, all cached image
   * derivatives (thumbnails, crops, etc.) are removed and will be regenerated
   * with the new file content.
   *
   * @param \Drupal\file\FileInterface $file_entity
   *   The file entity whose derivatives should be cleared.
   */
  protected function clearFileDerivatives(FileInterface $file_entity): void {
    $image_style_storage = $this->entityTypeManager->getStorage('image_style');
    $image_styles = $image_style_storage->loadMultiple();

    $file_uri = $file_entity->getFileUri();

    /** @var \Drupal\image\ImageStyleInterface $image_style */
    foreach ($image_styles as $image_style) {
      $image_style->flush($file_uri);
    }
  }

}
