<?php

namespace SugarCalendarBookings\Abilities;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use SugarCalendarBookings\Appointments\AppointmentModel;
use SugarCalendarBookings\Customers\CustomerModel;
use SugarCalendarBookings\Employees\EmployeeModel;
use SugarCalendarBookings\Locations\LocationModel;
use SugarCalendarBookings\Payments\PaymentModel;
use SugarCalendarBookings\Services\ServiceModel;
use SugarCalendarBookings\Support\Activities\ActivityModel;

/**
 * Canonical entity-to-array formatter used by every ability.
 *
 * Owns the abilities-API wire shape: keeps `list-X` and `get-X` payloads in
 * agreement and centralizes the `{ id, name }` projection of related entities
 * (tags, locations, employees, …).
 *
 * Pro can override the wire shape by rebinding this class in
 * `Pro/Loader::provides()` to a Pro subclass.
 *
 * @since 1.5.0
 */
class AbilityResponseFormatter {

	/**
	 * Project a list of models down to their `{ id, name }` shape.
	 *
	 * Used everywhere a related entity (tag, location, employee, etc.) appears
	 * in a parent's response body without needing the full summary.
	 *
	 * @since 1.5.0
	 *
	 * @param iterable|null $items Models exposing `id` and `name`.
	 *
	 * @return array<int, array{id: int, name: string}>
	 */
	public function id_name_list( $items ): array {

		if ( ! is_iterable( $items ) ) {
			return [];
		}

		$out = [];

		foreach ( $items as $item ) {
			$out[] = [
				'id'   => $item->id ?? 0,
				'name' => $item->name ?? '',
			];
		}

		return $out;
	}

	/**
	 * Format an appointment as a canonical response body.
	 *
	 * Expects the model to be eager-loaded with `service`, `customer`, `employee`,
	 * `location`, and `payments`. Without those relations the formatter still
	 * works but downstream callers will trigger N+1 queries.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment The appointment.
	 * @param bool             $full        When true, include `payments[]`, `notes[]`,
	 *                                      and `activity_log[]` (used by get-appointment).
	 *
	 * @return array
	 */
	public function appointment( AppointmentModel $appointment, bool $full = false ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		$data = [
			'id'                  => $appointment->id,
			'code'                => $appointment->code,
			'status'              => $appointment->status,
			'status_label'        => $appointment->get_status_label(),
			'start_at'            => $this->datetime( $appointment->start_at ),
			'end_at'              => $this->datetime( $appointment->end_at ),
			'duration_minutes'    => $appointment->start_at && $appointment->end_at ? $appointment->get_duration() : null,
			'customer_timezone'   => $appointment->customer_timezone,
			'ignore_availability' => $appointment->ignore_availability,
			'internal_notes'      => $appointment->internal_notes,
			'customer_notes'      => $appointment->customer_notes,
			'service'             => $appointment->service ? $this->service_summary( $appointment->service ) : null,
			'customer'            => $appointment->customer ? $this->customer_summary( $appointment->customer ) : null,
			'employee_id'         => $appointment->employee_id,
			'employee'            => $appointment->employee ? $this->employee_summary( $appointment->employee ) : null,
			'location'            => $appointment->location ? $this->location_summary( $appointment->location ) : null,
			'online_meeting'      => $this->online_meeting( $appointment ),
			'payment_summary'     => $this->payment_summary( $appointment ),
			'created_at'          => $this->datetime( $appointment->created_at ),
			'updated_at'          => $this->datetime( $appointment->updated_at ),
		];

		if ( $full ) {
			$payments             = is_array( $appointment->payments ?? null ) ? $appointment->payments : [];
			$notes                = is_array( $appointment->notes ?? null ) ? $appointment->notes : [];
			$activity_logs        = is_array( $appointment->activity_logs ?? null ) ? $appointment->activity_logs : [];
			$data['payments']     = array_map( [ $this, 'payment' ], $payments );
			$data['notes']        = array_map( [ $this, 'activity' ], $notes );
			$data['activity_log'] = array_map( [ $this, 'activity' ], $activity_logs );
		}

		return $data;
	}

