<?php

declare(strict_types=1);

namespace Drupal\siteimprove_accessibility\Repository;

use Drupal\Core\Database\Connection;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Logger\LoggerChannelTrait;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * AlfaScanRepository service to operate with AlfaScan entity.
 */
final class AlfaScanRepository {

  use LoggerChannelTrait;

  /**
   * Alfa scan records to be stored.
   */
  const SI_ALFA_SCAN_LIMIT = 100;

  /**
   * Alfa scan delete chunk size.
   */
  const SI_ALFA_SCAN_DELETE_CHUNK_SIZE = 50;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected EntityTypeManagerInterface $entityTypeManager;

  /**
   * Database connection service.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected Connection $connection;

  /**
   * DataFormatter service.
   *
   * @var \Drupal\Core\Datetime\DateFormatterInterface
   */
  protected DateFormatterInterface $dateFormatter;

  /**
   * LanguageManager service.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected LanguageManagerInterface $languageManager;

  /**
   * Site default language.
   *
   * @var string
   *   Lang-code.
   */
  private $defaultLanguage;

  /**
   * Constructs a new AlfaScanRepository object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   * @param \Drupal\Core\Database\Connection $connection
   *   Database connection service.
   * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
   *   DateFormatter service.
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   LanguageManager service.
   */
  public function __construct(
    EntityTypeManagerInterface $entity_type_manager,
    Connection $connection,
    DateFormatterInterface $date_formatter,
    LanguageManagerInterface $language_manager,
  ) {
    $this->entityTypeManager = $entity_type_manager;
    $this->connection = $connection;
    $this->dateFormatter = $date_formatter;
    $this->languageManager = $language_manager;

    $this->defaultLanguage = $language_manager->getDefaultLanguage()->getId();
  }

  /**
   * Creates or updates AlfaScan entity.
   *
   * @param string $uuid
   *   Uuid of the scanned node.
   * @param array $scan_results
   *   Scan results.
   *
   * @return \Drupal\Core\Entity\EntityInterface|null
   *   AlfaScan entity or null.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  public function createUpdateScan(string $uuid, array $scan_results = []): ?EntityInterface {
    $node = $this->entityTypeManager
      ->getStorage('node')
      ->loadByProperties(['uuid' => $uuid]);

    $node = reset($node);
    if (!$node) {
      throw new NotFoundHttpException('Node not found.');
    }

    $scan = $this->entityTypeManager
      ->getStorage('alfa_scan')
      ->loadByProperties(['nid' => $node->id()]);

    if ($scan) {
      $scan = reset($scan);
    }
    else {
      $scan = $this->entityTypeManager->getStorage('alfa_scan')->create([
        'nid' => $node->id(),
      ]);
    }
    $scan->set('scan_results', json_encode($scan_results));
    $scan->save();

    return ($scan instanceof EntityInterface) ? $scan : NULL;
  }

  /**
   * Count all AlfaScan reports.
   *
   * @return int
   *   Count of AlfaScans.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  public function countAllScans(): int {
    $query = $this->entityTypeManager->getStorage('alfa_scan')
      ->getQuery()
      ->accessCheck(FALSE);
    return (int) $query->count()->execute();
  }

  /**
   * Retrieves pages with accessibility issues.
   *
   * @param array $params
   *   Query parameters for filtering.
   *
   * @return array
   *   The list of pages with issues.
   */
  public function findPagesWithIssues(array $params): array {
    $params = $this->validateAndSanitizeParams($params);

    $query = $this->preparePagesWithIssuesQuery($params);
    $scans = $query->execute()->fetchAll();

    if (!empty($scans)) {
      $scans = $this->expandScansWithIssues($scans);
    }

    return array_map([$this, 'castPageFields'], $scans);
  }

