<?php

namespace Drupal\controlled_access_terms;

/**
 * Utility functions for working with Extended Date Time Format.
 */
class EDTFUtils {

  // EDTF Date Parse REGEX Array Positions.
  const DATE_PARSE_REGEX       = '/^([%\?~])?(Y?(-?[\dX]+)(E\d)?(S\d)?)([%\?~])?-?([%\?~])?([\dX]{2})?([%\?~])?-?([%\?~])?([\dX]{2})?([%\?~])?$/';
  const FULL_MATCH             = 0;
  const QUALIFIER_YEAR_ONLY    = 1;
  const YEAR_FULL              = 2;
  const YEAR_BASE              = 3;
  const YEAR_EXPONENT          = 4;
  const YEAR_SIGNIFICANT_DIGIT = 5;
  const QUALIFIER_YEAR         = 6;
  const QUALIFIER_MONTH_ONLY   = 7;
  const MONTH                  = 8;
  const QUALIFIER_MONTH        = 9;
  const QUALIFIER_DAY_ONLY     = 10;
  const DAY                    = 11;
  const QUALIFIER_DAY          = 12;

  /**
   * Month/Season to text map.
   *
   * @var array
   */
  const MONTHS_MAP = [
    '01' => ['mmm' => 'Jan', 'mmmm' => 'January'],
    '02' => ['mmm' => 'Feb', 'mmmm' => 'February'],
    '03' => ['mmm' => 'Mar', 'mmmm' => 'March'],
    '04' => ['mmm' => 'Apr', 'mmmm' => 'April'],
    '05' => ['mmm' => 'May', 'mmmm' => 'May'],
    '06' => ['mmm' => 'Jun', 'mmmm' => 'June'],
    '07' => ['mmm' => 'Jul', 'mmmm' => 'July'],
    '08' => ['mmm' => 'Aug', 'mmmm' => 'August'],
    '09' => ['mmm' => 'Sep', 'mmmm' => 'September'],
    '10' => ['mmm' => 'Oct', 'mmmm' => 'October'],
    '11' => ['mmm' => 'Nov', 'mmmm' => 'November'],
    '12' => ['mmm' => 'Dec', 'mmmm' => 'December'],
    '21' => ['mmm' => 'Spr', 'mmmm' => 'Spring'],
    '22' => ['mmm' => 'Sum', 'mmmm' => 'Summer'],
    '23' => ['mmm' => 'Aut', 'mmmm' => 'Autumn'],
    '24' => ['mmm' => 'Win', 'mmmm' => 'Winter'],
    '25' => ['mmm' => 'Spr', 'mmmm' => 'Spring - Northern Hemisphere'],
    '26' => ['mmm' => 'Sum', 'mmmm' => 'Summer - Northern Hemisphere'],
    '27' => ['mmm' => 'Aut', 'mmmm' => 'Autumn - Northern Hemisphere'],
    '28' => ['mmm' => 'Win', 'mmmm' => 'Winter - Northern Hemisphere'],
    '29' => ['mmm' => 'Spr', 'mmmm' => 'Spring - Southern Hemisphere'],
    '30' => ['mmm' => 'Sum', 'mmmm' => 'Summer - Southern Hemisphere'],
    '31' => ['mmm' => 'Aut', 'mmmm' => 'Autumn - Southern Hemisphere'],
    '32' => ['mmm' => 'Win', 'mmmm' => 'Winter - Southern Hemisphere'],
    '33' => ['mmm' => 'Q1', 'mmmm' => 'Quarter 1'],
    '34' => ['mmm' => 'Q2', 'mmmm' => 'Quarter 2'],
    '35' => ['mmm' => 'Q3', 'mmmm' => 'Quarter 3'],
    '36' => ['mmm' => 'Q4', 'mmmm' => 'Quarter 4'],
    // I'm making up the rest of these abbreviations
    // because I can't find standardized ones.
    '37' => ['mmm' => 'Quad1', 'mmmm' => 'Quadrimester 1'],
    '38' => ['mmm' => 'Quad2', 'mmmm' => 'Quadrimester 2'],
    '39' => ['mmm' => 'Quad3', 'mmmm' => 'Quadrimester 3'],
    '40' => ['mmm' => 'Sem1', 'mmmm' => 'Semestral 1'],
    '41' => ['mmm' => 'Sem2', 'mmmm' => 'Semestral 2'],
  ];