	/**
	 * Compact appointment representation for embedding inside other entities.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment The appointment.
	 *
	 * @return array
	 */
	public function appointment_summary( AppointmentModel $appointment ): array {

		return [
			'id'       => $appointment->id,
			'status'   => $appointment->status,
			'start_at' => $this->datetime( $appointment->start_at ),
		];
	}

	/**
	 * Format a service as a canonical response body.
	 *
	 * @since 1.5.0
	 *
	 * @param ServiceModel $service The service.
	 * @param bool         $full    When true, include override-only fields (payment_gateways, emails).
	 *
	 * @return array
	 */
	public function service( ServiceModel $service, bool $full = false ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		$data = [
			'id'                           => $service->id,
			'name'                         => $service->name,
			'status'                       => $service->status,
			'status_label'                 => $service->get_status_label(),
			'description'                  => $service->description,
			'duration'                     => $service->duration,
			'price'                        => $service->price,
			'color'                        => $service->color,
			'feature_image_id'             => $service->feature_image_id ? $service->feature_image_id : null,
			'use_default_working_schedule' => $service->use_default_working_schedule,
			'working_schedule'             => is_array( $service->working_schedule ) ? $service->working_schedule : [],
			'online_locations'             => is_array( $service->online_locations ) ? $service->online_locations : [],
			'override_payment_methods'     => $service->override_payment_methods,
			'override_email_notifications' => $service->override_email_notifications,
			'locations'                    => $this->id_name_list( $service->locations ?? [] ),
			'employees'                    => $this->id_name_list( $service->employees ?? [] ),
			'tags'                         => $this->id_name_list( $service->tags ?? [] ),
			'author_id'                    => $service->author_id,
			'created_at'                   => $this->datetime( $service->created_at ),
			'updated_at'                   => $this->datetime( $service->updated_at ),
		];

		if ( $full ) {
			if ( $service->override_payment_methods ) {
				$data['payment_gateways'] = is_array( $service->payment_gateways ) ? $service->payment_gateways : [];
			}
			if ( $service->override_email_notifications ) {
				$data['emails'] = is_array( $service->emails ) ? $service->emails : [];
			}
		}

		return $data;
	}

	/**
	 * Format a possibly-DateTime value as an ISO 8601 string.
	 *
	 * Some models cast `created_at`/`updated_at` to DateTimeImmutable, others
	 * (e.g. raw QueryBuilder rows) leave it as a string. Callers should not
	 * have to care which.
	 *
	 * @since 1.5.0
	 *
	 * @param mixed $value DateTimeImmutable, DateTime, string, or null.
	 *
	 * @return string|null
	 */
	public function datetime( $value ): ?string {

		if ( empty( $value ) ) {
			return null;
		}

		if ( $value instanceof DateTimeInterface ) {
			return $value->format( DATE_ATOM );
		}

		if ( ! is_scalar( $value ) ) {
			return null;
		}

		// Raw scalar inputs (e.g., MySQL `Y-m-d H:i:s` UTC strings from aggregate queries)
		// are normalized to DATE_ATOM so every datetime field in the response shares one shape.
		try {
			$parsed = new DateTimeImmutable( (string) $value, new DateTimeZone( 'UTC' ) );
		} catch ( Exception $e ) {
			return null;
		}

		return $parsed->format( DATE_ATOM );
	}

	/**
	 * Compact service representation for embedding inside other entities.
	 *
	 * @since 1.5.0
	 *
	 * @param ServiceModel $service The service.
	 *
	 * @return array
	 */
	public function service_summary( ServiceModel $service ): array {

		return [
			'id'       => $service->id,
			'name'     => $service->name,
			'color'    => $service->color,
			'duration' => $service->duration,
			'price'    => $service->price,
		];
	}

