<?php
/**
 * BitForgeNet Guest Tickets - Shared helpers: money, dates, references, arrays.
 *
 * @package BitForgeNet_Guest_Tickets
 * @since   1.0.0
 */

defined( 'ABSPATH' ) || exit;

// This file talks to the plugin's own tables. WordPress has no API for them --
// they are not posts and not meta -- so $wpdb is the only way in. The rows
// change on every booking and every scan, which makes an object cache wrong
// more often than it is right. Table names are built from $wpdb->prefix and
// never from a request, so interpolating one carries nothing to inject; every
// value goes through $wpdb->prepare(), and the few identifiers that cannot
// (ORDER BY columns) are matched against a whitelist first.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter


/**
 * Static utility helpers.
 */
class BFNET_Util {

	/**
	 * Supported currencies with their display symbol.
	 *
	 * @return array
	 */
	public static function currencies() {
		return apply_filters(
			'bfnet_currencies',
			array(
				'CAD' => array( 'symbol' => '$', 'label' => 'Canadian Dollar (CAD)' ),
				'USD' => array( 'symbol' => '$', 'label' => 'US Dollar (USD)' ),
				'EUR' => array( 'symbol' => '€', 'label' => 'Euro (EUR)' ),
				'MAD' => array( 'symbol' => 'DH', 'label' => 'Moroccan Dirham (MAD)' ),
				'GBP' => array( 'symbol' => '£', 'label' => 'Pound Sterling (GBP)' ),
				'CHF' => array( 'symbol' => 'CHF', 'label' => 'Swiss Franc (CHF)' ),
				'AED' => array( 'symbol' => 'AED', 'label' => 'UAE Dirham (AED)' ),
			)
		);
	}

	/**
	 * Format an amount held in minor units.
	 *
	 * @param int    $cents    Amount in cents.
	 * @param string $currency ISO code; defaults to the configured currency.
	 * @return string
	 */
	public static function money( $cents, $currency = '' ) {
		$currency   = $currency ? strtoupper( $currency ) : (string) BFNET_Settings::get( 'currency', 'CAD' );
		$currencies = self::currencies();
		$symbol     = isset( $currencies[ $currency ] ) ? $currencies[ $currency ]['symbol'] : $currency;

		$decimals = (int) BFNET_Settings::get( 'decimals', 2 );
		$amount   = number_format(
			( (int) $cents ) / 100,
			$decimals,
			(string) BFNET_Settings::get( 'decimal_separator', '.' ),
			(string) BFNET_Settings::get( 'thousand_separator', ' ' )
		);

		switch ( (string) BFNET_Settings::get( 'currency_position', 'suffix_space' ) ) {
			case 'prefix':
				return $symbol . $amount;
			case 'prefix_space':
				return $symbol . ' ' . $amount;
			case 'suffix':
				return $amount . $symbol;
			default:
				return $amount . ' ' . $symbol;
		}
	}

	/**
	 * Convert a decimal price string to integer cents.
	 *
	 * @param string|float $value Raw price.
	 * @return int
	 */
	public static function to_cents( $value ) {
		$value = str_replace( array( ' ', ',' ), array( '', '.' ), (string) $value );
		$value = preg_replace( '/[^0-9.\-]/', '', $value );
		return (int) round( ( (float) $value ) * 100 );
	}

	/* --------------------------------------------------------------------- */
	/* Time                                                                  */
	/* --------------------------------------------------------------------- */

	/**
	 * The site's timezone identifier, e.g. Europe/Madrid.
	 *
	 * @return string
	 */
	public static function site_timezone() {
		if ( function_exists( 'wp_timezone_string' ) ) {
			$tz = wp_timezone_string();
			if ( $tz ) {
				return $tz;
			}
		}
		$tz = get_option( 'timezone_string' );
		return $tz ? $tz : 'UTC';
	}

	/**
	 * The timezone an event actually happens in.
	 *
	 * Times are stored exactly as the organizer typed them — a 21:00 door time is the
	 * string "21:00:00" — and this tells us which wall clock that reading belongs to.
	 * Everything else (countdowns, check-in windows, "starts in 3 hours") is derived
	 * from the absolute instant those two values produce together, so a visitor in
	 * abroad and an agent at the door always agree on when it opens.
	 *
	 * @param object|null $event Event row.
	 * @return string
	 */
	public static function event_timezone( $event = null ) {
		if ( $event && ! empty( $event->timezone ) ) {
			$tz = (string) $event->timezone;
			if ( in_array( $tz, timezone_identifiers_list(), true ) ) {
				return $tz;
			}
		}
		return self::site_timezone();
	}

