<?php

declare(strict_types=1);

namespace Drupal\migrate_leshed\Plugin\migrate\source;

use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Utility\Token;
use Drupal\migrate\Attribute\MigrateSource;
use Drupal\migrate\Plugin\MigrationInterface;
use Drupal\migrate\Row;
use Drupal\migrate_plus\DataParserPluginManager;
use Drupal\migrate_plus\Plugin\migrate\source\Url;
use League\CommonMark\CommonMarkConverter;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Source plugin for projects, enriched with body and images from the drive.
 *
 * On top of the Google Sheets columns, this source reads the local files
 * synced from the shared Google Drive (see the `sync_leshed_drive` Makefile
 * target) that live under public://drive_shared_leshed. Files are grouped in
 * per-project folders named "Page{UID}" (optionally suffixed, e.g.
 * "Page1_Complet"), where {UID} matches the sheet UID column:
 * - "{UID}_texte_*.md": the body, written in Markdown, converted to HTML.
 * - "{UID}_images|Images/": one folder of images, each "{name}.jpg" paired
 *   with a "{name}_legende.md" caption used as the (required) alt text.
 *
 * These are exposed as two extra source properties consumed by the migration:
 * - body_html: the converted HTML string (or NULL).
 * - images: a list of ['uri' => string, 'alt' => string] items.
 *
 * @MigrateSource(
 *   id = "projet_node"
 * )
 */
#[MigrateSource(id: 'projet_node')]
final class ProjetNode extends Url {

  /**
   * Stream wrapper URI of the synced drive folder.
   */
  private const DRIVE_URI = 'public://drive_shared_leshed';

  /**
   * Image file extensions to import into field_images.
   */
  private const IMAGE_EXTENSIONS = ['png', 'gif', 'jpg', 'jpeg', 'webp'];

  /**
   * File extensions to import into field_fichiers.
   */
  private const FILE_EXTENSIONS = ['pdf'];

  /**
   * Default destination directory when no constant is configured.
   */
  private const DEFAULT_IMAGE_DESTINATION = 'public://projets/';

  /**
   * Lazily instantiated Markdown converter.
   */
  private ?CommonMarkConverter $markdownConverter = NULL;

  /**
   * Memoized map of UID => project folder URI, built once from the drive root.
   *
   * @var array<string, string>|null
   */
  private ?array $folderMap = NULL;