  const SEASONS_MAP = [
    // Northern Hemisphere bias for 21-24.
    '21' => '03',
    '22' => '06',
    '23' => '09',
    '24' => '12',
    // Northern seasons.
    '25' => '03',
    '26' => '06',
    '27' => '09',
    '28' => '12',
    // Southern seasons.
    '29' => '09',
    '30' => '12',
    '31' => '03',
    '32' => '06',
    // Quarters.
    '33' => '01',
    '34' => '04',
    '35' => '07',
    '36' => '10',
    // Quadrimesters.
    '37' => '01',
    '38' => '05',
    '39' => '09',
    // Semesters.
    '40' => '01',
    '41' => '07',
  ];

  /**
   * Southern hemisphere season map.
   *
   * (Currently unused until a config for this is established.)
   *
   * @var array
   */
  private $seasonMapSouth = [
  // Spring => September.
    '21' => '03',
  // Summer => December.
    '22' => '06',
  // Autumn => March.
    '23' => '09',
  // Winter => June.
    '24' => '12',
  ];

  /**
   * Validate an EDTF expression.
   *
   * @param string $edtf_text
   *   The datetime string.
   * @param bool $intervals
   *   Are interval expressions permitted?
   * @param bool $sets
   *   Are set expressions permitted?
   * @param bool $strict
   *   Are only valid calendar dates permitted?
   *
   * @return array
   *   Array of error messages. Valid if empty.
   */
  public static function validate($edtf_text, $intervals = TRUE, $sets = TRUE, $strict = FALSE) {
    if (empty($edtf_text)) {
      return ["Cannot parse empty value."];
    }
    $msgs = [];
    // Sets.
    if ($sets) {
      if (strpos($edtf_text, '[') !== FALSE || strpos($edtf_text, '{') !== FALSE) {
        // Test for valid enclosing characters and valid characters inside.
        $has_match = preg_match('/^([\[\{])[\d\-XYES.,]+([\]\}])$/', $edtf_text, $match);
        if (!$has_match) {
          $msgs[] = "The set is improperly encoded.";
          return $msgs;
        }
        // Test each date in set.
        foreach (preg_split('/(,|\.\.)/', trim($edtf_text, '{}[]')) as $date) {
          if (!empty($date)) {
            $msgs = array_merge($msgs, self::validateDate($date, $strict));
          }
        }
        return $msgs;
      }
    }
    // Intervals.
    if ($intervals && str_contains($edtf_text, '/')) {
      if (strpos($edtf_text, 'T') !== FALSE) {
        $msgs[] = "Date intervals cannot include times.";
      }
      foreach (explode('/', $edtf_text) as $date) {
        if (!empty($date) && !($date == '..')) {
          $msgs = array_merge($msgs, self::validateDate($date, $strict));
        }
      }
      return $msgs;
    }
    // Single date (we assume at this point).
    return self::validateDate($edtf_text, $strict);
  }