	/**
	 * Turn a stored wall-clock string into an absolute UTC timestamp.
	 *
	 * @param string $mysql Datetime string as stored.
	 * @param string $tz    Timezone identifier the reading belongs to.
	 * @return int|null Unix timestamp, or null when unusable.
	 */
	public static function ts( $mysql, $tz = '' ) {
		if ( empty( $mysql ) || '0000-00-00 00:00:00' === $mysql ) {
			return null;
		}

		$tz = $tz ? $tz : self::site_timezone();

		try {
			$dt = new DateTimeImmutable( str_replace( 'T', ' ', (string) $mysql ), new DateTimeZone( $tz ) );
		} catch ( Exception $e ) {
			return null;
		}

		return $dt->getTimestamp();
	}

	/**
	 * Format a stored datetime, in the timezone it belongs to.
	 *
	 * @param string $mysql  Datetime string.
	 * @param string $format Optional PHP date format.
	 * @param string $tz     Timezone identifier; defaults to the site's.
	 * @return string
	 */
	public static function date( $mysql, $format = '', $tz = '' ) {
		$ts = self::ts( $mysql, $tz );
		if ( null === $ts ) {
			return '—';
		}

		$format = $format ? $format : get_option( 'date_format' ) . ' ' . get_option( 'time_format' );
		$tz     = $tz ? $tz : self::site_timezone();

		return wp_date( $format, $ts, new DateTimeZone( $tz ) );
	}

	/**
	 * Format an event datetime in the event's own timezone.
	 *
	 * @param object $event  Event row.
	 * @param string $field  Column name (starts_at, ends_at, doors_open_at).
	 * @param string $format PHP date format.
	 * @return string
	 */
	public static function event_date( $event, $field = 'starts_at', $format = '' ) {
		if ( ! $event || empty( $event->{$field} ) ) {
			return '—';
		}
		return self::date( $event->{$field}, $format, self::event_timezone( $event ) );
	}

	/**
	 * Absolute timestamp of an event datetime.
	 *
	 * @param object $event Event row.
	 * @param string $field Column name.
	 * @return int|null
	 */
	public static function event_ts( $event, $field = 'starts_at' ) {
		if ( ! $event || empty( $event->{$field} ) ) {
			return null;
		}
		return self::ts( $event->{$field}, self::event_timezone( $event ) );
	}

	/**
	 * Short timezone label for display, e.g. MDT.
	 *
	 * @param string $mysql Datetime the label applies to (DST varies by date).
	 * @param string $tz    Timezone identifier.
	 * @return string
	 */
	public static function tz_abbr( $mysql, $tz = '' ) {
		$tz = $tz ? $tz : self::site_timezone();
		$ts = self::ts( $mysql, $tz );

		if ( null === $ts ) {
			return '';
		}

		try {
			$dt = new DateTimeImmutable( '@' . $ts );
			$dt = $dt->setTimezone( new DateTimeZone( $tz ) );
		} catch ( Exception $e ) {
			return '';
		}

		$abbr = $dt->format( 'T' );

		// PHP returns a raw offset such as "-06" for some zones; make it readable.
		if ( preg_match( '/^[+\-]/', $abbr ) ) {
			return 'UTC' . $dt->format( 'P' );
		}

		return $abbr;
	}

	/**
	 * Human readable "in 3 days" / "2 hours ago", computed from the absolute instant.
	 *
	 * @param string $mysql Datetime string.
	 * @param string $tz    Timezone the reading belongs to.
	 * @return string
	 */
	public static function relative( $mysql, $tz = '' ) {
		$ts = self::ts( $mysql, $tz );
		if ( null === $ts ) {
			return '';
		}

		$now = time();

		if ( $ts >= $now ) {
			/* translators: %s: human readable time difference. */
			return sprintf( __( 'in %s', 'bitforgenet-guest-tickets' ), human_time_diff( $now, $ts ) );
		}

		/* translators: %s: human readable time difference. */
		return sprintf( __( '%s ago', 'bitforgenet-guest-tickets' ), human_time_diff( $ts, $now ) );
	}

