<?php

namespace SugarCalendarBookings\Integrations\Zoom;

use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Customers\CustomerModel;
use SugarCalendarBookings\Employees\EmployeeModel;
use SugarCalendarBookings\Integrations\AbstractOAuthIntegration;
use SugarCalendarBookings\Integrations\CalendarConflictProviderInterface;
use SugarCalendarBookings\Integrations\ExternalBusyTimes\ExternalBusyTimeService;
use SugarCalendarBookings\Integrations\IntegrationCapabilityRegistry;
use SugarCalendarBookings\Integrations\IntegrationRegistry;
use SugarCalendarBookings\Integrations\MeetingProviderInterface;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionInterface;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthConnectionModel;
use SugarCalendarBookings\Integrations\OAuthRelay\OAuthRelayClient;
use SugarCalendarBookings\Locations\OnlineLocationProviderFactory;
use SugarCalendarBookings\Locations\ZoomProvider;
use SugarCalendarBookings\Options;
use SugarCalendarBookings\Plugin;
use SugarCalendarBookings\Services\ServiceModel;
use SugarCalendarBookings\Support\Bootable;
use SugarCalendarBookings\Support\Helpers\Date;
use SugarCalendarBookings\Support\Helpers\Helpers;
use SugarCalendarBookings\Support\Helpers\UI;
use WP_Error;

/**
 * Zoom integration.
 *
 * Provides Zoom video conferencing integration for the Integrations settings page
 * and implements the MeetingProviderInterface for creating, updating, and deleting
 * Zoom meetings tied to appointments.
 *
 * @since 1.4.0
 */
class ZoomIntegration extends AbstractOAuthIntegration implements MeetingProviderInterface, CalendarConflictProviderInterface, Bootable {

	/**
	 * Zoom scheduled meeting type.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	private const MEETING_TYPE_SCHEDULED = 2;

	/**
	 * Zoom recurring meeting with no fixed time type.
	 *
	 * These meetings are open-ended meeting rooms without specific occurrence times.
	 * Zoom's API returns the total recurrence window as the duration, which can span years.
	 *
	 * @see https://developers.zoom.us/docs/api/meetings/
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	private const MEETING_TYPE_RECURRING_NO_FIXED_TIME = 3;

	/**
	 * Maximum busy time duration in minutes.
	 *
	 * Matches the max service duration (720 min / 12 hours).
	 * Protects against any unexpected data from the Zoom API.
	 *
	 * @since 1.4.0
	 *
	 * @var int
	 */
	private const MAX_BUSY_TIME_DURATION = 720;

	/**
	 * Default meeting topic template with smart-tag placeholders.
	 *
	 * @since 1.4.0
	 *
	 * @var string
	 */
	private const DEFAULT_MEETING_TOPIC = '{service_name} with {customer_full_name}';

	/**
	 * Default meeting agenda template with smart-tag placeholders.
	 *
	 * @since 1.4.0
	 *
	 * @var string
	 */
	private const DEFAULT_MEETING_AGENDA = "{service_name}, {service_duration}\nBooked by {customer_full_name} ({customer_email})\nWith {employee_full_name}\n\nBooked via {site_name}";

	/**
	 * Provider slug constant.
	 *
	 * @since 1.4.0
	 *
	 * @var string
	 */
	private const PROVIDER_SLUG = 'zoom';

	/**
	 * Zoom actions client.
	 *
	 * @since 1.4.0
	 *
	 * @var ZoomActionsClient
	 */
	private ZoomActionsClient $client;

	/**
	 * Constructor.
	 *
	 * @since 1.4.0
	 *
	 * @param OAuthRelayClient  $oauth_relay_client OAuth relay client.
	 * @param ZoomActionsClient $client             Zoom actions client.
	 */
	public function __construct( OAuthRelayClient $oauth_relay_client, ZoomActionsClient $client ) {

		parent::__construct( $oauth_relay_client );
		$this->client = $client;
	}

	/**
	 * Initialize the integration.
	 *
	 * @since 1.4.0
	 */
	public function init() {

		Plugin::get( IntegrationRegistry::class )->register( $this );
		Plugin::get( IntegrationCapabilityRegistry::class )->register( $this );
		OnlineLocationProviderFactory::register( 'zoom', ZoomProvider::class );
	}