  /**
   * Validate a single date.
   *
   * @param string $datetime_str
   *   The datetime string.
   * @param bool $strict
   *   Are only valid calendar dates permitted?
   *
   * @return array
   *   Array of error messages. Valid if empty.
   */
  public static function validateDate($datetime_str, $strict = FALSE) {
    $msgs = [];

    if (strpos($datetime_str, 'T') > -1) {
      [$date, $time] = explode('T', $datetime_str);
    }
    else {
      $date = (string) $datetime_str;
      $time = NULL;
    }

    if (preg_match(self::DATE_PARSE_REGEX, $date, $parsed_date) !== 1 ||
      $date !== $parsed_date[self::FULL_MATCH]) {

      // "Something" is wrong with the provided date if it doesn't round-trip.
      // Includes (non-exhaustive):
      // - no invalid characters present,
      // - two-digit months and days, and
      // - capturing group qualifiers.
      return ["Could not parse the date '$date'."];
    }

    // Year.
    if (strpos($parsed_date[self::YEAR_FULL], 'Y') === 0) {
      if ($strict) {
        $msgs[] = "Extended years are not supported with the 'strict dates' option enabled.";
      }
      // Expand exponents.
      if (!empty($parsed_date[self::YEAR_EXPONENT])) {
        $exponent = intval(substr($parsed_date[self::YEAR_EXPONENT], 1));
        $parsed_date[self::YEAR_BASE] = strval((10 ** $exponent) * intval($parsed_date[self::YEAR_BASE]));
        $parsed_date[self::YEAR_BASE] = self::expandYear($parsed_date[self::YEAR_FULL], $parsed_date[self::YEAR_BASE], $parsed_date[self::YEAR_EXPONENT]);
      }
    }
    elseif (strlen(ltrim($parsed_date[self::YEAR_BASE], '-')) > 4) {
      $msgs[] = "Years longer than 4 digits must be prefixed with a 'Y'.";
    }
    elseif (strlen($parsed_date[self::YEAR_BASE]) < 4) {
      $msgs[] = "Years must be at least 4 characters long.";
    }
    $strict_pattern = 'Y';

    // Month.
    if (array_key_exists(self::MONTH, $parsed_date) && !empty($parsed_date[self::MONTH])) {
      // Valid month values?
      if (
            // Month doesn't exist in mapping
            // and isn't a valid unspecified month value.
            (
              !array_key_exists($parsed_date[self::MONTH], self::MONTHS_MAP) &&
              (intval(str_replace('X', '1', $parsed_date[self::MONTH])) > 12)
            ) ||
            // Sub-year groupings with day values.
            (
              array_key_exists($parsed_date[self::MONTH], self::MONTHS_MAP) &&
              array_key_exists(self::DAY, $parsed_date) &&
              $parsed_date[self::MONTH] > 12
            ) ||
            // Unspecifed character comes before number.
            (
              preg_match('/X+\d/', $parsed_date[self::MONTH])
            )
        ) {
        $msgs[] = "Provided month value '" . $parsed_date[self::MONTH] . "' is not valid.";
      }
      $strict_pattern = 'Y-m';
    }

    // Day.
    if (array_key_exists(self::DAY, $parsed_date) && !empty($parsed_date[self::DAY])) {
      // Valid day values?
      if (strpos($parsed_date[self::DAY], 'X') === FALSE &&
          !in_array(intval($parsed_date[self::DAY]), range(1, 31))) {
        $msgs[] = "Provided day value '" . $parsed_date[self::DAY] . "' is not valid.";
      }
      $strict_pattern = 'Y-m-d';
    }
    // Time.
    if (strpos($datetime_str, 'T') !== FALSE && empty($time)) {
      $msgs[] = "Time not provided with time seperator (T).";
    }

    if ($time) {
      if (!preg_match('/^-?(\d{4})(-\d{2}){2}T\d{2}(:\d{2}){2}(Z|(\+|-)\d{2}:\d{2})?$/', $datetime_str, $matches)) {
        $msgs[] = "The date/time '$datetime_str' is invalid.";
      }
      $strict_pattern = 'Y-m-d\TH:i:s';
      if (count($matches) > 4) {
        if ($matches[4] === 'Z') {
          $strict_pattern .= '\Z';
        }
        else {
          $strict_pattern .= 'P';
        }
      }
    }

    if ($strict) {
      // Assemble the parts again.
      if ($time) {
        $cleaned_datetime = $datetime_str;
      }
      else {
        $cleaned_datetime = implode('-', array_filter([
          $parsed_date[self::YEAR_BASE],
          $parsed_date[self::MONTH],
          $parsed_date[self::DAY],
        ]));
      }
      $datetime_obj = \DateTime::createFromFormat('!' . $strict_pattern, $cleaned_datetime);
      $errors = \DateTime::getLastErrors();
      if (!$datetime_obj ||
          !empty($errors['warning_count']) ||
          // DateTime will create valid dates from Y-m without warning,
          // so validate we still have what it was given.
          !($cleaned_datetime === $datetime_obj->format($strict_pattern))
        ) {
        $msgs[] = "Strictly speaking, the date (and/or time) '$datetime_str' is invalid.";
      }
    }

    return $msgs;
  }