	/**
	 * Timezone identifiers grouped for a <select>, with the site's zone first.
	 *
	 * @return array
	 */
	public static function timezone_choices() {
		$site   = self::site_timezone();
		$common = array(
			'Europe/London',
			'Europe/Paris',
			'Europe/Madrid',
			'Europe/Berlin',
			'Europe/Rome',
			'Europe/Amsterdam',
			'Europe/Lisbon',
			'America/New_York',
			'America/Chicago',
			'America/Denver',
			'America/Los_Angeles',
			'America/Toronto',
			'America/Vancouver',
			'America/Montreal',
			'Africa/Casablanca',
			'Asia/Dubai',
			'UTC',
		);

		$all     = timezone_identifiers_list();
		$ordered = array();

		if ( in_array( $site, $all, true ) ) {
			$ordered[] = $site;
		}
		foreach ( $common as $tz ) {
			if ( $tz !== $site && in_array( $tz, $all, true ) ) {
				$ordered[] = $tz;
			}
		}
		foreach ( $all as $tz ) {
			if ( ! in_array( $tz, $ordered, true ) ) {
				$ordered[] = $tz;
			}
		}

		return $ordered;
	}

	/**
	 * Generate a v4 UUID.
	 *
	 * @return string
	 */
	public static function uuid() {
		if ( function_exists( 'wp_generate_uuid4' ) ) {
			return wp_generate_uuid4();
		}
		$data    = random_bytes( 16 );
		$data[6] = chr( ( ord( $data[6] ) & 0x0f ) | 0x40 );
		$data[8] = chr( ( ord( $data[8] ) & 0x3f ) | 0x80 );
		return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
	}

	/**
	 * Build a unique, human-friendly order reference.
	 *
	 * @param int $event_id Event id.
	 * @return string
	 */
	public static function order_reference( $event_id ) {
		global $wpdb;

		$table  = BFNET_Install::table( 'orders' );
		$prefix = strtoupper( (string) BFNET_Settings::get( 'ticket_prefix', 'BFNET' ) );
		$year   = wp_date( 'Y' );

		for ( $attempt = 0; $attempt < 12; $attempt++ ) {
			$candidate = sprintf( '%s-%s-%s', $prefix, $year, strtoupper( wp_generate_password( 6, false, false ) ) );
			$exists    = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE reference = %s", $candidate ) ); // phpcs:ignore WordPress.DB.PreparedSQL
			if ( ! $exists ) {
				return $candidate;
			}
		}

		return sprintf( '%s-%s-%d-%s', $prefix, $year, (int) $event_id, strtoupper( substr( md5( uniqid( '', true ) ), 0, 8 ) ) );
	}

	/**
	 * Build a unique ticket serial such as BFNET-LN01-000042.
	 *
	 * @param object $event    Event row.
	 * @param int    $sequence Sequence number within the event.
	 * @return string
	 */
	public static function ticket_serial( $event, $sequence ) {
		$prefix = ! empty( $event->ticket_prefix ) ? $event->ticket_prefix : strtoupper( (string) BFNET_Settings::get( 'ticket_prefix', 'BFNET' ) );
		$prefix = strtoupper( preg_replace( '/[^A-Z0-9\-]/i', '', $prefix ) );
		$prefix = $prefix ? $prefix : 'BFNET';

		return sprintf( '%s-%06d', $prefix, (int) $sequence );
	}

	/**
	 * Safe JSON decode returning an array.
	 *
	 * @param string $json JSON string.
	 * @return array
	 */
	public static function json( $json ) {
		if ( empty( $json ) ) {
			return array();
		}
		$data = json_decode( (string) $json, true );
		return is_array( $data ) ? $data : array();
	}