  /**
   * Counts pages with accessibility issues.
   *
   * @param array $params
   *   Query parameters for filtering.
   *
   * @return int
   *   The total count of pages with issues.
   */
  public function countPagesWithIssues(array $params): int {
    $params = $this->validateAndSanitizeParams($params);

    $query = $this->preparePagesWithIssuesQuery($params, FALSE);
    return (int) $query->countQuery()->execute()->fetchField();
  }

  /**
   * Prepares query to find pages with issues.
   *
   * @param array $params
   *   Filter parameters.
   * @param bool $use_limit
   *   Whether to apply limit & offset.
   */
  private function preparePagesWithIssuesQuery(array $params, bool $use_limit = TRUE) {
    $query = $this->connection->select('siteimprove_accessibility_alfa_scan', 's');

    $query->fields('s', ['id', 'nid', 'changed']);
    $query->addField('n', 'title', 'title');

    $query->join('siteimprove_accessibility_occurrence', 'o', 'o.scan_id = s.id');
    $query->join('node_field_data', 'n', 'n.nid = s.nid AND n.langcode = :langcode', [':langcode' => $this->defaultLanguage]);
    $query->leftJoin('path_alias', 'pa', "pa.path = CONCAT('/node/', s.nid) AND pa.langcode = :pa_langcode", [':pa_langcode' => $this->defaultLanguage]);

    $query->addExpression('COUNT(o.rule_id)', 'issuesCount');
    $query->addExpression('SUM(o.occurrence)', 'occurrences');
    $query->addExpression("COALESCE(pa.alias, CONCAT('/node/', s.nid))", 'url');

    $query->groupBy('s.id')
      ->groupBy('s.nid')
      ->groupBy('s.changed')
      ->groupBy('n.title')
      ->groupBy('pa.alias');

    if (!empty($params['rule_id'])) {
      $query->condition('o.rule_id', $params['rule_id']);
    }

    if (!empty($params['search_term']) && !empty($params['search_field'])) {
      if ($params['search_field'] === 'url') {
        $query->condition('pa.alias', '%' . $params['search_term'] . '%', 'LIKE');
      }
      else {
        $query->condition($params['search_field'], '%' . $params['search_term'] . '%', 'LIKE');
      }
    }

    if (!empty($params['sort_field']) && !empty($params['sort_direction'])) {
      $query->orderBy($params['sort_field'], $params['sort_direction']);
    }

    if ($use_limit && isset($params['limit']) && isset($params['offset'])) {
      $query->range($params['offset'], $params['limit']);
    }

    return $query;
  }

  /**
   * Expands scans with issue details.
   *
   * @param array $scans
   *   List of scan records.
   *
   * @return array
   *   Updated scans with issue details.
   */
  private function expandScansWithIssues(array $scans): array {
    $scan_ids = array_map(fn($record) => (int) $record->id, $scans);

    $query = $this->connection->select('siteimprove_accessibility_occurrence', 'o')
      ->fields('o', ['scan_id', 'rule_id', 'occurrence'])
      ->condition('o.scan_id', $scan_ids, 'IN');

    $occurrences = $query->execute()->fetchAll();

    foreach ($occurrences as $occurrence) {
      foreach ($scans as $scan) {
        if ($scan->id === $occurrence->scan_id) {
          $scan->issues[] = [
            'id' => (int) $occurrence->rule_id,
            'occurrences' => (int) $occurrence->occurrence,
          ];
          break;
        }
      }
    }

    return $scans;
  }