  /**
   * Expand an exponent year.
   *
   * @param string $year_full
   *   The full year expression from the EDTF string.
   * @param string $year_base
   *   The base expression from the EDTF string.
   * @param string $year_exponent
   *   The exponent expression from the EDTF string.
   *
   * @return string
   *   The expanded year value.
   */
  public static function expandYear($year_full, $year_base, $year_exponent) {
    if (!empty($year_exponent)) {
      $exponent = intval(substr($year_exponent, 1));
      return strval((10 ** $exponent) * intval($year_base));
    }
    else {
      return $year_base;
    }
  }

  /**
   * Converts an EDTF string into an ISO 8601 timestamp string.
   *
   * @param string $edtf
   *   The array containing the 'value' element.
   *
   * @return string
   *   Returns the ISO 8601 timestamp.
   */
  public static function iso8601Value(string $edtf) {

    if (count(self::validate($edtf)) > 0) {
      return '';
    }
    // Sets.
    if (strpos($edtf, '[') !== FALSE || strpos($edtf, '{') !== FALSE) {
      // Use first in set.
      $dates = preg_split('/(,|\.\.)/', trim($edtf, '{}[]'));
      return self::iso8601Value(array_shift($dates));
    }
    // Intervals.
    if (str_contains($edtf, '/')) {
      $dates = explode('/', $edtf);
      return self::iso8601Value(array_shift($dates));
    }

    $date_time = explode('T', $edtf);

    // Valid EDTF values with time portions are already ISO 8601 timestamps.
    if (array_key_exists(1, $date_time) && !empty($date_time[1])) {
      return $edtf;
    }

    preg_match(EDTFUtils::DATE_PARSE_REGEX, $date_time[0], $parsed_date);

    $year = '';
    $month = '';
    $day = '';

    // Expand the year if the Year Exponent exists.
    if (array_key_exists(EDTFUtils::YEAR_EXPONENT, $parsed_date) && !empty($parsed_date[EDTFUtils::YEAR_EXPONENT])) {
      $parsed_date[EDTFUtils::YEAR_BASE] = EDTFUtils::expandYear($parsed_date[EDTFUtils::YEAR_FULL], $parsed_date[EDTFUtils::YEAR_BASE], $parsed_date[EDTFUtils::YEAR_EXPONENT]);
    }

    // Clean-up unspecified year/decade.
    $year = str_replace('X', '0', $parsed_date[EDTFUtils::YEAR_BASE]);

    if (array_key_exists(EDTFUtils::MONTH, $parsed_date)) {
      $month = str_replace('XX', '01', $parsed_date[EDTFUtils::MONTH]);
      $month = str_replace('X', '0', $month);

      // ISO 8601 doesn't support seasonal notation yet. Swap them out.
      if (array_key_exists($month, EDTFUtils::SEASONS_MAP)) {
        $month = EDTFUtils::SEASONS_MAP[$month];
      }
    }

    if (array_key_exists(EDTFUtils::DAY, $parsed_date)) {
      $day = str_replace('XX', '01', $parsed_date[EDTFUtils::DAY]);
      $day = str_replace('X', '0', $day);
    }

    return implode('-', array_filter([$year, $month, $day]));

  }

}