	/**
	 * Format a customer as a canonical response body.
	 *
	 * Aggregate fields (appointments_count, last_appointment_at, total_spent)
	 * are computed by the caller in a single grouped query and passed in.
	 *
	 * `appointments_count` uses *lifetime-value framing* — drafts and
	 * cancellations are excluded so the number reflects real, kept business
	 * with this customer. This differs from `service.appointments_count` and
	 * `employee.appointments_count`, which include cancellations as a
	 * demand/workload signal.
	 *
	 * @since 1.5.0
	 *
	 * @param CustomerModel $customer   The customer.
	 * @param array         $aggregates Optional aggregate stats.
	 *
	 * @return array
	 */
	public function customer( CustomerModel $customer, array $aggregates = [] ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		return [
			'id'                  => $customer->id,
			'name'                => $customer->name,
			'status'              => $customer->status,
			'status_label'        => method_exists( $customer, 'get_status_label' ) ? $customer->get_status_label() : '',
			'email'               => $customer->email,
			'phone'               => $customer->phone,
			'notes'               => $customer->notes ?? '',
			'tags'                => $this->id_name_list( $customer->tags ?? [] ),
			'appointments_count'  => (int) ( $aggregates['appointments_count'] ?? 0 ),
			'last_appointment_at' => $this->datetime( $aggregates['last_appointment_at'] ?? null ),
			'total_spent'         => [
				'amount'   => array_key_exists( 'total_spent_amount', $aggregates ) && $aggregates['total_spent_amount'] === null
					? null
					: (float) ( $aggregates['total_spent_amount'] ?? 0.0 ),
				'currency' => (string) ( $aggregates['total_spent_currency'] ?? '' ),
			],
			'created_at'          => $this->datetime( $customer->created_at ),
			'updated_at'          => $this->datetime( $customer->updated_at ),
		];
	}

	/**
	 * Compact customer representation for embedding inside other entities.
	 *
	 * @since 1.5.0
	 *
	 * @param CustomerModel $customer The customer.
	 *
	 * @return array
	 */
	public function customer_summary( CustomerModel $customer ): array {

		return [
			'id'    => $customer->id,
			'name'  => $customer->name,
			'email' => $customer->email,
			'phone' => $customer->phone,
		];
	}