	/**
	 * Get the integration slug.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_slug(): string {

		return self::PROVIDER_SLUG;
	}

	/**
	 * Get the integration display name.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_name(): string {

		return esc_html__( 'Zoom', 'sugar-calendar-bookings-scheduling-appointments-lite' );
	}

	/**
	 * Get the URL to the Zoom icon.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_icon_url(): string {

		return SCBOOKINGS_PLUGIN_URL . 'assets/images/icons/integrations/zoom.png';
	}

	/**
	 * Get the integration description.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_description(): string {

		return sprintf(
			/* Translators: %1$s - opening link tag, %2$s - closing link tag. */
			__( 'Connect your Zoom account to automatically generate meeting links for your appointments. If you need help, please refer to %1$sour documentation%2$s.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
			'<a href="' . esc_url(
				Helpers::get_utm_url(
					'https://sugarcalendar.com/docs/bookings/zoom-integration/',
					[
						'medium'  => 'settings-integrations-zoom',
						'content' => 'Zoom documentation',
					]
				)
			) . '" target="_blank" rel="noopener noreferrer">',
			'</a>'
		);
	}

	/**
	 * Get the post data processing rules for Zoom settings.
	 *
	 * @since 1.4.0
	 *
	 * @return array
	 */
	public function get_post_data_processing_rules() {

		$rules = parent::get_post_data_processing_rules();

		$rules[ 'integrations.' . $this->get_slug() . '.meeting_topic' ]  = 'string';
		$rules[ 'integrations.' . $this->get_slug() . '.meeting_agenda' ] = 'textarea';

		return $rules;
	}

	/**
	 * Render the Zoom-specific provider settings (meeting topic template).
	 *
	 * @since 1.4.0
	 */
	protected function render_provider_settings(): void {

		?>
		<hr class="sugar-calendar-bookings-setting-row-divider" />
		<?php

		UI::text_input_with_smart_tags(
			[
				'id'          => 'zoom-meeting-topic',
				'name'        => 'integrations.zoom.meeting_topic',
				'value'       => $this->get_meeting_topic_template(),
				'label'       => esc_html__( 'Meeting Title', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'description' => esc_html__( 'You can find available placeholders via the smart tags button.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'smart_tags'  => $this->get_meeting_topic_smart_tags(),
			]
		);

		UI::textarea_with_smart_tags(
			[
				'id'          => 'zoom-meeting-agenda',
				'name'        => 'integrations.zoom.meeting_agenda',
				'value'       => $this->get_meeting_agenda_template(),
				'label'       => esc_html__( 'Meeting Agenda', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'description' => esc_html__( 'Agenda of the meeting that will be displayed in the Zoom. You can find available placeholders via the smart tags button.', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'rows'        => 8,
				'smart_tags'  => $this->get_meeting_topic_smart_tags(),
			]
		);
	}

	/**
	 * Wrap an OAuth connection model in a Zoom-specific connection.
	 *
	 * @since 1.4.0
	 *
	 * @param OAuthConnectionModel $model The connection model.
	 *
	 * @return OAuthConnectionInterface
	 */
	protected function wrap_connection( OAuthConnectionModel $model ): OAuthConnectionInterface {

		return new ZoomOAuthConnection( $model );
	}

	/**
	 * Get the provider slug identifier.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_provider_slug(): string {

		return self::PROVIDER_SLUG;
	}

	/**
	 * Get the provider display name.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	public function get_display_name(): string {

		return $this->get_name();
	}

	/**
	 * Get the metadata keys this provider stores on an appointment.
	 *
	 * @since 1.4.0
	 *
	 * @return string[]
	 */
	public function get_meeting_meta_keys(): array {

		return [ 'meeting_id', 'join_url' ];
	}

	/**
	 * Create a Zoom meeting for an appointment.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel $appointment The appointment model.
	 *
	 * @return array|WP_Error Meeting metadata array on success, WP_Error on failure.
	 */
	public function create_meeting( AppointmentModel $appointment ) {

		/** @var ZoomOAuthConnection|null $connection */
		$connection = $this->get_active_connection( $appointment->employee_id );

		if ( ! $connection ) {
			return new WP_Error(
				'zoom_no_connection',
				esc_html__( 'No active Zoom connection found.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
			);
		}

		// Build meeting data.
		$meeting_data = $this->build_create_meeting_data( $appointment );

		// Create the meeting via Zoom API.
		$result = $this->client->create_meeting( $connection, $meeting_data );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Verify the API returned a valid meeting ID.
		if ( empty( $result['id'] ) ) {
			return new WP_Error(
				'zoom_invalid_response',
				esc_html__( 'Zoom API returned an invalid response.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
			);
		}

		return [
			'provider'   => 'zoom',
			'meeting_id' => (string) $result['id'],
			'join_url'   => $result['join_url'] ?? '',
		];
	}

	/**
	 * Update an existing Zoom meeting for an appointment.
	 *
	 * Handles invitee changes by delete + create (Zoom doesn't support
	 * updating invitees). For time/service changes, patches via API.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel $appointment The appointment model.
	 *
	 * @return array|WP_Error Updated meeting metadata array on success, WP_Error on failure.
	 */
	public function update_meeting( AppointmentModel $appointment ) { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$employee_changed = $appointment->has_changed( 'employee_id' );
		$customer_changed = $appointment->has_changed( 'customer_id' );
		$invitees_changed = $employee_changed || $customer_changed;

		// Zoom doesn't support updating invitees — recreate the meeting.
		if ( $invitees_changed ) {
			$old_meeting_id = $appointment->online_location['meeting_id'] ?? '';

			$delete_result = $this->delete_meeting( $appointment );

			if ( is_wp_error( $delete_result ) ) {
				return $delete_result;
			}

			// Delete the old busy time entry to prevent orphaned records.
			if ( ! empty( $old_meeting_id ) ) {
				Plugin::get( ExternalBusyTimeService::class )->delete_by_provider_event( 'zoom', (string) $old_meeting_id );
			}

			// Clear meeting metadata so create_meeting() idempotency check doesn't bail.
			$online_location = $appointment->online_location;

			unset( $online_location['meeting_id'], $online_location['join_url'] );
			$appointment->online_location = $online_location;

			return $this->create_meeting( $appointment );
		}

		/** @var ZoomOAuthConnection|null $connection */
		$connection = $this->get_active_connection( $appointment->employee_id );

		if ( ! $connection ) {
			return new WP_Error(
				'zoom_no_connection',
				esc_html__( 'No active Zoom connection found.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
			);
		}

		$online_location = $appointment->online_location;

		// Build update payload with only the changed fields.
		$meeting_data = [];

		$time_changed    = $appointment->has_changed( 'start_at' ) || $appointment->has_changed( 'end_at' );
		$service_changed = $appointment->has_changed( 'service_id' );

		if ( $time_changed ) {
			$meeting_data['start_time'] = $appointment->start_at->format( 'Y-m-d\TH:i:s\Z' );
			$meeting_data['duration']   = (int) $appointment->get_duration();
		}

		if ( $service_changed ) {
			$service  = ServiceModel::get( $appointment->service_id );
			$customer = CustomerModel::get( $appointment->customer_id );

			$meeting_data['topic'] = $this->build_topic( $appointment, $service, $customer );

			$agenda = $this->build_agenda( $appointment, $service, $customer );

			if ( ! empty( $agenda ) ) {
				$meeting_data['agenda'] = $agenda;
			}
		}

		// Update the meeting via Zoom API.
		$result = $this->client->update_meeting( $connection, $online_location['meeting_id'], $meeting_data );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return [
			'provider'   => 'zoom',
			'meeting_id' => $online_location['meeting_id'],
			'join_url'   => $online_location['join_url'] ?? '',
		];
	}

	/**
	 * Delete a Zoom meeting for an appointment.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel $appointment The appointment model.
	 *
	 * @return true|WP_Error True on success, WP_Error on failure.
	 */
	public function delete_meeting( AppointmentModel $appointment ) {

		$online_location = $appointment->online_location;

		if ( ! is_array( $online_location ) || empty( $online_location['meeting_id'] ) ) {
			return true;
		}

		/** @var ZoomOAuthConnection|null $connection */
		$connection = $this->get_active_connection( $appointment->employee_id );

		if ( ! $connection ) {
			return new WP_Error(
				'zoom_no_connection',
				esc_html__( 'No active Zoom connection found.', 'sugar-calendar-bookings-scheduling-appointments-lite' )
			);
		}

		// Delete the meeting via Zoom API.
		$result = $this->client->delete_meeting( $connection, $online_location['meeting_id'] );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return true;
	}

	/**
	 * Fetch busy times from Zoom for a given connection.
	 *
	 * Retrieves scheduled and upcoming meetings from the user's Zoom account
	 * and returns them as busy time entries.
	 *
	 * @since 1.4.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 * @param string               $from       Start datetime (Y-m-d H:i:s).
	 * @param string               $to         End datetime (Y-m-d H:i:s).
	 *
	 * @return array Array of event arrays.
	 */
	public function fetch_busy_times( OAuthConnectionModel $connection, string $from, string $to ): array {

		$zoom_connection = new ZoomOAuthConnection( $connection );
		$meetings        = $this->fetch_all_meetings( $zoom_connection, $from, $to );

		return array_filter(
			array_map( [ $this, 'meeting_to_busy_time' ], $meetings )
		);
	}

	/**
	 * Get the calendar ID for Zoom.
	 *
	 * Zoom uses the account_id as the calendar identifier since
	 * Zoom doesn't have a concept of multiple calendars.
	 *
	 * @since 1.4.0
	 *
	 * @param OAuthConnectionModel $connection OAuth connection.
	 *
	 * @return string Calendar identifier.
	 */
	public function get_calendar_id( OAuthConnectionModel $connection ): string {

		return $connection->account_id;
	}

	/**
	 * Fetch all scheduled meetings with pagination.
	 *
	 * @since 1.4.0
	 *
	 * @param ZoomOAuthConnection $connection Zoom OAuth connection.
	 * @param string              $from       Start datetime.
	 * @param string              $to         End datetime.
	 *
	 * @return array Raw meeting arrays from Zoom API.
	 */
	private function fetch_all_meetings( ZoomOAuthConnection $connection, string $from, string $to ): array {

		$all_meetings    = [];
		$next_page_token = '';

		do {
			$params = [
				'type'      => 'scheduled',
				'from'      => gmdate( 'Y-m-d', strtotime( $from ) ),
				'to'        => gmdate( 'Y-m-d', strtotime( $to ) ),
				'page_size' => 100,
			];

			if ( ! empty( $next_page_token ) ) {
				$params['next_page_token'] = $next_page_token;
			}

			$result = $this->client->list_meetings( $connection, $params );

			if ( is_wp_error( $result ) ) {
				break;
			}

			$all_meetings    = array_merge( $all_meetings, $result['meetings'] ?? [] );
			$next_page_token = $result['next_page_token'] ?? '';
		} while ( ! empty( $next_page_token ) );

		return $all_meetings;
	}

	/**
	 * Convert a Zoom meeting array to a busy time entry.
	 *
	 * @since 1.4.0
	 *
	 * @param array $meeting Zoom meeting data.
	 *
	 * @return array|null Busy time entry or null if meeting data is invalid.
	 */
	private function meeting_to_busy_time( array $meeting ): ?array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		if ( empty( $meeting['id'] ) || empty( $meeting['start_time'] ) ) {
			return null;
		}

		// Skip recurring meetings with no fixed time — they are open-ended meeting rooms,
		// not real scheduled events, and Zoom reports their duration as the entire recurrence window (years).
		if ( (int) ( $meeting['type'] ?? 0 ) === self::MEETING_TYPE_RECURRING_NO_FIXED_TIME ) {
			return null;
		}

		$start_dt = Date::create( $meeting['start_time'] );

		if ( ! $start_dt ) {
			return null;
		}

		$duration = (int) ( $meeting['duration'] ?? 0 );
		$duration = $duration > 0 ? $duration : 60;

		// Safety net: skip meetings exceeding the max service duration (720 min).
		if ( $duration > self::MAX_BUSY_TIME_DURATION ) {
			return null;
		}

		$end_dt = $start_dt->modify( '+' . $duration . ' minutes' );

		return [
			'provider_event_id' => (string) $meeting['id'],
			'title'             => $meeting['topic'] ?? '',
			'start_at'          => $start_dt->format( 'Y-m-d H:i:s' ),
			'end_at'            => $end_dt->format( 'Y-m-d H:i:s' ),
			'is_all_day'        => false,
		];
	}

	/**
	 * Get the saved meeting topic template.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	private function get_meeting_topic_template(): string {

		return Plugin::get( Options::class )->get(
			'integrations.zoom.meeting_topic',
			self::DEFAULT_MEETING_TOPIC
		);
	}

	/**
	 * Get the saved meeting agenda template.
	 *
	 * @since 1.4.0
	 *
	 * @return string
	 */
	private function get_meeting_agenda_template(): string {

		return Plugin::get( Options::class )->get(
			'integrations.zoom.meeting_agenda',
			self::DEFAULT_MEETING_AGENDA
		);
	}

	/**
	 * Get the available smart tags for the meeting topic template.
	 *
	 * Returns a categorized array of placeholders that can be used
	 * in the meeting topic template field.
	 *
	 * @since 1.4.0
	 *
	 * @return array
	 */
	private function get_meeting_topic_smart_tags(): array {

		return [
			[
				'label' => esc_html__( 'Appointment', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'tags'  => [
					[
						'value' => 'start_date',
						'label' => esc_html__( 'Start Date', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
					[
						'value' => 'start_time',
						'label' => esc_html__( 'Start Time', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
					[
						'value' => 'end_time',
						'label' => esc_html__( 'End Time', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
					[
						'value' => 'appointment_code',
						'label' => esc_html__( 'Appointment Code', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
				],
			],
			[
				'label' => esc_html__( 'Service', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'tags'  => [
					[
						'value' => 'service_name',
						'label' => esc_html__( 'Service Name', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
					[
						'value' => 'service_duration',
						'label' => esc_html__( 'Service Duration', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
				],
			],
			[
				'label' => esc_html__( 'Customer', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'tags'  => [
					[
						'value' => 'customer_full_name',
						'label' => esc_html__( 'Customer Full Name', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
					[
						'value' => 'customer_email',
						'label' => esc_html__( 'Customer Email', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
				],
			],
			[
				'label' => esc_html__( 'Employee', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'tags'  => [
					[
						'value' => 'employee_full_name',
						'label' => esc_html__( 'Employee Full Name', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
				],
			],
			[
				'label' => esc_html__( 'General', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
				'tags'  => [
					[
						'value' => 'site_name',
						'label' => esc_html__( 'Site Name', 'sugar-calendar-bookings-scheduling-appointments-lite' ),
					],
				],
			],
		];
	}

	/**
	 * Build meeting data for creating a Zoom meeting.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel $appointment The appointment model.
	 *
	 * @return array Meeting data formatted for Zoom API.
	 */
	private function build_create_meeting_data( AppointmentModel $appointment ): array {

		// Fetch related models once to avoid duplicate queries in helpers.
		$service  = ServiceModel::get( $appointment->service_id );
		$customer = CustomerModel::get( $appointment->customer_id );
		$employee = EmployeeModel::get( $appointment->employee_id );

		$meeting_data = [
			'topic'      => $this->build_topic( $appointment, $service, $customer, $employee ),
			'start_time' => $appointment->start_at->format( 'Y-m-d\TH:i:s\Z' ),
			'duration'   => (int) $appointment->get_duration(),
			'type'       => self::MEETING_TYPE_SCHEDULED,
		];

		$agenda = $this->build_agenda( $appointment, $service, $customer, $employee );

		if ( ! empty( $agenda ) ) {
			$meeting_data['agenda'] = $agenda;
		}

		$settings = [
			'waiting_room' => false,
		];

		$invitees = $this->build_invitees( $employee, $customer );

		if ( ! empty( $invitees ) ) {
			$settings['meeting_invitees'] = $invitees;
		}

		$meeting_data['settings'] = $settings;

		return $meeting_data;
	}

	/**
	 * Build the smart tag replacements array for meeting templates.
	 *
	 * Shared by build_topic() and build_agenda() to avoid duplication.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel   $appointment The appointment model.
	 * @param ServiceModel|null  $service     The service model.
	 * @param CustomerModel|null $customer    The customer model.
	 * @param EmployeeModel|null $employee    The employee model.
	 *
	 * @return array Associative array of placeholder => replacement value.
	 */
	private function build_smart_tag_replacements( AppointmentModel $appointment, ?ServiceModel $service = null, ?CustomerModel $customer = null, ?EmployeeModel $employee = null ): array {

		$employee = $employee ?? EmployeeModel::get( $appointment->employee_id );

		$options     = Plugin::get( Options::class );
		$time_format = $options->get( 'display.time_format', get_option( 'time_format' ) );
		$date_format = get_option( 'date_format', 'M j, Y' );

		return [
			'{service_name}'       => $service ? $service->name : '',
			'{service_duration}'   => $service
				? $service->duration . ' ' . esc_html__( 'min', 'sugar-calendar-bookings-scheduling-appointments-lite' )
				: '',
			'{customer_full_name}' => $customer ? $customer->name : '',
			'{customer_email}'     => $customer ? $customer->email : '',
			'{employee_full_name}' => $employee ? $employee->name : '',
			'{start_date}'         => $appointment->start_at
				? wp_date( $date_format, $appointment->start_at->getTimestamp() )
				: '',
			'{start_time}'         => $appointment->start_at
				? wp_date( $time_format, $appointment->start_at->getTimestamp() )
				: '',
			'{end_time}'           => $appointment->end_at
				? wp_date( $time_format, $appointment->end_at->getTimestamp() )
				: '',
			'{appointment_code}'   => $appointment->code ?? '',
			'{site_name}'          => get_bloginfo( 'name' ),
		];
	}

	/**
	 * Build meeting topic from template and smart tags.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel   $appointment The appointment model.
	 * @param ServiceModel|null  $service     The service model.
	 * @param CustomerModel|null $customer    The customer model.
	 * @param EmployeeModel|null $employee    The employee model.
	 *
	 * @return string Meeting topic (max 200 chars).
	 */
	private function build_topic( AppointmentModel $appointment, ?ServiceModel $service = null, ?CustomerModel $customer = null, ?EmployeeModel $employee = null ): string {

		$template     = $this->get_meeting_topic_template();
		$replacements = $this->build_smart_tag_replacements( $appointment, $service, $customer, $employee );

		$topic = str_replace(
			array_keys( $replacements ),
			array_values( $replacements ),
			$template
		);

		// Clean up extra whitespace from empty replacements.
		$topic = trim( preg_replace( '/\s+/', ' ', $topic ) );

		if ( empty( $topic ) ) {
			$topic = sprintf( 'Appointment #%d', $appointment->id );
		}

		// Zoom API limit: 200 characters.
		return mb_substr( $topic, 0, 200 );
	}

	/**
	 * Build meeting agenda from template and smart tags.
	 *
	 * @since 1.4.0
	 *
	 * @param AppointmentModel   $appointment The appointment model.
	 * @param ServiceModel|null  $service     The service model.
	 * @param CustomerModel|null $customer    The customer model.
	 * @param EmployeeModel|null $employee    The employee model.
	 *
	 * @return string Meeting agenda (max 2000 chars), or empty string if not configured.
	 */
	private function build_agenda( AppointmentModel $appointment, ?ServiceModel $service = null, ?CustomerModel $customer = null, ?EmployeeModel $employee = null ): string {

		$template = $this->get_meeting_agenda_template();

		if ( empty( $template ) ) {
			return '';
		}

		$replacements = $this->build_smart_tag_replacements( $appointment, $service, $customer, $employee );

		$agenda = str_replace(
			array_keys( $replacements ),
			array_values( $replacements ),
			$template
		);

		$agenda = trim( $agenda );

		if ( empty( $agenda ) ) {
			return '';
		}

		// Zoom API limit: 2000 characters.
		return mb_substr( $agenda, 0, 2000 );
	}

	/**
	 * Build invitees list from employee and customer.
	 *
	 * @since 1.4.0
	 *
	 * @param EmployeeModel|null $employee The employee model.
	 * @param CustomerModel|null $customer The customer model.
	 *
	 * @return array Invitees array for Zoom API.
	 */
	private function build_invitees( ?EmployeeModel $employee = null, ?CustomerModel $customer = null ): array {

		$invitees = [];

		if ( $employee && ! empty( $employee->email ) ) {
			$invitees[] = [ 'email' => $employee->email ];
		}

		if ( $customer && ! empty( $customer->email ) ) {
			$invitees[] = [ 'email' => $customer->email ];
		}

		return $invitees;
	}
}