  /**
   * Memoized destination directory for field_fichiers (from field settings).
   */
  private ?string $filesDestination = NULL;

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    MigrationInterface $migration,
    DataParserPluginManager $parserPluginManager,
    private readonly FileSystemInterface $fileSystem,
    private readonly EntityFieldManagerInterface $entityFieldManager,
    private readonly Token $token,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition, $migration, $parserPluginManager);
  }

  /**
   * {@inheritdoc}
   */
  public static function create($container, array $configuration, $plugin_id, $plugin_definition, ?MigrationInterface $migration = NULL): self {
    return new self(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $migration,
      $container->get('plugin.manager.migrate_plus.data_parser'),
      $container->get('file_system'),
      $container->get('entity_field.manager'),
      $container->get('token'),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function fields(): array {
    return parent::fields() + [
      'body_html' => $this->t('Project body (HTML converted from Markdown)'),
      'images' => $this->t('Project images with captions'),
      'pdfs' => $this->t('Project PDF files'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds(): array {
    return [
      'id' => [
        'type' => 'string',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row): bool {
    // Let the parent (and prepare-row event subscribers) decide first: no need
    // to hit the filesystem for a row that will be skipped.
    if (!parent::prepareRow($row)) {
      return FALSE;
    }

    // Always define both properties so the process pipeline never references
    // an undefined source key, even when no drive folder exists yet.
    $row->setSourceProperty('body_html', NULL);
    $row->setSourceProperty('images', []);
    $row->setSourceProperty('pdfs', []);

    $uid = trim((string) $row->getSourceProperty('id'));
    $folder = $uid !== '' ? $this->findProjectFolder($uid) : NULL;
    if ($folder !== NULL) {
      $row->setSourceProperty('body_html', $this->readBody($folder, $uid));
      $row->setSourceProperty('images', $this->readImages($folder, $uid, (string) $row->getSourceProperty('title')));
      $row->setSourceProperty('pdfs', $this->readPdfs($folder, $uid));
    }

    return TRUE;
  }

  /**
   * Returns the public:// URI of the project folder, or NULL when missing.
   */
  private function findProjectFolder(string $uid): ?string {
    return $this->getFolderMap()[$uid] ?? NULL;
  }

  /**
   * Builds (once) the UID => project folder URI map from the drive root.
   *
   * @return array<string, string>
   *   Map keyed by the numeric part of each "Page{UID}[_suffix]" folder.
   */
  private function getFolderMap(): array {
    if ($this->folderMap !== NULL) {
      return $this->folderMap;
    }

    $this->folderMap = [];
    $base = $this->fileSystem->realpath(self::DRIVE_URI);
    if ($base === FALSE || !is_dir($base)) {
      return $this->folderMap;
    }
    // Match "Page{N}" exactly or with a suffix ("_Complet", ...). Sorting keeps
    // the resolution deterministic when e.g. "Page5" and "Page5_Complet"
    // coexist (the plain, longer-suffixed folder wins consistently).
    $entries = scandir($base) ?: [];
    sort($entries, SORT_NATURAL | SORT_FLAG_CASE);
    foreach ($entries as $entry) {
      if ($entry === '.' || $entry === '..' || !is_dir($base . '/' . $entry)) {
        continue;
      }
      if (preg_match('/^Page(\d+)(_.*)?$/', $entry, $matches)) {
        $this->folderMap[$matches[1]] = self::DRIVE_URI . '/' . $entry;
      }
    }

    return $this->folderMap;
  }

  /**
   * Reads the Markdown body file and converts it to HTML.
   */
  private function readBody(string $folderUri, string $uid): ?string {
    $dir = $this->fileSystem->realpath($folderUri);
    if ($dir === FALSE) {
      return NULL;
    }
    // Prefer "{UID}_texte*.md", fall back to any Markdown file at the root.
    $preferred = '/^' . preg_quote($uid, '/') . '_texte.*\.md$/i';
    $entries = scandir($dir) ?: [];
    sort($entries, SORT_NATURAL | SORT_FLAG_CASE);
    $markdown = NULL;
    foreach ($entries as $entry) {
      if (!is_file($dir . '/' . $entry) || !preg_match('/\.md$/i', $entry)) {
        continue;
      }
      if (preg_match($preferred, $entry)) {
        $markdown = $dir . '/' . $entry;
        break;
      }
      $markdown ??= $dir . '/' . $entry;
    }
    if ($markdown === NULL) {
      return NULL;
    }

    $contents = file_get_contents($markdown);
    if ($contents === FALSE || trim($contents) === '') {
      return NULL;
    }
    $contents = $this->cleanBodyMarkdown($contents);
    if ($contents === '') {
      return NULL;
    }

    return (string) $this->getMarkdownConverter()->convert($contents);
  }

  /**
   * Cleans the raw source Markdown before conversion.
   *
   * Removes redundant content already present elsewhere: raw HTML wrappers,
   * invisible characters, and the leading title block (H1 title + H2/H3
   * artists/subtitle, redundant with the sheet). Headings located *inside* the
   * body (e.g. artist/curator bios) are preserved. Idempotent: running it on an
   * already-cleaned text has no effect.
   */
  private function cleanBodyMarkdown(string $md): string {
    // 1. Strip HTML tags (<div>, <br>, </div>, ...).
    $md = (string) preg_replace('/<[^>]+>/', '', $md);
    // 2. Strip zero-width/BOM characters and turn NBSP into a normal space.
    $md = (string) preg_replace('/[\x{200B}\x{200E}\x{200F}\x{FEFF}]/u', '', $md);
    $md = (string) preg_replace('/\x{00A0}/u', ' ', $md);
    // 3. Drop only the leading heading block: skip leading blank and heading
    //    lines until the first body line.
    $lines = preg_split('/\R/u', $md) ?: [];
    $i = 0;
    $count = count($lines);
    while ($i < $count && (trim($lines[$i]) === '' || preg_match('/^\s*#{1,6}\s/u', $lines[$i]))) {
      $i++;
    }
    $md = implode("\n", array_slice($lines, $i));
    // 4. Collapse blank lines and trim.
    $md = (string) preg_replace('/\n{3,}/', "\n\n", $md);

    return trim($md);
  }

  /**
   * Builds the list of images (URI + alt) for the project.
   *
   * @return array<int, array{uri: string, alt: string, caption: string, destination: string}>
   *   The images to import.
   */
  private function readImages(string $folderUri, string $uid, string $title): array {
    $dir = $this->fileSystem->realpath($folderUri);
    if ($dir === FALSE) {
      return [];
    }
    // The destination is passed through each item because a sub_process row
    // cannot read the parent migration's `constants`. It is namespaced per
    // project (UID) so identically-named files from different projects never
    // collide when file_import reuses existing files (reuse: true).
    $base = $this->configuration['constants']['images_destination']
      ?? self::DEFAULT_IMAGE_DESTINATION;
    $destination = rtrim($base, '/') . '/' . $uid . '/';
    // Locate the images subfolder ("{UID}_images", case-insensitive).
    $imagesDirUri = NULL;
    $pattern = '/^' . preg_quote($uid, '/') . '_images$/i';
    foreach (scandir($dir) ?: [] as $entry) {
      if (preg_match($pattern, $entry) && is_dir($dir . '/' . $entry)) {
        $imagesDirUri = $folderUri . '/' . $entry;
        break;
      }
    }
    if ($imagesDirUri === NULL) {
      return [];
    }

    $imagesDir = $this->fileSystem->realpath($imagesDirUri);
    if ($imagesDir === FALSE) {
      return [];
    }
    $images = [];
    $files = scandir($imagesDir) ?: [];
    sort($files, SORT_NATURAL | SORT_FLAG_CASE);
    foreach ($files as $file) {
      $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION));
      if (!in_array($extension, self::IMAGE_EXTENSIONS, TRUE)) {
        continue;
      }
      $name = pathinfo($file, PATHINFO_FILENAME);
      $legende = $this->readCaptionFile($imagesDir . '/' . $name . '_legende.md');
      // Plain text (required alt), falling back to the project title. Capped to
      // the image field's alt column length (varchar 512).
      $alt = $legende !== '' ? $this->toPlainText($legende) : $title;
      $alt = mb_substr($alt, 0, 512);
      // Rich caption (image_field_caption): Markdown converted to HTML.
      $caption = $legende !== '' ? (string) $this->getMarkdownConverter()->convert($legende) : '';
      $images[] = [
        'uri' => $imagesDirUri . '/' . $file,
        'alt' => $alt,
        'caption' => $caption,
        'destination' => $destination,
      ];
    }

    return $images;
  }

  /**
   * Builds the list of PDF files for the project.
   *
   * Collects PDFs both at the project folder root and inside the "{UID}_pdf"
   * subfolder. Each item carries the destination directory (from the
   * field_fichiers settings) and a human-readable description.
   *
   * @return array<int, array{uri: string, destination: string, description: string}>
   *   The files to import.
   */
  private function readPdfs(string $folderUri, string $uid): array {
    $dir = $this->fileSystem->realpath($folderUri);
    if ($dir === FALSE) {
      return [];
    }

    // Directories to scan: the project folder root and its "{UID}_pdf" subfolder.
    $sources = [$folderUri];
    $pattern = '/^' . preg_quote($uid, '/') . '_pdf$/i';
    foreach (scandir($dir) ?: [] as $entry) {
      if (preg_match($pattern, $entry) && is_dir($dir . '/' . $entry)) {
        $sources[] = $folderUri . '/' . $entry;
      }
    }

    $destination = $this->filesDestination();
    $pdfs = [];
    foreach ($sources as $sourceUri) {
      $sourceDir = $this->fileSystem->realpath($sourceUri);
      if ($sourceDir === FALSE) {
        continue;
      }
      $files = scandir($sourceDir) ?: [];
      sort($files, SORT_NATURAL | SORT_FLAG_CASE);
      foreach ($files as $file) {
        if (!is_file($sourceDir . '/' . $file)) {
          continue;
        }
        if (!in_array(strtolower(pathinfo($file, PATHINFO_EXTENSION)), self::FILE_EXTENSIONS, TRUE)) {
          continue;
        }
        $pdfs[] = [
          'uri' => $sourceUri . '/' . $file,
          'destination' => $destination,
          'description' => $this->humanizeFilename($file),
        ];
      }
    }

    return $pdfs;
  }

  /**
   * Resolves (once) the field_fichiers destination directory from its settings.
   */
  private function filesDestination(): string {
    if ($this->filesDestination !== NULL) {
      return $this->filesDestination;
    }
    $scheme = 'public';
    $directory = '';
    $definitions = $this->entityFieldManager->getFieldDefinitions('node', 'projet');
    $field = $definitions['field_fichiers'] ?? NULL;
    if ($field !== NULL) {
      $scheme = $field->getFieldStorageDefinition()->getSetting('uri_scheme') ?: 'public';
      $directory = (string) $this->token->replace((string) $field->getSetting('file_directory'));
    }

    return $this->filesDestination = $scheme . '://' . trim($directory, '/') . '/';
  }

  /**
   * Turns a file name into a readable label (drops UID prefix and extension).
   */
  private function humanizeFilename(string $file): string {
    $name = pathinfo($file, PATHINFO_FILENAME);
    // Drop a leading "{UID}_" numeric prefix.
    $name = preg_replace('/^\d+[_-]/', '', $name) ?? $name;
    // Separators to spaces, collapse, trim.
    $name = (string) preg_replace('/[_\-]+/u', ' ', $name);

    return trim((string) preg_replace('/\s+/u', ' ', $name));
  }

  /**
   * Returns the raw contents of a caption (legende) file, or '' when missing.
   */
  private function readCaptionFile(string $captionPath): string {
    if (!is_file($captionPath)) {
      return '';
    }
    $contents = file_get_contents($captionPath);
    if ($contents === FALSE) {
      return '';
    }
    // Drive exports are occasionally Windows-1252 rather than UTF-8.
    if (!mb_check_encoding($contents, 'UTF-8')) {
      $contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
    }

    return trim($contents);
  }

  /**
   * Reduces Markdown/HTML text to a single trimmed plain-text line (for alt).
   */
  private function toPlainText(string $text): string {
    $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
    // Drop Markdown emphasis/heading markers left after tag stripping.
    $text = preg_replace('/[*_#`>]+/u', '', $text) ?? $text;

    return trim((string) preg_replace('/\s+/u', ' ', $text));
  }

  /**
   * Returns the shared Markdown converter.
   *
   * Raw HTML embedded in the source Markdown (alignment <div>, <br>, ...) is
   * stripped: only the Markdown itself is converted to HTML, keeping the body
   * clean and safe for the wysiwyg format.
   */
  private function getMarkdownConverter(): CommonMarkConverter {
    if ($this->markdownConverter === NULL) {
      $this->markdownConverter = new CommonMarkConverter([
        'html_input' => 'strip',
        'allow_unsafe_links' => FALSE,
      ]);
    }

    return $this->markdownConverter;
  }

}
