<?php

namespace SugarCalendarBookings\Appointments;

/**
 * Service for generating and verifying secure cancellation tokens.
 *
 * @since 1.1.0
 */
class CancellationTokenService {

	/**
	 * Grace-window length, in days, during which legacy (pre-1.4.1) tokens
	 * are still accepted alongside the new HMAC tokens. Allows already-sent
	 * confirmation emails to remain usable while in-flight bookings roll over.
	 *
	 * @since 1.4.1
	 *
	 * @var int
	 */
	const LEGACY_TOKEN_GRACE_DAYS = 14;

	/**
	 * Generate a secure cancellation token for an appointment.
	 *
	 * The token is HMAC-SHA256(code|id|start_at, wp_salt()) truncated to 24
	 * characters. Binding to wp_salt() makes the token cryptographically
	 * unforgeable from leaked appointment fields alone — only an attacker
	 * who has the site's wp_salt() (which would already imply full DB
	 * compromise) can compute the token.
	 *
	 * Including start_at ensures token invalidates if appointment is rescheduled.
	 *
	 * @since 1.1.0
	 * @since 1.4.1 Switched from hash() to hash_hmac() with wp_salt() — see
	 *              verify_cancellation_token() for the legacy-token grace window.
	 *
	 * @param AppointmentModel $appointment Appointment model.
	 *
	 * @return string Cancellation token (24 characters).
	 */
	public function generate_cancellation_token( AppointmentModel $appointment ): string {

		$data = $this->get_token_data( $appointment );

		return substr( hash_hmac( 'sha256', $data, wp_salt() ), 0, 24 );
	}

	/**
	 * Generate the legacy (pre-1.4.1) cancellation token for an appointment.
	 *
	 * Kept for the grace-window dual-verify path below. Do not call directly.
	 *
	 * @since 1.4.1
	 *
	 * @param AppointmentModel $appointment Appointment model.
	 *
	 * @return string Legacy cancellation token (24 characters).
	 */
	private function generate_legacy_cancellation_token( AppointmentModel $appointment ): string {

		return substr( hash( 'sha256', $this->get_token_data( $appointment ) ), 0, 24 );
	}

	/**
	 * Build the token derivation string for an appointment.
	 *
	 * @since 1.4.1
	 *
	 * @param AppointmentModel $appointment Appointment model.
	 *
	 * @return string
	 */
	private function get_token_data( AppointmentModel $appointment ): string {

		return sprintf(
			'%s|%d|%s',
			$appointment->code,
			$appointment->id,
			$appointment->start_at->format( 'Y-m-d H:i:s' )
		);
	}

	/**
	 * Verify a cancellation token for an appointment.
	 *
	 * Accepts the new HMAC token. Also accepts the legacy SHA-256 token for
	 * appointments whose start_at falls inside the grace window — confirmation
	 * emails already in customers' inboxes keep working until their booking
	 * time has passed (after which the token is no longer useful regardless).
	 *
	 * @since 1.1.0
	 * @since 1.4.1 Added grace-window dual-verify for legacy tokens.
	 *
	 * @param string           $token       Token to verify.
	 * @param AppointmentModel $appointment Appointment model.
	 *
	 * @return bool True if token is valid, false otherwise.
	 */
	public function verify_cancellation_token( string $token, AppointmentModel $appointment ): bool {

		// Constant-time check against the current HMAC token.
		if ( hash_equals( $this->generate_cancellation_token( $appointment ), $token ) ) {
			return true;
		}

		// Grace-window check: accept legacy tokens for bookings whose start
		// is within LEGACY_TOKEN_GRACE_DAYS of the cut-over. Pre-existing
		// confirmation emails for bookings further out should be regenerated.
		$cutoff_seconds = self::LEGACY_TOKEN_GRACE_DAYS * DAY_IN_SECONDS;
		$start_ts       = $appointment->start_at->getTimestamp();

		if ( $start_ts > time() && ( $start_ts - time() ) <= $cutoff_seconds ) {
			return hash_equals( $this->generate_legacy_cancellation_token( $appointment ), $token );
		}

		return false;
	}

	/**
	 * Encode appointment ID for URL parameter.
	 *
	 * @since 1.1.0
	 *
	 * @param int $appointment_id Appointment ID.
	 *
	 * @return string Encoded appointment ID.
	 */
	public function encode_appointment_id( int $appointment_id ): string {

		// Simple encoding: base64 with URL-safe characters.
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
		return rtrim( strtr( base64_encode( (string) $appointment_id ), '+/', '-_' ), '=' );
	}

	/**
	 * Decode appointment ID from URL parameter.
	 *
	 * @since 1.1.0
	 *
	 * @param string $encoded_id Encoded appointment ID.
	 *
	 * @return int|null Decoded appointment ID or null if invalid.
	 */
	public function decode_appointment_id( string $encoded_id ): ?int {

		// Decode base64 URL-safe string.
		// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
		$decoded = base64_decode( strtr( $encoded_id, '-_', '+/' ), true );

		if ( $decoded === false || ! is_numeric( $decoded ) ) {
			return null;
		}

		return (int) $decoded;
	}

	/**
	 * Generate cancellation URL for an appointment.
	 *
	 * @since 1.1.0
	 *
	 * @param AppointmentModel $appointment Appointment model.
	 *
	 * @return string Cancellation URL.
	 */
	public function generate_cancellation_url( AppointmentModel $appointment ): string {

		$token      = $this->generate_cancellation_token( $appointment );
		$encoded_id = $this->encode_appointment_id( $appointment->id );

		$url = add_query_arg(
			[
				'scbookings_cancel_appointment' => '1',
				'id'                            => $encoded_id,
				'token'                         => $token,
			],
			home_url( '/' )
		);

		return $url;
	}
}
