<?php
/**
 * ANCP Core - shared date parsing service.
 *
 * Turns the date strings family historians actually have - GRO quarters
 * ("Q3 1861", "Sep 1861 Q"), approximations ("abt 1790", "c1841"), dual
 * dating ("1751/52"), ranges, plain years, and common day-month-year forms -
 * into a sortable MySQL DATE **plus a precision** stating what the normalised
 * value actually asserts (day|month|quarter|year|range). The VERBATIM string
 * is always stored separately by the caller (spec-00 Section 3); this parser
 * only ever produces the standardised value and returns null rather than
 * guess when it cannot parse.
 *
 * Promoted from the ONS plugin's APONST_Dates in core 0.12.0 (OPS spec v0.2
 * Section 3.6) so every suite plugin parses dates identically. Supersedes the
 * GEDCOM-only Tables::normalise_date() for record-domain strings (its
 * pattern set is a strict subset of this one).
 *
 * @package AncestorPress\Core
 * @since   0.12.0
 */

namespace AncestorPress\Core;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Date string parser.
 *
 * @since 0.12.0
 */
class Dates {

	/**
	 * Month-name map (3-letter prefix match).
	 *
	 * @var array
	 */
	const MONTHS = array(
		'JAN' => 1,
		'FEB' => 2,
		'MAR' => 3,
		'APR' => 4,
		'MAY' => 5,
		'JUN' => 6,
		'JUL' => 7,
		'AUG' => 8,
		'SEP' => 9,
		'OCT' => 10,
		'NOV' => 11,
		'DEC' => 12,
	);

	/**
	 * GRO quarter -> representative month (the LAST month of the quarter,
	 * matching how FreeBMD names them: Mar = Jan-Mar, Jun = Apr-Jun...).
	 *
	 * @var array
	 */
	const QUARTERS = array(
		1 => 3,
		2 => 6,
		3 => 9,
		4 => 12,
	);

	/**
	 * Parse a date string to a sortable DATE plus what it asserts.
	 *
	 * Never throws, never guesses: an unparseable string returns a null date
	 * with precision 'unknown', and the verbatim original (stored by the
	 * caller) remains the only record.
	 *
	 * @since  0.12.0
	 * @param  string $raw           The date exactly as recorded.
	 * @param  bool   $excel_serials Also accept bare Excel serial numbers
	 *                               (import context only - a bare "25000" in
	 *                               manual entry is more likely a typo).
	 * @return array {
	 *     @type string|null $date      'YYYY-MM-DD' or null.
	 *     @type string      $precision 'day'|'month'|'quarter'|'year'|'range'|
	 *                                  'unknown'. What the DATE value asserts;
	 *                                  'unknown' only when date is null.
	 *     @type bool        $qualified True when the string carried an
	 *                                  approximation/qualifier (abt, c., bef,
	 *                                  aft, est, ?, dual dating) - the caller
	 *                                  maps this to a confidence flag.
	 * }
	 */
	public static function analyse( $raw, $excel_serials = false ) {
		$s = trim( (string) $raw );
		if ( '' === $s ) {
			return self::result( null, 'unknown', false );
		}

		// ISO already: YYYY-MM-DD.
		if ( preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $s, $m ) ) {
			return self::result( self::ymd( $m[1], $m[2], $m[3] ), 'day', false );
		}

		// Excel serial (days since 1899-12-30). Only when opted in.
		if ( $excel_serials && preg_match( '/^\d{4,6}$/', $s ) && (int) $s >= 1000 && (int) $s <= 80000 && ( (int) $s < 1500 || (int) $s > 2100 ) ) {
			$ts = ( (int) $s - 25569 ) * DAY_IN_SECONDS; // 25569 = 1970-01-01 as a serial.
			$d  = gmdate( 'Y-m-d', $ts );
			return ( $d >= '1600-01-01' && $d <= '2100-12-31' )
				? self::result( $d, 'day', false )
				: self::result( null, 'unknown', false );
		}

		// Strip approximation/qualifier noise: abt, about, c, c., ca, circa,
		// est, bef, before, aft, after, int, ? - the verbatim string keeps the
		// nuance; the sortable value just needs the date. Seeing one marks the
		// result qualified.
		$clean     = preg_replace( '/\b(abt\.?|about|circa|ca\.?|est\.?|bef\.?|before|aft\.?|after|cal\.?|int\.?)\b/i', ' ', $s );
		$clean     = preg_replace( '/^c\.?\s*(?=\d)/i', '', trim( $clean ) );
		$clean     = trim( str_replace( array( '?', '~' ), ' ', $clean ) );
		$qualified = ( $clean !== $s );

		// BET x AND y / FROM x TO y - a range: normalise on the FIRST date.
		$is_range = false;
		if ( preg_match( '/^(?:bet|between|from)\s+(.+?)\s+(?:and|to)\s+/i', $clean, $m ) ) {
			$clean     = trim( $m[1] );
			$is_range  = true;
			$qualified = true;
		}

		// Dual dating (pre-1752 civil/legal year): "1751/52" or "1751/2" -
		// keep the FIRST year (the as-written primary); flagged, not resolved.
		if ( preg_match( '/^(\d{4})\/\d{1,2}$/', $clean, $m ) ) {
			return self::result( self::ymd( $m[1], 1, 1 ), $is_range ? 'range' : 'year', true );
		}

