<?php

declare(strict_types=1);

namespace Drupal\ffmpeg_media\Service;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\File\FileExists;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\ffmpeg_media\Trait\FileUsageCheckerTrait;
use Drupal\ffmpeg_media\ValueObject\ConversionContext;
use Drupal\file\FileInterface;
use Drupal\file\FileUsage\FileUsageInterface;
use Drupal\media\MediaInterface;

/**
 * Handles scheduling and execution of original file retention.
 *
 * @internal
 */
final class OriginalFileRetentionService implements OriginalFileRetentionServiceInterface {

  use FileUsageCheckerTrait;

  /**
   * Retention table name.
   */
  protected const string TABLE = 'ffmpeg_media_original_retention';

  /**
   * Default retention period in days.
   */
  protected const int DEFAULT_RETENTION_DAYS = 30;

  /**
   * Maximum retention period in days.
   */
  protected const int MAX_RETENTION_DAYS = 90;

  /**
   * Default trash directory.
   */
  protected const string DEFAULT_TRASH_PATH = 'public://ffmpeg_media/trash';

  /**
   * Seconds to defer when a file is still in use.
   */
  protected const int DEFER_SECONDS = 86400;

  /**
   * Maximum number of deferrals before abandoning a retention record.
   */
  protected const int MAX_DEFERRALS = 7;