  /**
   * Validates and sanitizes request parameters.
   *
   * @param array $params
   *   The query parameters from the request.
   *
   * @return array
   *   The sanitized and validated parameters.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
   *   If validation fails.
   */
  private function validateAndSanitizeParams(array $params): array {
    $field_mapping = [
      'title' => 'n.title',
      'url' => 'url',
      'occurrences' => 'occurrences',
      'issuesCount' => 'issuesCount',
      'lastChecked' => 's.changed',
    ];

    $object_params = ['sort', 'filter'];

    foreach ($object_params as $param) {
      if (!empty($params[$param])) {
        $params[$param] = json_decode(urldecode($params[$param]), TRUE);
      }
    }

    return [
      'limit' => (int) ($params['pageSize'] ?? 10),
      'offset' => ((int) ($params['pageSize'] ?? 10)) * (max((int) ($params['page'] ?? 1), 1) - 1),
      'sort_field' => isset($params['sort']['property']) && isset($field_mapping[$params['sort']['property']])
        ? $field_mapping[$params['sort']['property']]
        : NULL,
      'sort_direction' => isset($params['sort']['direction']) && strtoupper($params['sort']['direction']) === 'DESC'
        ? 'DESC'
        : 'ASC',
      'search_term' => $params['query'] ?? NULL,
      'search_field' => isset($params['searchType']) && isset($field_mapping[$params['searchType']])
        ? $field_mapping[$params['searchType']]
        : NULL,
      'rule_id' => isset($params['ruleId']) ? (int) $params['ruleId'] : NULL,
    ];
  }

  /**
   * Casts page fields to proper types.
   *
   * @param object $page
   *   Raw database row.
   *
   * @return array
   *   Processed page data.
   */
  private function castPageFields(object $page): array {
    return [
      'id' => (int) $page->id,
      'title' => (string) $page->title,
      'url' => (string) $page->url,
      'occurrences' => (int) $page->occurrences,
      'issuesCount' => (int) $page->issuesCount,
      'lastChecked' => $this->dateFormatter->format($page->changed, 'custom', 'Y-m-d H:i:s'),
      'issues' => $page->issues ?? [],
    ];
  }

  /**
   * Checks the number of AlfaScan entities and removes the oldest.
   */
  private function enforceScanLimit() {
    try {
      $storage = $this->entityTypeManager->getStorage('alfa_scan');

      $total_scans = $storage->getQuery()
        ->accessCheck(FALSE)
        ->count()
        ->execute();

      if ($total_scans > self::SI_ALFA_SCAN_LIMIT) {
        $excess_count = $total_scans - self::SI_ALFA_SCAN_LIMIT;

        $oldest_scan_ids = $storage->getQuery()
          ->accessCheck(FALSE)
          ->sort('changed', 'ASC')
          ->range(0, $excess_count)
          ->execute();

        if (!empty($oldest_scan_ids)) {
          $oldest_scans = $storage->loadMultiple($oldest_scan_ids);
          foreach ($oldest_scans as $scan) {
            $scan->delete();
          }
        }
      }
    }
    catch (\Exception $exception) {
      $this->getLogger('siteimprove_accessibility')
        ->error($exception->getMessage());
    }
  }

  /**
   * Deletes orphaned scans.
   */
  private function deleteOrphanedScans(): void {
    try {
      $storage = $this->entityTypeManager->getStorage('alfa_scan');

      $query = $this->connection->select('siteimprove_accessibility_alfa_scan', 'a')
        ->fields('a', ['id']);
      $query->leftJoin('node_field_data', 'n', 'a.nid = n.nid');
      $query->where('n.nid IS NULL');

      $orphaned_scan_ids = $query->execute()->fetchCol();

      if (!empty($orphaned_scan_ids)) {
        foreach (array_chunk($orphaned_scan_ids, 50) as $scan_ids_batch) {
          $orphaned_scans = $storage->loadMultiple($scan_ids_batch);
          foreach ($orphaned_scans as $scan) {
            $scan->delete();
          }
        }

        $this->getLogger('alfa_scan')
          ->notice('Deleted {count} orphaned Alfa Scan records.', [
            'count' => count($orphaned_scan_ids),
          ]);
      }
    }
    catch (\Exception $e) {
      $this->getLogger('alfa_scan')
        ->error('Failed to clean up orphaned Alfa Scans: @message', [
          '@message' => $e->getMessage(),
        ]);
    }
  }

  /**
   * Cleans up AlfaScan records.
   */
  public function cleanUp(): void {
    $this->deleteOrphanedScans();
    $this->enforceScanLimit();
  }

}