	/**
	 * Format an employee as a canonical response body. Used by Pro abilities.
	 *
	 * `appointments_count` uses *workload framing* — drafts are excluded as
	 * abandoned booking flows, but cancellations are counted because the
	 * slot was still held on this employee's calendar. This differs from
	 * `customer.appointments_count`, which uses lifetime-value framing and
	 * excludes cancellations too.
	 *
	 * @since 1.5.0
	 *
	 * @param EmployeeModel $employee   The employee.
	 * @param array         $aggregates Optional aggregate stats.
	 *
	 * @return array
	 */
	public function employee( EmployeeModel $employee, array $aggregates = [] ): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		return [
			'id'                           => $employee->id,
			'name'                         => $employee->name,
			'status'                       => $employee->status,
			'email'                        => $employee->email,
			'phone'                        => $employee->phone,
			'user_id'                      => $employee->user_id,
			'feature_image_id'             => isset( $employee->feature_image_id ) ? (int) $employee->feature_image_id : null,
			'notes'                        => (string) ( $employee->notes ?? '' ),
			'use_default_working_schedule' => $employee->use_default_working_schedule,
			'working_schedule'             => is_array( $employee->working_schedule ) ? $employee->working_schedule : [],
			'services'                     => array_map(
				[ $this, 'service_summary' ],
				is_array( $employee->services ?? null ) ? $employee->services : []
			),
			'tags'                         => $this->id_name_list( $employee->tags ?? [] ),
			'appointments_count'           => (int) ( $aggregates['appointments_count'] ?? 0 ),
			'created_at'                   => $this->datetime( $employee->created_at ),
			'updated_at'                   => $this->datetime( $employee->updated_at ),
		];
	}

	/**
	 * Compact employee representation for embedding inside other entities.
	 *
	 * @since 1.5.0
	 *
	 * @param EmployeeModel $employee The employee.
	 *
	 * @return array
	 */
	public function employee_summary( EmployeeModel $employee ): array {

		return [
			'id'    => $employee->id,
			'name'  => $employee->name,
			'email' => $employee->email,
		];
	}

	/**
	 * Compact location representation for embedding inside other entities.
	 *
	 * @since 1.5.0
	 *
	 * @param LocationModel $location The location.
	 *
	 * @return array
	 */
	public function location_summary( LocationModel $location ): array {

		return [
			'id'           => $location->id,
			'name'         => $location->name,
			'address'      => $location->address ?? '',
			'city'         => $location->city ?? '',
			'country_code' => $location->country_code ?? '',
			'timezone'     => $location->timezone ?? '',
		];
	}

	/**
	 * Format a payment record.
	 *
	 * @since 1.5.0
	 *
	 * @param PaymentModel $payment The payment.
	 *
	 * @return array
	 */
	public function payment( PaymentModel $payment ): array {

		return [
			'id'              => $payment->id,
			'status'          => $payment->status,
			'subtotal_amount' => $payment->subtotal_amount,
			'total_amount'    => $payment->total_amount,
			'currency'        => $payment->currency,
			'gateway'         => $payment->gateway,
			'mode'            => $payment->mode,
			'transaction_id'  => $payment->transaction_id,
			'created_at'      => $this->datetime( $payment->created_at ),
		];
	}

	/**
	 * Format an activity-log entry.
	 *
	 * @since 1.5.0
	 *
	 * @param ActivityModel $activity Activity model.
	 *
	 * @return array
	 */
	public function activity( ActivityModel $activity ): array {

		return [
			'id'         => $activity->id,
			'type'       => $activity->type,
			'message'    => (string) ( $activity->message ?? '' ),
			'created_at' => $this->datetime( $activity->created_at ),
		];
	}

	/**
	 * Pull the online-meeting payload out of an appointment's `online_location` meta.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment The appointment.
	 *
	 * @return array|null
	 */
	public function online_meeting( AppointmentModel $appointment ): ?array {

		$meta = is_array( $appointment->online_location ) ? $appointment->online_location : null;

		if ( ! $meta || empty( $meta['provider'] ) ) {
			return null;
		}

		return [
			'provider'   => (string) $meta['provider'],
			'meeting_id' => isset( $meta['meeting_id'] ) ? (string) $meta['meeting_id'] : null,
			'join_url'   => isset( $meta['join_url'] ) ? (string) $meta['join_url'] : null,
		];
	}

	/**
	 * Pick the most recent payment off the appointment's relation as a summary.
	 *
	 * Selects deterministically by `created_at` (tie-broken by `id`) so the
	 * summary reflects current state even when an appointment has been paid,
	 * refunded, and re-paid — independent of the eager-load order callers used.
	 *
	 * Returns null when the appointment has no payments.
	 *
	 * @since 1.5.0
	 *
	 * @param AppointmentModel $appointment The appointment.
	 *
	 * @return array|null
	 */
	public function payment_summary( AppointmentModel $appointment ): ?array {

		$payments = is_array( $appointment->payments ?? null ) ? $appointment->payments : [];

		if ( empty( $payments ) ) {
			return null;
		}

		$primary = null;

		foreach ( $payments as $payment ) {

			if ( ! $payment instanceof PaymentModel ) {
				continue;
			}

			if ( $primary === null || $this->is_more_recent_payment( $payment, $primary ) ) {
				$primary = $payment;
			}
		}

		if ( $primary === null ) {
			return null;
		}

		return [
			'status'       => $primary->status,
			'total_amount' => $primary->total_amount,
			'currency'     => $primary->currency,
			'gateway'      => $primary->gateway,
		];
	}

	/**
	 * Compare two payments to decide which is more recent.
	 *
	 * Sorts by `created_at` and tie-breaks on `id` so the result is stable
	 * even when two payments share a timestamp.
	 *
	 * @since 1.5.0
	 *
	 * @param PaymentModel $candidate Payment being evaluated.
	 * @param PaymentModel $current   Current "most recent" payment.
	 *
	 * @return bool True when $candidate is more recent than $current.
	 */
	private function is_more_recent_payment( PaymentModel $candidate, PaymentModel $current ): bool {

		$candidate_at = $candidate->created_at instanceof DateTimeInterface
			? $candidate->created_at->getTimestamp()
			: strtotime( (string) ( $candidate->created_at ?? '' ) );
		$current_at   = $current->created_at instanceof DateTimeInterface
			? $current->created_at->getTimestamp()
			: strtotime( (string) ( $current->created_at ?? '' ) );

		$candidate_at = $candidate_at === false ? 0 : (int) $candidate_at;
		$current_at   = $current_at === false ? 0 : (int) $current_at;

		if ( $candidate_at !== $current_at ) {
			return $candidate_at > $current_at;
		}

		return (int) ( $candidate->id ?? 0 ) > (int) ( $current->id ?? 0 );
	}
}