		// GRO quarter forms - quarter-number first or last, or a month with
		// a quarter word attached, in either order.
		if ( preg_match( '/^Q([1-4])\s+(\d{4})$/i', $clean, $m ) ) {
			return self::result( self::ymd( $m[2], self::QUARTERS[ (int) $m[1] ], 1 ), $is_range ? 'range' : 'quarter', $qualified );
		}
		if ( preg_match( '/^(\d{4})\s+Q([1-4])$/i', $clean, $m ) ) {
			return self::result( self::ymd( $m[1], self::QUARTERS[ (int) $m[2] ], 1 ), $is_range ? 'range' : 'quarter', $qualified );
		}
		if ( preg_match( '/^([a-z]{3,9})\.?\s+(?:q|qtr|quarter)\.?\s+(\d{4})$/i', $clean, $m ) && self::month_num( $m[1] ) ) {
			return self::result( self::ymd( $m[2], self::month_num( $m[1] ), 1 ), $is_range ? 'range' : 'quarter', $qualified );
		}
		if ( preg_match( '/^(\d{4})\s+([a-z]{3,9})\.?\s+(?:q|qtr|quarter)\.?$/i', $clean, $m ) && self::month_num( $m[2] ) ) {
			return self::result( self::ymd( $m[1], self::month_num( $m[2] ), 1 ), $is_range ? 'range' : 'quarter', $qualified );
		}

		// DD MON YYYY / DD Month YYYY (GEDCOM-ish and the common transcription form).
		if ( preg_match( '/^(\d{1,2})\s+([a-z]{3,9})\.?\s+(\d{4})$/i', $clean, $m ) && self::month_num( $m[2] ) ) {
			return self::result( self::ymd( $m[3], self::month_num( $m[2] ), $m[1] ), $is_range ? 'range' : 'day', $qualified );
		}

		// MON DD YYYY (US transcriptions: "Mar 4 1851", "March 4, 1851").
		if ( preg_match( '/^([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(\d{4})$/i', $clean, $m ) && self::month_num( $m[1] ) ) {
			return self::result( self::ymd( $m[3], self::month_num( $m[1] ), $m[2] ), $is_range ? 'range' : 'day', $qualified );
		}

		// MON YYYY.
		if ( preg_match( '/^([a-z]{3,9})\.?\s+(\d{4})$/i', $clean, $m ) && self::month_num( $m[1] ) ) {
			return self::result( self::ymd( $m[2], self::month_num( $m[1] ), 1 ), $is_range ? 'range' : 'month', $qualified );
		}

		// YYYY MON (registers occasionally transcribe year-first).
		if ( preg_match( '/^(\d{4})\s+([a-z]{3,9})\.?$/i', $clean, $m ) && self::month_num( $m[2] ) ) {
			return self::result( self::ymd( $m[1], self::month_num( $m[2] ), 1 ), $is_range ? 'range' : 'month', $qualified );
		}

		// DD/MM/YYYY or DD.MM.YYYY or DD-MM-YYYY (UK order - this is a
		// UK-records domain; US-order files should map via import templates).
		if ( preg_match( '/^(\d{1,2})[\/\.\-](\d{1,2})[\/\.\-](\d{4})$/', $clean, $m ) ) {
			return self::result( self::ymd( $m[3], $m[2], $m[1] ), $is_range ? 'range' : 'day', $qualified );
		}

		// Bare year.
		if ( preg_match( '/^(\d{4})$/', $clean, $m ) ) {
			return self::result( self::ymd( $m[1], 1, 1 ), $is_range ? 'range' : 'year', $qualified );
		}

		return self::result( null, 'unknown', $qualified );
	}

	/**
	 * Parse a date string to a sortable MySQL DATE, or null.
	 *
	 * Thin wrapper over analyse() for callers that only need the value.
	 *
	 * @since  0.12.0
	 * @param  string $raw           The date exactly as recorded.
	 * @param  bool   $excel_serials Also accept bare Excel serial numbers.
	 * @return string|null           'YYYY-MM-DD' or null.
	 */
	public static function parse( $raw, $excel_serials = false ) {
		$r = self::analyse( $raw, $excel_serials );
		return $r['date'];
	}

	/**
	 * Assemble a result triple, forcing 'unknown' precision on a null date.
	 *
	 * @param  string|null $date      Normalised date or null.
	 * @param  string      $precision Precision keyword.
	 * @param  bool        $qualified Qualifier seen.
	 * @return array
	 */
	private static function result( $date, $precision, $qualified ) {
		return array(
			'date'      => $date,
			'precision' => null === $date ? 'unknown' : $precision,
			'qualified' => (bool) $qualified,
		);
	}

	/**
	 * Month number from a (possibly full) month name, or 0.
	 *
	 * @param  string $name Month name or abbreviation.
	 * @return int
	 */
	private static function month_num( $name ) {
		$key = strtoupper( substr( trim( $name ), 0, 3 ) );
		return isset( self::MONTHS[ $key ] ) ? self::MONTHS[ $key ] : 0;
	}

	/**
	 * Validated Y-m-d string, or null for impossible dates.
	 *
	 * @param  int|string $y Year.
	 * @param  int|string $m Month.
	 * @param  int|string $d Day.
	 * @return string|null
	 */
	private static function ymd( $y, $m, $d ) {
		$y = (int) $y;
		$m = (int) $m;
		$d = (int) $d;
		if ( $y < 100 || ! checkdate( $m, $d, $y ) ) {
			return null;
		}
		return sprintf( '%04d-%02d-%02d', $y, $m, $d );
	}
}
