<?php
/**
 * Hours when messages should not arrive.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Consent;

use DateTimeImmutable;
use DateTimeZone;
use TextTango\Support\Clock;
use TextTango\Support\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * Holds messages back until a civil hour (spec §22).
 *
 * A quiet window is **held, not dropped**. An order update that would land at
 * 03:00 is worth delivering at 08:00; throwing it away would lose the customer
 * information they need. So a message caught by the window is queued with a
 * delay rather than skipped.
 *
 * The interesting case is the ordinary one: a window like 21:00–08:00 wraps
 * past midnight, so "is now between start and end" is false for every hour it
 * actually covers. Both orientations are handled explicitly below.
 *
 * Times are read in the site's timezone. Per-recipient timezones are a later
 * refinement — knowing them means knowing where each customer is, which this
 * plugin does not.
 */
final class QuietHours {

	/**
	 * Whether the window is switched on.
	 */
	public static function is_enabled(): bool {
		return (bool) Settings::get( 'quiet_hours_enabled', false );
	}

	/**
	 * Whether an event is subject to the window.
	 *
	 * Marketing always is. Order updates are only if the merchant asks, because
	 * a delayed dispatch notice is less useful than a prompt one. Codes are never
	 * held — somebody is waiting on the screen for them.
	 *
	 * @param string $event Event id.
	 */
	public static function applies_to( string $event ): bool {
		if ( ! self::is_enabled() || in_array( $event, Consent::ALWAYS_ALLOWED, true ) ) {
			return false;
		}

		if ( Consent::is_marketing( $event ) ) {
			return true;
		}

		return (bool) Settings::get( 'quiet_hours_include_transactional', false );
	}

	/**
	 * How long a message must wait, in seconds. Zero means send now.
	 *
	 * @param string   $event     Event id.
	 * @param int|null $timestamp Moment to judge; defaults to now.
	 */
	public static function delay_for( string $event, ?int $timestamp = null ): int {
		if ( ! self::applies_to( $event ) ) {
			return 0;
		}

		$now     = $timestamp ?? Clock::now();
		$verdict = self::evaluate( $now, self::timezone(), self::start(), self::end() );

		return $verdict['quiet'] ? max( 0, $verdict['opens_at'] - $now ) : 0;
	}

	/**
	 * Whether a moment falls inside the window.
	 *
	 * @param int|null $timestamp Moment to judge; defaults to now.
	 */
	public static function is_quiet( ?int $timestamp = null ): bool {
		if ( ! self::is_enabled() ) {
			return false;
		}

		return self::evaluate( $timestamp ?? Clock::now(), self::timezone(), self::start(), self::end() )['quiet'];
	}

	/**
	 * Decide whether a moment is quiet, and when the window next opens.
	 *
	 * Pure, so the wrap-past-midnight case can be tested at every boundary
	 * without depending on the clock or the site's settings.
	 *
	 * @param int    $timestamp Unix timestamp to judge.
	 * @param string $timezone  Timezone the window is expressed in.
	 * @param int    $start     Window start, in minutes past midnight.
	 * @param int    $end       Window end, in minutes past midnight.
	 * @return array{quiet: bool, opens_at: int}
	 */
	public static function evaluate( int $timestamp, string $timezone, int $start, int $end ): array {
		// A zero-length window covers nothing; treat it as switched off rather
		// than as covering the whole day.
		if ( $start === $end ) {
			return array(
				'quiet'    => false,
				'opens_at' => $timestamp,
			);
		}

		try {
			$zone = new DateTimeZone( $timezone );
		} catch ( \Exception $e ) {
			$zone = new DateTimeZone( 'UTC' );
		}

		$moment  = ( new DateTimeImmutable( '@' . $timestamp ) )->setTimezone( $zone );
		$minutes = ( (int) $moment->format( 'H' ) ) * 60 + (int) $moment->format( 'i' );

		$wraps = $start > $end;
		$quiet = $wraps
			// 21:00–08:00: quiet late in the evening *or* early in the morning.
			? ( $minutes >= $start || $minutes < $end )
			// 01:00–06:00: quiet only between the two.
			: ( $minutes >= $start && $minutes < $end );

		if ( ! $quiet ) {
			return array(
				'quiet'    => false,
				'opens_at' => $timestamp,
			);
		}

		$opens = $moment->setTime( intdiv( $end, 60 ), $end % 60, 0 );

		// Still late in the evening of a wrapping window, so the window opens
		// tomorrow morning rather than earlier today.
		if ( $opens->getTimestamp() <= $timestamp ) {
			$opens = $opens->modify( '+1 day' );
		}

		return array(
			'quiet'    => true,
			'opens_at' => $opens->getTimestamp(),
		);
	}

	/**
	 * Window start, in minutes past midnight.
	 */
	public static function start(): int {
		return self::to_minutes( (string) Settings::get( 'quiet_hours_start', '21:00' ), 21 * 60 );
	}

	/**
	 * Window end, in minutes past midnight.
	 */
	public static function end(): int {
		return self::to_minutes( (string) Settings::get( 'quiet_hours_end', '08:00' ), 8 * 60 );
	}

	/**
	 * The site's timezone name.
	 */
	public static function timezone(): string {
		return wp_timezone_string();
	}

	/**
	 * Parse `HH:MM` into minutes past midnight.
	 *
	 * @param string $value    Time as stored.
	 * @param int    $fallback Minutes to use when it cannot be read.
	 */
	public static function to_minutes( string $value, int $fallback ): int {
		if ( 1 !== preg_match( '/^([01]?\d|2[0-3]):([0-5]\d)$/', trim( $value ), $parts ) ) {
			return $fallback;
		}

		return (int) $parts[1] * 60 + (int) $parts[2];
	}

	/**
	 * Format minutes past midnight back to `HH:MM`.
	 *
	 * @param int $minutes Minutes past midnight.
	 */
	public static function to_time( int $minutes ): string {
		return sprintf( '%02d:%02d', intdiv( $minutes, 60 ) % 24, $minutes % 60 );
	}
}
