<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\ffmpeg_media\Enum\OutputType;
use Drupal\file\FileInterface;
use Drupal\file\FileUsage\FileUsageInterface;

/**
 * Table-based storage for conversion output files.
 *
 * Stores output file references in the `ffmpeg_media_outputs` table
 * instead of dedicated entity fields. Manages Drupal file usage so
 * referenced files are not garbage-collected.
 *
 * @internal
 */
final class OutputStorageService implements OutputStorageServiceInterface {

  /**
   * Database table name.
   */
  private const string TABLE = 'ffmpeg_media_outputs';

  /**
   * Module name used for file usage tracking.
   */
  private const string FILE_USAGE_MODULE = 'ffmpeg_media';

  /**
   * File usage type identifier.
   */
  private const string FILE_USAGE_TYPE = 'media';

  /**
   * Constructs an OutputStorageService object.
   *
   * @param \Drupal\Core\Database\Connection $database
   *   Database connection.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   Entity type manager for loading file entities.
   * @param \Drupal\file\FileUsage\FileUsageInterface $fileUsage
   *   File usage service.
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $cacheTagsInvalidator
   *   Cache tags invalidator for busting render caches on output changes.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service for resolving stream wrapper URIs.
   */
  public function __construct(
    protected readonly Connection $database,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly FileUsageInterface $fileUsage,
    protected readonly CacheTagsInvalidatorInterface $cacheTagsInvalidator,
    protected readonly FileSystemInterface $fileSystem,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function setOutput(int $mediaId, string $fieldName, string $outputType, FileInterface $file): void {
    $fileId = (int) $file->id();

    // Validate output type before any storage operations.
    $type = OutputType::tryFrom($outputType);
    if ($type === NULL) {
      throw new \InvalidArgumentException(sprintf(
        'Invalid output type "%s" for media %d field %s.',
        $outputType, $mediaId, $fieldName,
      ));
    }

    // Wrap DELETE → MERGE → file.usage in a transaction so a crash
    // mid-operation does not leave inconsistent state.
    $transaction = $this->database->startTransaction();
    try {
      // Load existing file references for this (media, field, type) tuple.
      // Used for both single-value replacement and multi-value duplicate check.
      $existingFileIds = $this->database->select(self::TABLE, 'o')
        ->fields('o', ['file_id'])
        ->condition('media_id', $mediaId)
        ->condition('field_name', $fieldName)
        ->condition('output_type', $outputType)
        ->execute()?->fetchCol() ?? [];

      // Single-value outputs are replaced atomically: delete existing records
      // and their file usage before inserting the new one.
      if (!$type->isMultiValue() && $existingFileIds !== []) {
        $this->database->delete(self::TABLE)
          ->condition('media_id', $mediaId)
          ->condition('field_name', $fieldName)
          ->condition('output_type', $outputType)
          ->execute();

        $this->removeFileUsageCounts($this->countFileReferences($existingFileIds), $mediaId);
      }

      // For single-value outputs, the old usage was already removed above,
      // so we must always add usage for the (re-)inserted reference.
      // For multi-value outputs, skip add() when this exact file_id already
      // has a row — prevents usage-count inflation on idempotent re-sets.
      $needsUsageAdd = !$type->isMultiValue()
        || !in_array($fileId, $existingFileIds, TRUE);

      $this->database->merge(self::TABLE)
        ->keys([
          'media_id' => $mediaId,
          'field_name' => $fieldName,
          'output_type' => $outputType,
          'file_id' => $fileId,
        ])
        ->fields([
          'created' => time(),
        ])
        ->execute();

      if ($needsUsageAdd) {
        $this->fileUsage->add(
          $file,
          self::FILE_USAGE_MODULE,
          self::FILE_USAGE_TYPE,
          (string) $mediaId,
        );
      }
    }
    catch (\Throwable $e) {
      $transaction->rollBack();
      throw $e;
    }

    // Unset the transaction object to commit (Drupal commits on __destruct).
    unset($transaction);

    // Cache invalidation is outside the transaction — a stale cache is
    // acceptable; an inconsistent database is not.
    $this->invalidateCaches($mediaId);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getOutput(int $mediaId, string $fieldName, string $outputType): ?FileInterface {
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['file_id'])
      ->condition('media_id', $mediaId)
      ->condition('field_name', $fieldName)
      ->condition('output_type', $outputType)
      ->range(0, 1)
      ->orderBy('id', 'DESC');

    $statement = $query->execute();

    if ($statement === NULL) {
      return NULL;
    }

    $fileId = $statement->fetchField();
    if ($fileId === FALSE) {
      return NULL;
    }

    $file = $this->entityTypeManager->getStorage('file')->load($fileId);

    return $file instanceof FileInterface ? $file : NULL;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getOutputs(int $mediaId, string $fieldName, string $outputType): array {
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['file_id'])
      ->condition('media_id', $mediaId)
      ->condition('field_name', $fieldName)
      ->condition('output_type', $outputType)
      ->orderBy('id', 'ASC');

    $statement = $query->execute();

    if ($statement === NULL) {
      return [];
    }

    $fileIds = $statement->fetchCol();
    if ($fileIds === []) {
      return [];
    }

    $fileStorage = $this->entityTypeManager->getStorage('file');
    $loadedFiles = $fileStorage->loadMultiple($fileIds);

    $files = [];
    foreach ($fileIds as $fileId) {
      $file = $loadedFiles[$fileId] ?? NULL;
      if ($file instanceof FileInterface) {
        $files[] = $file;
      }
    }

    return $files;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getAllOutputs(int $mediaId, string $fieldName = ''): array {
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['output_type', 'file_id'])
      ->condition('media_id', $mediaId)
      ->orderBy('output_type', 'ASC')
      ->orderBy('id', 'ASC');

    if ($fieldName !== '') {
      $query->condition('field_name', $fieldName);
    }

    $statement = $query->execute();

    if ($statement === NULL) {
      return [];
    }

    $rows = $statement->fetchAll();

    $grouped = [];
    foreach ($rows as $row) {
      $grouped[$row->output_type][] = $row->file_id;
    }

    if ($grouped === []) {
      return [];
    }

    // Collect all file IDs and load in batch.
    $allFileIds = array_unique(array_merge(...array_values($grouped)));
    $fileStorage = $this->entityTypeManager->getStorage('file');
    $loadedFiles = $fileStorage->loadMultiple($allFileIds);

    $result = [];
    foreach ($grouped as $outputType => $fileIds) {
      $result[$outputType] = [];
      foreach ($fileIds as $fileId) {
        $file = $loadedFiles[$fileId] ?? NULL;
        if ($file instanceof FileInterface) {
          $result[$outputType][] = $file;
        }
      }
    }

    return $result;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function hasOutputs(int $mediaId, string $fieldName = ''): bool {
    $query = $this->database->select(self::TABLE, 'o')
      ->condition('media_id', $mediaId);

    if ($fieldName !== '') {
      $query->condition('field_name', $fieldName);
    }

    $statement = $query->countQuery()->execute();

    return ((int) ($statement?->fetchField() ?? 0)) > 0;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  #[\NoDiscard]
  public function getConvertedMediaIds(): array {
    $statement = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['media_id'])
      ->distinct()
      ->execute();

    if ($statement === NULL) {
      return [];
    }

    return array_map('intval', $statement->fetchCol());
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<string, array<int, array{file_id: int, field_name: string, created: int, file: \Drupal\file\FileInterface|null}>>
   */
  #[\Override]
  #[\NoDiscard]
  public function getOutputsByMediaId(int $mediaId): array {
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['output_type', 'field_name', 'file_id', 'created'])
      ->condition('media_id', $mediaId)
      ->orderBy('output_type', 'ASC')
      ->orderBy('id', 'ASC');

    $statement = $query->execute();

    if ($statement === NULL) {
      return [];
    }

    $rows = $statement->fetchAll();

    if ($rows === []) {
      return [];
    }

    // Collect all file IDs for a single batch load.
    $allFileIds = [];
    foreach ($rows as $row) {
      $allFileIds[] = (int) $row->file_id;
    }
    $allFileIds = array_unique($allFileIds);

    $fileStorage = $this->entityTypeManager->getStorage('file');
    $loadedFiles = $fileStorage->loadMultiple($allFileIds);

    // Group rows by output_type with file metadata.
    $result = [];
    foreach ($rows as $row) {
      $fileId = (int) $row->file_id;
      $file = $loadedFiles[$fileId] ?? NULL;

      $result[$row->output_type][] = [
        'file_id' => $fileId,
        'field_name' => $row->field_name,
        'created' => (int) $row->created,
        'file' => $file instanceof FileInterface ? $file : NULL,
      ];
    }

    return $result;
  }

  /**
   * {@inheritdoc}
   *
   * @phpstan-return array<int, array{row_id: int, media_id: int, field_name: string, output_type: string, file_id: int, expected_uri: string|null, status: string}>
   */
  #[\Override]
  #[\NoDiscard]
  public function verifyOutputFiles(int $mediaId = 0): array {
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['id', 'media_id', 'field_name', 'output_type', 'file_id']);

    if ($mediaId > 0) {
      $query->condition('media_id', $mediaId);
    }

    $statement = $query->execute();
    if ($statement === NULL) {
      return [];
    }

    $rows = $statement->fetchAll();
    if ($rows === []) {
      return [];
    }

    // Batch load all file entities.
    $fileIds = array_unique(array_map(
      static fn(\stdClass $row): int => (int) $row->file_id,
      $rows,
    ));
    $fileStorage = $this->entityTypeManager->getStorage('file');
    $loadedFiles = $fileStorage->loadMultiple($fileIds);

    $orphaned = [];
    foreach ($rows as $row) {
      $fileId = (int) $row->file_id;
      $file = $loadedFiles[$fileId] ?? NULL;

      if (!$file instanceof FileInterface) {
        $orphaned[] = [
          'row_id' => (int) $row->id,
          'media_id' => (int) $row->media_id,
          'field_name' => (string) $row->field_name,
          'output_type' => $row->output_type,
          'file_id' => $fileId,
          'expected_uri' => NULL,
          'status' => 'missing_entity',
        ];
        continue;
      }

      $uri = $file->getFileUri() ?? '';
      $realPath = $uri !== '' ? $this->fileSystem->realpath($uri) : FALSE;

      if ($realPath === FALSE || !file_exists($realPath)) {
        $orphaned[] = [
          'row_id' => (int) $row->id,
          'media_id' => (int) $row->media_id,
          'field_name' => (string) $row->field_name,
          'output_type' => $row->output_type,
          'file_id' => $fileId,
          'expected_uri' => $uri,
          'status' => 'missing_file',
        ];
      }
    }

    return $orphaned;
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteOrphanedRecord(int $mediaId, string $fieldName, string $outputType, int $fileId): void {
    $this->database->delete(self::TABLE)
      ->condition('media_id', $mediaId)
      ->condition('field_name', $fieldName)
      ->condition('output_type', $outputType)
      ->condition('file_id', $fileId)
      ->execute();

    $this->removeFileUsageCounts([$fileId => 1], $mediaId);
    $this->invalidateCaches($mediaId);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteOutput(int $mediaId, string $fieldName, string $outputType): void {
    // Load file IDs before deleting rows so we can remove file usage.
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['file_id'])
      ->condition('media_id', $mediaId)
      ->condition('field_name', $fieldName)
      ->condition('output_type', $outputType);

    $fileIds = $query->execute()?->fetchCol() ?? [];
    $fileUsageCounts = $this->countFileReferences($fileIds);

    $delete = $this->database->delete(self::TABLE)
      ->condition('media_id', $mediaId)
      ->condition('field_name', $fieldName)
      ->condition('output_type', $outputType);
    $delete->execute();

    $this->removeFileUsageCounts($fileUsageCounts, $mediaId);

    $this->invalidateCaches($mediaId);
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function deleteAllOutputs(int $mediaId, string $fieldName = ''): void {
    // Load file IDs before deleting rows.
    $query = $this->database->select(self::TABLE, 'o')
      ->fields('o', ['file_id'])
      ->condition('media_id', $mediaId);

    if ($fieldName !== '') {
      $query->condition('field_name', $fieldName);
    }

    $fileIds = $query->execute()?->fetchCol() ?? [];
    $fileUsageCounts = $this->countFileReferences($fileIds);

    $delete = $this->database->delete(self::TABLE)
      ->condition('media_id', $mediaId);

    if ($fieldName !== '') {
      $delete->condition('field_name', $fieldName);
    }

    $delete->execute();

    $this->removeFileUsageCounts($fileUsageCounts, $mediaId);

    $this->invalidateCaches($mediaId);
  }

  /**
   * Builds per-file usage counts from output table rows.
   *
   * @param array<int|string> $fileIds
   *   File entity IDs from output rows, including duplicates.
   *
   * @return array<int, int>
   *   Usage counts keyed by file entity ID.
   */
  private function countFileReferences(array $fileIds): array {
    $normalizedFileIds = [];
    foreach ($fileIds as $fileId) {
      $normalizedFileId = (int) $fileId;
      if ($normalizedFileId > 0) {
        $normalizedFileIds[] = $normalizedFileId;
      }
    }

    if ($normalizedFileIds === []) {
      return [];
    }

    /** @var array<int<1, max>, int<1, max>> $usageCounts */
    $usageCounts = array_count_values($normalizedFileIds);
    return $usageCounts;
  }

  /**
   * Removes file usage for the given file IDs and reference counts.
   *
   * @param array<int, int> $fileUsageCounts
   *   Usage counts keyed by file entity ID.
   * @param int $mediaId
   *   Media entity ID (used as the usage entity ID).
   */
  private function removeFileUsageCounts(array $fileUsageCounts, int $mediaId): void {
    if ($fileUsageCounts === []) {
      return;
    }

    $fileStorage = $this->entityTypeManager->getStorage('file');
    $files = $fileStorage->loadMultiple(array_keys($fileUsageCounts));

    foreach ($fileUsageCounts as $fileId => $count) {
      $file = $files[$fileId] ?? NULL;
      if ($file instanceof FileInterface) {
        if ($count > 1) {
          $this->fileUsage->delete(
            $file,
            self::FILE_USAGE_MODULE,
            self::FILE_USAGE_TYPE,
            (string) $mediaId,
            $count,
          );
        }
        else {
          $this->fileUsage->delete(
            $file,
            self::FILE_USAGE_MODULE,
            self::FILE_USAGE_TYPE,
            (string) $mediaId,
          );
        }
      }
    }
  }

  /**
   * Invalidates output-dependent render and stats caches.
   */
  private function invalidateCaches(int $mediaId): void {
    $this->cacheTagsInvalidator->invalidateTags([
      'ffmpeg_media:outputs:' . $mediaId,
      'ffmpeg_media:stats',
      'ffmpeg_media:dashboard',
    ]);
  }

}