	/**
	 * Status labels for orders.
	 *
	 * @return array
	 */
	public static function payment_statuses() {
		return array(
			'pending'   => __( 'Awaiting payment', 'bitforgenet-guest-tickets' ),
			'paid'      => __( 'Paid', 'bitforgenet-guest-tickets' ),
			'cancelled' => __( 'Cancelled', 'bitforgenet-guest-tickets' ),
			'refunded'  => __( 'Refunded', 'bitforgenet-guest-tickets' ),
			'expired'   => __( 'Expired', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * Status labels for events.
	 *
	 * @return array
	 */
	public static function event_statuses() {
		return array(
			'draft'          => __( 'Draft', 'bitforgenet-guest-tickets' ),
			'published'      => __( 'Published — registration closed', 'bitforgenet-guest-tickets' ),
			'open'           => __( 'Registration open', 'bitforgenet-guest-tickets' ),
			'sold_out'       => __( 'Sold out', 'bitforgenet-guest-tickets' ),
			'closed'         => __( 'Registration closed', 'bitforgenet-guest-tickets' ),
			'cancelled'      => __( 'Cancelled', 'bitforgenet-guest-tickets' ),
			'archived'       => __( 'Archived', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * Status labels for tickets.
	 *
	 * @return array
	 */
	public static function ticket_statuses() {
		return array(
			'issued'   => __( 'Issued', 'bitforgenet-guest-tickets' ),
			'used'     => __( 'Checked in', 'bitforgenet-guest-tickets' ),
			'void'     => __( 'Void', 'bitforgenet-guest-tickets' ),
			'refunded' => __( 'Refunded', 'bitforgenet-guest-tickets' ),
		);
	}

	/**
	 * Colour for a status badge.
	 *
	 * @param string $status Status slug.
	 * @return string CSS colour.
	 */
	public static function status_color( $status ) {
		$map = array(
			'paid'      => '#1E8E3E',
			'used'      => '#1E8E3E',
			'open'      => '#1E8E3E',
			'pending'   => '#B26B00',
			'issued'    => '#1A73E8',
			'published' => '#1A73E8',
			'draft'     => '#5F6368',
			'closed'    => '#5F6368',
			'archived'  => '#5F6368',
			'sold_out'  => '#8430CE',
			'cancelled' => '#C5221F',
			'refunded'  => '#C5221F',
			'void'      => '#C5221F',
			'expired'   => '#C5221F',
		);
		return isset( $map[ $status ] ) ? $map[ $status ] : '#5F6368';
	}

	/**
	 * Truncate a string on a word boundary.
	 *
	 * @param string $text   Source text.
	 * @param int    $length Maximum length.
	 * @return string
	 */
	public static function excerpt( $text, $length = 120 ) {
		$text = wp_strip_all_tags( (string) $text );
		if ( mb_strlen( $text ) <= $length ) {
			return $text;
		}
		return rtrim( mb_substr( $text, 0, $length ), " \t\n\r\0\x0B.,;:" ) . '…';
	}

	/**
	 * Full name from first/last parts.
	 *
	 * @param string $first First name.
	 * @param string $last  Last name.
	 * @return string
	 */
	public static function full_name( $first, $last ) {
		return trim( trim( (string) $first ) . ' ' . trim( (string) $last ) );
	}

	/* --------------------------------------------------------------------- */
	/* Booking page                                                          */
	/* --------------------------------------------------------------------- */

	/**
	 * The page that carries the booking shortcode.
	 *
	 * Falls back to searching the site for [bitforgenet_registration] and remembers what it
	 * finds, so a direct event link keeps working even when nobody set the option. This
	 * is what stopped shared links landing on an empty home page.
	 *
	 * @return int Page id, or 0 when nothing matches.
	 */
	public static function registration_page_id() {
		$configured = (int) BFNET_Settings::get( 'registration_page_id', 0 );

		if ( $configured && 'publish' === get_post_status( $configured ) ) {
			return $configured;
		}

		$cached = get_transient( 'bfnet_booking_page_id' );
		if ( false !== $cached ) {
			return (int) $cached;
		}

		$found = self::find_shortcode_page();

		set_transient( 'bfnet_booking_page_id', $found, HOUR_IN_SECONDS );

		// Remember it so the admin screens stop nagging.
		if ( $found && ! $configured ) {
			BFNET_Settings::save( array( 'registration_page_id' => $found ) );
		}

		return $found;
	}

	/**
	 * Search published content for the booking shortcode.
	 *
	 * @return int
	 */
	private static function find_shortcode_page() {
		global $wpdb;

		$like = '%' . $wpdb->esc_like( '[bitforgenet_registration' ) . '%';

		$id = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				 WHERE post_status = 'publish' AND post_type IN ('page','post') AND post_content LIKE %s
				 ORDER BY post_type = 'page' DESC, ID ASC LIMIT 1",
				$like
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL

		if ( $id ) {
			return $id;
		}

		// Page builders often store content outside post_content.
		$id = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT post_id FROM {$wpdb->postmeta} m
				 INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id
				 WHERE p.post_status = 'publish' AND m.meta_value LIKE %s LIMIT 1",
				$like
			)
		); // phpcs:ignore WordPress.DB.PreparedSQL

		return $id;
	}

	/**
	 * Public URL of the booking page.
	 *
	 * @return string
	 */
	public static function registration_page_url() {
		$id = self::registration_page_id();

		if ( $id ) {
			$url = get_permalink( $id );
			if ( $url ) {
				return $url;
			}
		}

		return home_url( '/' );
	}

	/**
	 * Shareable link that opens one event's booking form directly.
	 *
	 * @param object $event Event row.
	 * @return string
	 */
	public static function event_url( $event ) {
		if ( ! $event ) {
			return self::registration_page_url();
		}
		return add_query_arg( 'bfnet_event', $event->slug, self::registration_page_url() );
	}

	/**
	 * Forget the cached booking page (called when a page is saved).
	 *
	 * @return void
	 */
	public static function flush_page_cache() {
		delete_transient( 'bfnet_booking_page_id' );
	}

	/**
	 * Legal documents to link next to the consent checkbox.
	 *
	 * An event may override the site-wide pages; a raw URL in Settings still works as a
	 * last resort so nothing breaks for sites configured before page pickers existed.
	 *
	 * @param object|null $event Event row.
	 * @return array List of ['label' => string, 'url' => string].
	 */
	public static function legal_links( $event = null ) {
		$links = array();

		$terms_page = 0;
		if ( $event && ! empty( $event->terms_page_id ) ) {
			$terms_page = (int) $event->terms_page_id;
		} else {
			$terms_page = (int) BFNET_Settings::get( 'terms_page_id', 0 );
		}

		$terms_url = $terms_page ? get_permalink( $terms_page ) : (string) BFNET_Settings::get( 'terms_url', '' );
		if ( $terms_url ) {
			$links[] = array(
				'label' => __( 'Event terms', 'bitforgenet-guest-tickets' ),
				'url'   => $terms_url,
			);
		}

		$reg_page = 0;
		if ( $event && ! empty( $event->regulation_page_id ) ) {
			$reg_page = (int) $event->regulation_page_id;
		} else {
			$reg_page = (int) BFNET_Settings::get( 'regulation_page_id', 0 );
		}

		if ( $reg_page ) {
			$reg_url = get_permalink( $reg_page );
			if ( $reg_url ) {
				$links[] = array(
					'label' => __( 'Regulation of the sale', 'bitforgenet-guest-tickets' ),
					'url'   => $reg_url,
				);
			}
		}

		$privacy_page = (int) BFNET_Settings::get( 'privacy_page_id', 0 );
		$privacy_url  = $privacy_page ? get_permalink( $privacy_page ) : (string) BFNET_Settings::get( 'privacy_url', '' );
		if ( $privacy_url ) {
			$links[] = array(
				'label' => __( 'Privacy', 'bitforgenet-guest-tickets' ),
				'url'   => $privacy_url,
			);
		}

		return $links;
	}

	/**
	 * Build a wp-admin URL for a BitForgeNet Guest Tickets screen.
	 *
	 * @param string $page  Page slug without the bitforgenet-guest-tickets- prefix.
	 * @param array  $args  Extra query args.
	 * @return string
	 */
	/**
	 * A cache-busting version string for one of our own asset files.
	 *
	 * The plugin version is not enough: a file can change inside a release, and
	 * a browser, a combiner or a CDN that has kept the old copy will happily go
	 * on serving it. Stamping the file's own modification time means a changed
	 * file always arrives changed — which is worth doing, because a stale script
	 * once made a perfectly working discount code report itself broken.
	 *
	 * @param string $relative Path under the plugin folder.
	 * @return string
	 */
	public static function asset_version( $relative ) {
		$path = BFNET_PATH . ltrim( $relative, '/' );
		$time = is_readable( $path ) ? filemtime( $path ) : 0;

		return $time ? BFNET_VERSION . '.' . $time : BFNET_VERSION;
	}

	public static function admin_url( $page, $args = array() ) {
		$args = array_merge( array( 'page' => $page ), $args );
		return add_query_arg( $args, admin_url( 'admin.php' ) );
	}

	/**
	 * Parse the gate names setting into a list.
	 *
	 * @return array
	 */
	public static function gates() {
		$raw   = (string) BFNET_Settings::get( 'gate_names', '' );
		$lines = array_filter( array_map( 'trim', preg_split( '/\r\n|\r|\n/', $raw ) ) );
		return $lines ? array_values( $lines ) : array( __( 'Main entrance', 'bitforgenet-guest-tickets' ) );
	}
}
