<?php
/**
 * Business hours — structured schedule derived from the display string.
 *
 * The wizard and Settings both collect a per-day schedule and then flatten it
 * to a human-readable string. This class turns that string back into a
 * structured schedule so conversation timestamps can be bucketed as in-hours
 * or after-hours.
 *
 * Read-only with respect to existing behavior: the display string is never
 * modified (it feeds the AI prompt), and no save path needs to know the
 * cache here exists — see maybe_refresh_cache().
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @since      1.41.0
 */

if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Structured business hours.
 */
class SiteStaffr_Business_Hours {

	/**
	 * Sentinel returned by parse_legacy_string() for a 24/7 business.
	 *
	 * @var string
	 */
	const ALWAYS_OPEN = 'always_open';

	/**
	 * Option holding the read-through cache.
	 *
	 * Deliberately its own option rather than a key inside sitestaffr_settings.
	 * Several admin AJAX handlers read-modify-write that blob without locking,
	 * and this cache is refreshed from *read* paths — including the public
	 * shareable report. Co-locating them would let a cache refresh clobber a
	 * concurrent settings save.
	 *
	 * @var string
	 */
	const CACHE_OPTION = 'sitestaffr_business_hours_schedule';

	/**
	 * Day keys, Monday-first, matching the order the editors emit.
	 *
	 * @var string[]
	 */
	private static $day_keys = array( 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun' );

	/**
	 * Per-request memo, so get_state() and get_schedule() resolve the cache
	 * once instead of each re-reading (and, on a miss, each re-writing) it.
	 *
	 * @var array|null
	 */
	private static $memo = null;

	/**
	 * Current hours state.
	 *
	 * @return string One of 'configured', 'always_open', 'unknown'.
	 */
	public static function get_state() {
		$cache = self::maybe_refresh_cache();
		return $cache['state'];
	}

	/**
	 * Structured schedule, or null when the state is not 'configured'.
	 *
	 * @return array<string,array>|null
	 */
	public static function get_schedule() {
		$cache = self::maybe_refresh_cache();
		return ( 'configured' === $cache['state'] ) ? $cache['days'] : null;
	}

	/**
	 * The hours display string, settings array first, standalone option as
	 * fallback — matching the convention used for notification_email.
	 *
	 * @return string
	 */
	private static function get_display_string() {
		$settings = get_option( 'sitestaffr_settings', array() );
		if ( is_array( $settings ) && ! empty( $settings['business_hours'] ) ) {
			return trim( (string) $settings['business_hours'] );
		}
		return trim( (string) get_option( 'sitestaffr_business_hours', '' ) );
	}

	/**
	 * Read-through cache with a staleness guard.
	 *
	 * The cache records the exact string it was derived from. If the current
	 * display string differs, the cache is discarded and rebuilt. That makes
	 * it self-correcting against every writer — including the legacy REST
	 * endpoint, which writes business_hours without knowing this cache exists.
	 *
	 * @return array{state:string,days:array,parsed_of:string}
	 */
	private static function maybe_refresh_cache() {
		$display = self::get_display_string();

		if ( is_array( self::$memo ) && self::$memo['parsed_of'] === $display ) {
			return self::$memo;
		}

		$cached = get_option( self::CACHE_OPTION, null );
		if ( is_array( $cached )
			&& isset( $cached['state'], $cached['parsed_of'] )
			&& $cached['parsed_of'] === $display ) {
			if ( ! isset( $cached['days'] ) || ! is_array( $cached['days'] ) ) {
				$cached['days'] = array();
			}
			self::$memo = $cached;
			return $cached;
		}

		$parsed = self::parse_legacy_string( $display );

		if ( self::ALWAYS_OPEN === $parsed ) {
			$fresh = array(
				'state'     => self::ALWAYS_OPEN,
				'days'      => array(),
				'parsed_of' => $display,
			);
		} elseif ( is_array( $parsed ) ) {
			$fresh = array(
				'state'     => 'configured',
				'days'      => $parsed,
				'parsed_of' => $display,
			);
		} else {
			$fresh = array(
				'state'     => 'unknown',
				'days'      => array(),
				'parsed_of' => $display,
			);
		}

		update_option( self::CACHE_OPTION, $fresh, false );
		self::$memo = $fresh;

		return $fresh;
	}

	/**
	 * Parse an hours display string into a structured schedule.
	 *
	 * Returns the ALWAYS_OPEN sentinel for 24/7, a 7-day array when the string
	 * is understood, and null for anything else. It must never guess — an
	 * owner's free-text hours produce null, which surfaces as an empty state
	 * rather than a wrong number on a billing page.
	 *
	 * @param string $string Hours display string.
	 * @return array<string,array>|string|null
	 */
	public static function parse_legacy_string( $string ) {
		$string = trim( (string) $string );
		if ( '' === $string ) {
			return null;
		}

		$normalized = strtolower( $string );

		if ( '24/7' === $normalized || '24-7' === $normalized ) {
			return self::ALWAYS_OPEN;
		}

		// Per-day custom builder output: "Mon: 9:00 AM-5:00 PM, Sat: Closed".
		if ( false !== strpos( $string, ':' ) && preg_match( '/\b(mon|tue|wed|thu|fri|sat|sun)\s*:/i', $string ) ) {
			return self::parse_per_day( $string );
		}

		// Weekday presets: "Mon-Fri 9am-5pm, Sat-Sun Closed" and
		// "Monday-Friday 9am-5pm".
		if ( preg_match( '/(mon|monday)\s*-\s*(fri|friday)\s+(.+?)(?:,|$)/i', $string, $m ) ) {
			$range = self::parse_time_range( $m[3] );
			if ( null === $range ) {
				return null;
			}
			$schedule = array();
			foreach ( self::$day_keys as $day ) {
				$is_weekday       = ! in_array( $day, array( 'sat', 'sun' ), true );
				$schedule[ $day ] = $is_weekday
					? array(
						'open'  => true,
						'start' => $range['start'],
						'end'   => $range['end'],
					)
					: array(
						'open'  => false,
						'start' => '',
						'end'   => '',
					);
			}
			return $schedule;
		}

		return null;
	}

	/**
	 * Parse the per-day builder format.
	 *
	 * Every day must be accounted for; a partial string is treated as
	 * unparseable rather than silently defaulting the missing days.
	 *
	 * @param string $string Hours display string.
	 * @return array<string,array>|null
	 */
	private static function parse_per_day( $string ) {
		$schedule = array();

		foreach ( self::$day_keys as $day ) {
			if ( ! preg_match( '/\b' . $day . '\s*:\s*([^,]+)/i', $string, $m ) ) {
				return null;
			}

			$value = trim( $m[1] );
			if ( 0 === strcasecmp( $value, 'closed' ) ) {
				$schedule[ $day ] = array(
					'open'  => false,
					'start' => '',
					'end'   => '',
				);
				continue;
			}

			$range = self::parse_time_range( $value );
			if ( null === $range ) {
				return null;
			}
			$schedule[ $day ] = array(
				'open'  => true,
				'start' => $range['start'],
				'end'   => $range['end'],
			);
		}

		return $schedule;
	}

	/**
	 * Parse "9:00 AM-5:00 PM" or "9am-5pm" into 24-hour start/end.
	 *
	 * @param string $value Time range.
	 * @return array{start:string,end:string}|null
	 */
	private static function parse_time_range( $value ) {
		$parts = explode( '-', str_replace( '–', '-', trim( $value ) ) );
		if ( 2 !== count( $parts ) ) {
			return null;
		}

		$start = self::parse_clock_time( $parts[0] );
		$end   = self::parse_clock_time( $parts[1] );

		if ( null === $start || null === $end ) {
			return null;
		}

		return array(
			'start' => $start,
			'end'   => $end,
		);
	}

	/**
	 * Parse a single clock time into "HH:MM".
	 *
	 * @param string $value Clock time such as "9:00 AM", "9am", "17:00".
	 * @return string|null
	 */
	private static function parse_clock_time( $value ) {
		$value = strtolower( trim( $value ) );

		if ( ! preg_match( '/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/', $value, $m ) ) {
			return null;
		}

		$hour   = (int) $m[1];
		$minute = isset( $m[2] ) && '' !== $m[2] ? (int) $m[2] : 0;
		$suffix = isset( $m[3] ) ? $m[3] : '';

		if ( 'pm' === $suffix && $hour < 12 ) {
			$hour += 12;
		} elseif ( 'am' === $suffix && 12 === $hour ) {
			$hour = 0;
		}

		if ( $hour > 23 || $minute > 59 ) {
			return null;
		}

		return sprintf( '%02d:%02d', $hour, $minute );
	}

	/**
	 * Is this local moment outside the schedule?
	 *
	 * Start is inclusive, end exclusive. A window whose end is at or before
	 * its start wraps midnight.
	 *
	 * @param DateTimeImmutable   $local_dt Moment in the site timezone.
	 * @param array<string,array> $schedule Structured schedule.
	 * @return bool
	 */
	public static function is_after_hours( $local_dt, $schedule ) {
		if ( ! is_array( $schedule ) || empty( $schedule ) ) {
			return false;
		}

		$day_index = (int) $local_dt->format( 'N' ); // 1 = Monday.
		$day_key   = self::$day_keys[ $day_index - 1 ];

		if ( empty( $schedule[ $day_key ]['open'] ) ) {
			return true;
		}

		$now   = $local_dt->format( 'H:i' );
		$start = $schedule[ $day_key ]['start'];
		$end   = $schedule[ $day_key ]['end'];

		if ( '' === $start || '' === $end ) {
			return true;
		}

		if ( $end <= $start ) {
			// Wraps midnight.
			return ! ( $now >= $start || $now < $end );
		}

		return ! ( $now >= $start && $now < $end );
	}
}