  /**
   * Constructs an OriginalFileRetentionService instance.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
   *   Config factory.
   * @param \Drupal\Core\Database\Connection $database
   *   Database connection.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   Entity type manager.
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   File system service.
   * @param \Drupal\file\FileUsage\FileUsageInterface $fileUsage
   *   File usage service.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger
   *   Logger channel.
   */
  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly Connection $database,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected readonly FileSystemInterface $fileSystem,
    protected readonly FileUsageInterface $fileUsage,
    protected readonly LoggerChannelInterface $logger,
  ) {}

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function schedule(MediaInterface $media, ConversionContext $context, FileInterface $sourceFile): void {
    $config = $this->configFactory->get('ffmpeg_media.settings');
    if ($config->get('preserve_original')) {
      return;
    }

    $retentionEnabled = (bool) ($config->get('original_retention_enabled') ?? TRUE);
    $retentionDays = (int) ($config->get('original_retention_days') ?? self::DEFAULT_RETENTION_DAYS);
    $retentionDays = max(1, min(self::MAX_RETENTION_DAYS, $retentionDays));

    $trashEnabled = (bool) ($config->get('original_trash_enabled') ?? TRUE);
    $trashPath = (string) ($config->get('original_trash_path') ?? self::DEFAULT_TRASH_PATH);
    if (trim($trashPath) === '') {
      $trashPath = self::DEFAULT_TRASH_PATH;
    }

    $deleteAfter = $retentionEnabled ? (time() + ($retentionDays * 86400)) : time();

    $merge = $this->database->merge(self::TABLE)
      ->keys([
        'media_id' => (int) $media->id(),
        'file_id' => (int) $sourceFile->id(),
      ])
      ->fields([
        'source_field' => $context->fieldName,
        'delete_after' => $deleteAfter,
        'trash_enabled' => (int) $trashEnabled,
        'trash_path' => $trashPath,
        'status' => 'scheduled',
        'updated' => time(),
      ])
      ->insertFields([
        'media_id' => (int) $media->id(),
        'file_id' => (int) $sourceFile->id(),
        'source_field' => $context->fieldName,
        'delete_after' => $deleteAfter,
        'trash_enabled' => (int) $trashEnabled,
        'trash_path' => $trashPath,
        'created' => time(),
        'updated' => time(),
        'status' => 'scheduled',
      ]);
    $merge->execute();

    // If retention is disabled, process immediately.
    if (!$retentionEnabled) {
      $this->processScheduledRecord((int) $media->id(), (int) $sourceFile->id());
    }
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  public function processDue(int $limit = 50): int {
    $now = time();
    $query = $this->database->select(self::TABLE, 'r')
      ->fields('r')
      ->condition('status', 'scheduled')
      ->condition('delete_after', $now, '<=')
      ->orderBy('delete_after', 'ASC')
      ->range(0, $limit);

    $records = $query->execute()?->fetchAllAssoc('id') ?? [];
    $processed = 0;

    foreach ($records as $record) {
      if (!is_array($record)) {
        $record = (array) $record;
      }
      $this->processRecord($record);
      $processed++;
    }

    return $processed;
  }

  /**
   * Processes a scheduled record by media/file IDs.
   */
  protected function processScheduledRecord(int $mediaId, int $fileId): void {
    $record = $this->database->select(self::TABLE, 'r')
      ->fields('r')
      ->condition('media_id', $mediaId)
      ->condition('file_id', $fileId)
      ->condition('status', 'scheduled')
      ->execute()?->fetchAssoc();

    if ($record !== FALSE && $record !== NULL) {
      $this->processRecord($record);
    }
  }

  /**
   * Processes a single retention record.
   *
   * @param array<string, string|null> $record
   *   Record data from the retention table.
   */
  protected function processRecord(array $record): void {
    $fileId = (int) ($record['file_id'] ?? 0);
    $mediaId = (int) ($record['media_id'] ?? 0);

    /** @var \Drupal\file\FileInterface|null $file */
    $file = $this->entityTypeManager->getStorage('file')->load($fileId);
    if (!$file instanceof FileInterface) {
      $this->updateRecordStatus((int) $record['id'], 'missing', 'File entity not found.');
      return;
    }

    /** @var \Drupal\media\MediaInterface|null $media */
    $media = $mediaId > 0 ? $this->entityTypeManager->getStorage('media')->load($mediaId) : NULL;

    if ($this->hasOtherFileUsage($file, $media)) {
      $this->deferRecord($record, 'File still in use by other entities.');
      return;
    }

    // Clear media source field if still referencing this file.
    $sourceField = (string) ($record['source_field'] ?? '');
    if ($media instanceof MediaInterface && $sourceField !== '' && $this->mediaReferencesFile($media, $sourceField, $file)) {
      try {
        $media->set($sourceField, NULL);
        $media->save();
      }
      catch (\Throwable $e) {
        // Reload entity to verify actual DB state — save may have partially
        // committed (entity saved but post-save hook threw).
        $this->entityTypeManager->getStorage('media')->resetCache([$mediaId]);
        /** @var \Drupal\media\MediaInterface|null $freshMedia */
        $freshMedia = $this->entityTypeManager->getStorage('media')->load($mediaId);

        if ($freshMedia instanceof MediaInterface && !$this->mediaReferencesFile($freshMedia, $sourceField, $file)) {
          // Partial commit: field is already NULL in DB. Continue cleanup.
          $this->logger->warning('Media @id source field cleared despite save error (partial commit): @error', [
            '@id' => $mediaId,
            '@error' => $e->getMessage(),
          ]);
        }
        else {
          // True failure: field still references the file in DB.
          $this->logger->error('Failed to clear source field on media @id: @error', [
            '@id' => $mediaId,
            '@error' => $e->getMessage(),
          ]);
          $this->updateRecordStatus((int) $record['id'], 'failed', 'Media save failed: ' . $e->getMessage());
          return;
        }
      }
    }

    $trashEnabled = (bool) ($record['trash_enabled'] ?? FALSE);
    $trashPath = (string) ($record['trash_path'] ?? self::DEFAULT_TRASH_PATH);

    if ($trashEnabled) {
      $trashedUri = $this->moveToTrash($file, $trashPath);
      if ($trashedUri === NULL) {
        $this->updateRecordStatus((int) $record['id'], 'failed', 'Failed to move file to trash.');
        return;
      }

      $this->updateRecordStatus((int) $record['id'], 'trashed', NULL);
      return;
    }

    // Reset cache to ensure hooks triggered by $media->save() are reflected.
    $fileId = $file->id();
    if ($fileId === NULL) {
      $this->updateRecordStatus((int) $record['id'], 'failed', 'File has no ID.');
      return;
    }
    $this->entityTypeManager->getStorage('file')->resetCache([$fileId]);
    /** @var \Drupal\file\FileInterface|null $freshFile */
    $freshFile = $this->entityTypeManager->getStorage('file')->load($fileId);
    if ($freshFile !== NULL && $this->countFileUsage($freshFile) === 0) {
      $freshFile->delete();
      $this->updateRecordStatus((int) $record['id'], 'deleted', NULL);
      return;
    }

    $this->deferRecord($record, 'File usage still present after media cleanup.');
  }

  /**
   * Moves a file to the configured trash directory.
   *
   * @return string|null
   *   New URI or NULL on failure.
   */
  protected function moveToTrash(FileInterface $file, string $trashPath): ?string {
    $trashPath = trim($trashPath);
    if ($trashPath === '') {
      $trashPath = self::DEFAULT_TRASH_PATH;
    }

    if (!$this->fileSystem->prepareDirectory($trashPath, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) {
      $this->logger->error('Failed to prepare trash directory @dir for original file cleanup.', ['@dir' => $trashPath]);
      return NULL;
    }

    $sourceUri = $file->getFileUri();
    if ($sourceUri === NULL) {
      $this->logger->error('File has no URI for original file cleanup.');
      return NULL;
    }

    $filename = basename($file->getFilename() ?? 'unknown');
    $base = pathinfo($filename, PATHINFO_FILENAME);
    $extension = pathinfo($filename, PATHINFO_EXTENSION);
    $suffix = date('Ymd_His');
    $targetName = $base . '_' . $suffix . ($extension !== '' ? '.' . $extension : '');
    $targetUri = $this->buildPath($trashPath, $targetName);

    try {
      $movedUri = $this->fileSystem->move($sourceUri, $targetUri, FileExists::Rename);
    }
    catch (\Throwable $e) {
      $this->logger->error('Failed to move original file @file to trash @dest: @error', [
        '@file' => $sourceUri,
        '@dest' => $targetUri,
        '@error' => $e->getMessage(),
      ]);
      return NULL;
    }

    try {
      $file->setFileUri($movedUri);
      $file->setPermanent();
      $file->save();
    }
    catch (\Throwable $e) {
      // Move physical file back to original location.
      try {
        $this->fileSystem->move($movedUri, $sourceUri, FileExists::Rename);
      }
      catch (\Throwable) {
        $this->logger->critical('Failed to restore file @src from @dest after save failure.', [
          '@src' => $sourceUri,
          '@dest' => $movedUri,
        ]);
      }

      // Verify and fix DB state — save may have partially committed,
      // leaving the file entity pointing to the (now moved-back) trash URI.
      $currentFileId = $file->id();
      if ($currentFileId !== NULL) {
        $this->entityTypeManager->getStorage('file')->resetCache([$currentFileId]);
        /** @var \Drupal\file\FileInterface|null $freshFile */
        $freshFile = $this->entityTypeManager->getStorage('file')->load($currentFileId);
        if ($freshFile !== NULL && $freshFile->getFileUri() === $movedUri) {
          try {
            $freshFile->setFileUri($sourceUri);
            $freshFile->save();
          }
          catch (\Throwable $restoreError) {
            $this->logger->critical('File entity @id has orphaned URI @uri after failed trash move. Manual fix required.', [
              '@id' => $currentFileId,
              '@uri' => $movedUri,
            ]);
          }
        }
      }

      $this->logger->error('Failed to save file entity after trash move: @error', [
        '@error' => $e->getMessage(),
      ]);
      return NULL;
    }

    return $movedUri;
  }

  /**
   * Builds a path or URI safely for stream wrappers.
   */
  protected function buildPath(string $directory, string $filename): string {
    if (str_contains($directory, '://')) {
      [$scheme, $rest] = explode('://', $directory, 2);
      $rest = trim($rest, '/');
      $prefix = $scheme . '://';
      if ($rest !== '') {
        $prefix .= $rest . '/';
      }
      return $prefix . ltrim($filename, '/');
    }

    $directory = rtrim($directory, DIRECTORY_SEPARATOR);
    return $directory . DIRECTORY_SEPARATOR . ltrim($filename, DIRECTORY_SEPARATOR);
  }

  /**
   * Updates record status and error details.
   */
  protected function updateRecordStatus(int $recordId, string $status, ?string $error): void {
    $this->database->update(self::TABLE)
      ->fields([
        'status' => $status,
        'last_error' => $error,
        'updated' => time(),
      ])
      ->condition('id', $recordId)
      ->execute();
  }

  /**
   * Defers a record to be retried later.
   *
   * @param array<string, string|null> $record
   *   Record data from the retention table.
   * @param string $reason
   *   Reason for deferral.
   */
  protected function deferRecord(array $record, string $reason): void {
    $currentCount = (int) ($record['defer_count'] ?? 0);
    if ($currentCount >= self::MAX_DEFERRALS) {
      $this->updateRecordStatus((int) $record['id'], 'abandoned', $reason);
      $this->logger->info('Abandoned retention record @id after @count deferrals: @reason', [
        '@id' => $record['id'],
        '@count' => $currentCount,
        '@reason' => $reason,
      ]);
      return;
    }

    $this->database->update(self::TABLE)
      ->fields([
        'delete_after' => time() + self::DEFER_SECONDS,
        'defer_count' => $currentCount + 1,
        'last_error' => $reason,
        'updated' => time(),
      ])
      ->condition('id', (int) $record['id'])
      ->execute();
  }

  /**
   * {@inheritdoc}
   */
  #[\Override]
  protected function getFileUsageService(): FileUsageInterface {
    return $this->fileUsage;
  }

}
