<?php
/**
 * REST controller for the wrenchpress/v1 appointment CRUD namespace.
 *
 * This is the plugin's first register_rest_route namespace and the real
 * security gate for the calendar island (04-05): the browser's hidden controls
 * are cosmetic, the permission callbacks here are authoritative. Every route
 * declares an explicit permission_callback that calls current_user_can()
 * (never a blanket allow-all), and every argument is sanitized through the
 * route's args map before a callback runs. Reads require VIEW_APPOINTMENTS; writes
 * requires MANAGE_APPOINTMENTS plus the wp_rest nonce the island sends as the
 * X-WP-Nonce header.
 *
 * Datetimes are naive site-local strings. The repository stores them as
 * 'Y-m-d H:i:s' (current_time('mysql')); this controller emits them as
 * 'Y-m-d\TH:i:s' for FullCalendar's timeZone: 'local' with no UTC round-trip.
 *
 * @package WrenchPress
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class WrenchPress_Appointments_Rest_Controller
 */
class WrenchPress_Appointments_Rest_Controller {

	/**
	 * REST namespace for every appointment route.
	 */
	const REST_NAMESPACE = 'wrenchpress/v1';

	/**
	 * Register the appointment CRUD routes on rest_api_init.
	 *
	 * Route map (consumed by the calendar island in 04-05):
	 *   GET    /appointments                       list in range   (view cap)
	 *   POST   /appointments                       create          (manage cap)
	 *   PUT    /appointments/{id}                  update/reschedule (manage cap)
	 *   DELETE /appointments/{id}                  cancel or delete (manage cap)
	 *   POST   /appointments/{id}/work-order       create + link WO (manage cap)
	 *
	 * @return void
	 */
	public static function register_routes() {
		$namespace = self::REST_NAMESPACE;

		register_rest_route(
			$namespace,
			'/appointments',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( __CLASS__, 'list_in_range' ),
					'permission_callback' => array( __CLASS__, 'can_view' ),
					'args'                => array(
						'start' => array(
							'required'          => true,
							'sanitize_callback' => 'sanitize_text_field',
						),
						'end'   => array(
							'required'          => true,
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( __CLASS__, 'create' ),
					'permission_callback' => array( __CLASS__, 'can_manage' ),
					'args'                => self::write_args( true ),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/appointments/(?P<id>\d+)',
			array(
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( __CLASS__, 'update' ),
					'permission_callback' => array( __CLASS__, 'can_manage' ),
					'args'                => self::write_args( false ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( __CLASS__, 'delete_item' ),
					'permission_callback' => array( __CLASS__, 'can_manage' ),
					'args'                => array(
						'mode' => array(
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/appointments/(?P<id>\d+)/work-order',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( __CLASS__, 'create_work_order' ),
					'permission_callback' => array( __CLASS__, 'can_manage' ),
				),
			)
		);
	}

	/**
	 * Permission callback for reads: the calendar is visible to every staff role.
	 *
	 * @return bool
	 */
	public static function can_view() {
		return current_user_can( WrenchPress_Capabilities::VIEW_APPOINTMENTS );
	}

	/**
	 * Permission callback for writes: create/edit/cancel is manage-gated.
	 *
	 * @return bool
	 */
	public static function can_manage() {
		return current_user_can( WrenchPress_Capabilities::MANAGE_APPOINTMENTS );
	}

	/**
	 * Build the shared argument map for the create and update routes.
	 *
	 * The customer_id and vehicle_id are only accepted on create; on edit the
	 * link is immutable (an edit reschedules and re-notes, it does not move the
	 * appointment to a different customer or vehicle), so they are omitted.
	 * notes are sanitized with sanitize_textarea_field on input (stored-XSS
	 * defense, T-04-10); force carries the "Save anyway" override.
	 *
	 * @param bool $include_link Whether to require the customer/vehicle link args.
	 * @return array<string, array<string, mixed>> Route args map.
	 */
	private static function write_args( $include_link ) {
		$args = array(
			'start_at' => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
			),
			'end_at'   => array(
				'required'          => true,
				'sanitize_callback' => 'sanitize_text_field',
			),
			'notes'    => array(
				'sanitize_callback' => 'sanitize_textarea_field',
			),
			'status'   => array(
				'sanitize_callback' => 'sanitize_text_field',
			),
			'force'    => array(
				'sanitize_callback' => 'rest_sanitize_boolean',
			),
		);

		if ( $include_link ) {
			$args = array_merge(
				array(
					'customer_id' => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
					'vehicle_id'  => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
				),
				$args
			);
		}

		return $args;
	}

	/**
	 * GET /appointments: list appointments overlapping a datetime range.
	 *
	 * Feeds one FullCalendar view fetch. Every row is mapped to the response
	 * shape the island expects, including resolved customer/vehicle labels and
	 * naive-local 'Y-m-d\TH:i:s' datetimes.
	 *
	 * @param WP_REST_Request $request Incoming request (start, end).
	 * @return WP_REST_Response
	 */
	public static function list_in_range( WP_REST_Request $request ) {
		$start = (string) $request->get_param( 'start' );
		$end   = (string) $request->get_param( 'end' );

		$rows  = WrenchPress_Appointments_Repository::list_in_range( $start, $end );
		$items = array();

		foreach ( $rows as $row ) {
			$items[] = self::format_appointment( $row );
		}

		return new WP_REST_Response( $items, 200 );
	}

	/**
	 * POST /appointments: create a customer/vehicle appointment.
	 *
	 * Enforces the same both-ids-required + vehicle-belongs-to-customer rule as
	 * the work-order create flow (validate_link), rejects a non-positive window,
	 * and runs the warn-only overlap check: when an active appointment overlaps
	 * and force is falsy the create is refused with a 409 conflict payload the
	 * island renders behind a "Save anyway" affordance (APPT-03).
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create( WP_REST_Request $request ) {
		$customer_id = absint( $request->get_param( 'customer_id' ) );
		$vehicle_id  = absint( $request->get_param( 'vehicle_id' ) );

		$link = self::validate_link( $customer_id, $vehicle_id );
		if ( is_wp_error( $link ) ) {
			return $link;
		}

		$start_at = (string) $request->get_param( 'start_at' );
		$end_at   = (string) $request->get_param( 'end_at' );

		$window = self::validate_window( $start_at, $end_at );
		if ( is_wp_error( $window ) ) {
			return $window;
		}

		$conflict = self::guard_conflicts( $start_at, $end_at, 0, (bool) $request->get_param( 'force' ) );
		if ( is_wp_error( $conflict ) ) {
			return $conflict;
		}

		$new_id = WrenchPress_Appointments_Repository::create(
			array(
				'customer_id' => $customer_id,
				'vehicle_id'  => $vehicle_id,
				'start_at'    => $start_at,
				'end_at'      => $end_at,
				'status'      => (string) $request->get_param( 'status' ),
				'notes'       => (string) $request->get_param( 'notes' ),
			)
		);

		if ( ! $new_id ) {
			return new WP_Error(
				'wrenchpress_create_failed',
				__( 'The appointment could not be created.', 'pitdesk-repair-crm' ),
				array( 'status' => 500 )
			);
		}

		return new WP_REST_Response( self::format_appointment( WrenchPress_Appointments_Repository::get( $new_id ) ), 201 );
	}

	/**
	 * PUT /appointments/{id}: reschedule and re-note an existing appointment.
	 *
	 * The id is absint-coerced and existence-checked (404 on a miss, IDOR guard
	 * T-04-09). The customer/vehicle link is immutable here, so the overlap check
	 * re-runs with the row's own id as the exclude so an appointment never
	 * conflicts with itself; the same warn-only 409-unless-force policy applies.
	 * The existing work_order_id and (when the request omits it) status are
	 * preserved so an edit never silently unlinks a work order or resets status.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update( WP_REST_Request $request ) {
		$id       = absint( $request->get_param( 'id' ) );
		$existing = WrenchPress_Appointments_Repository::get( $id );

		if ( ! $existing ) {
			return self::not_found();
		}

		$start_at = (string) $request->get_param( 'start_at' );
		$end_at   = (string) $request->get_param( 'end_at' );

		$window = self::validate_window( $start_at, $end_at );
		if ( is_wp_error( $window ) ) {
			return $window;
		}

		$conflict = self::guard_conflicts( $start_at, $end_at, $id, (bool) $request->get_param( 'force' ) );
		if ( is_wp_error( $conflict ) ) {
			return $conflict;
		}

		$status = (string) $request->get_param( 'status' );
		if ( '' === $status ) {
			$status = (string) $existing->status;
		}

		$updated = WrenchPress_Appointments_Repository::update(
			$id,
			array(
				'start_at'      => $start_at,
				'end_at'        => $end_at,
				'status'        => $status,
				'notes'         => (string) $request->get_param( 'notes' ),
				'work_order_id' => (int) $existing->work_order_id,
			)
		);

		if ( ! $updated ) {
			return new WP_Error(
				'wrenchpress_update_failed',
				__( 'The appointment could not be updated.', 'pitdesk-repair-crm' ),
				array( 'status' => 500 )
			);
		}

		return new WP_REST_Response( self::format_appointment( WrenchPress_Appointments_Repository::get( $id ) ), 200 );
	}

	/**
	 * DELETE /appointments/{id}: cancel (default) or hard-delete an appointment.
	 *
	 * A mode of "delete" removes the row; any other mode sets status to cancelled
	 * while preserving the window, notes, and work-order link. Existence is
	 * checked first (404) before any write.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_item( WP_REST_Request $request ) {
		$id       = absint( $request->get_param( 'id' ) );
		$existing = WrenchPress_Appointments_Repository::get( $id );

		if ( ! $existing ) {
			return self::not_found();
		}

		if ( 'delete' === (string) $request->get_param( 'mode' ) ) {
			if ( ! WrenchPress_Appointments_Repository::delete( $id ) ) {
				return new WP_Error(
					'wrenchpress_delete_failed',
					__( 'The appointment could not be deleted.', 'pitdesk-repair-crm' ),
					array( 'status' => 500 )
				);
			}

			return new WP_REST_Response(
				array(
					'id'      => $id,
					'deleted' => true,
					'mode'    => 'delete',
				),
				200
			);
		}

		$cancelled = WrenchPress_Appointments_Repository::update(
			$id,
			array(
				'start_at'      => (string) $existing->start_at,
				'end_at'        => (string) $existing->end_at,
				'status'        => 'cancelled',
				'notes'         => (string) $existing->notes,
				'work_order_id' => (int) $existing->work_order_id,
			)
		);

		if ( ! $cancelled ) {
			return new WP_Error(
				'wrenchpress_cancel_failed',
				__( 'The appointment could not be cancelled.', 'pitdesk-repair-crm' ),
				array( 'status' => 500 )
			);
		}

		return new WP_REST_Response( self::format_appointment( WrenchPress_Appointments_Repository::get( $id ) ), 200 );
	}

	/**
	 * POST /appointments/{id}/work-order: create a work order and link it back.
	 *
	 * Reuses the existing Work Orders create flow verbatim (customer, vehicle,
	 * status 'new', shop default tax rate) rather than writing a parallel insert
	 * (D-07), then records the new work_order_id on the appointment. When the
	 * appointment is already linked the existing link is returned unchanged so a
	 * double submit cannot spawn a duplicate work order.
	 *
	 * @param WP_REST_Request $request Incoming request.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create_work_order( WP_REST_Request $request ) {
		$id          = absint( $request->get_param( 'id' ) );
		$appointment = WrenchPress_Appointments_Repository::get( $id );

		if ( ! $appointment ) {
			return self::not_found();
		}

		if ( (int) $appointment->work_order_id > 0 ) {
			return new WP_REST_Response(
				array(
					'id'            => $id,
					'work_order_id' => (int) $appointment->work_order_id,
					'linked'        => true,
				),
				200
			);
		}

		$customer_id = (int) $appointment->customer_id;
		$vehicle_id  = (int) $appointment->vehicle_id;

		$link = self::validate_link( $customer_id, $vehicle_id );
		if ( is_wp_error( $link ) ) {
			return $link;
		}

		$work_order_id = WrenchPress_Work_Orders_Repository::create(
			array(
				'customer_id' => $customer_id,
				'vehicle_id'  => $vehicle_id,
				'status'      => 'new',
				'tax_rate'    => (string) WrenchPress_Settings_Page::default_tax_rate(),
			)
		);

		if ( ! $work_order_id ) {
			return new WP_Error(
				'wrenchpress_work_order_failed',
				__( 'The work order could not be created.', 'pitdesk-repair-crm' ),
				array( 'status' => 500 )
			);
		}

		WrenchPress_Appointments_Repository::set_work_order( $id, (int) $work_order_id );

		return new WP_REST_Response(
			array(
				'id'            => $id,
				'work_order_id' => (int) $work_order_id,
				'linked'        => true,
			),
			201
		);
	}

	/**
	 * Validate the customer/vehicle link (both required; vehicle owned by customer).
	 *
	 * Copied from WrenchPress_Work_Orders_Admin_Page::validate_create so the
	 * appointment and work-order create paths reject the same crafted pairings.
	 * The vehicle-ownership re-check is the server-side defense against a crafted
	 * POST attaching a foreign vehicle to a customer (T-04-08).
	 *
	 * @param int $customer_id Owning customer id.
	 * @param int $vehicle_id  Vehicle id.
	 * @return true|WP_Error True when valid, else a 400 wrenchpress_invalid_link error.
	 */
	private static function validate_link( $customer_id, $vehicle_id ) {
		$customer_id = absint( $customer_id );
		$vehicle_id  = absint( $vehicle_id );

		if ( 0 === $customer_id ) {
			return self::invalid_link( __( 'Choose the customer this appointment is for.', 'pitdesk-repair-crm' ) );
		}

		if ( ! WrenchPress_Customers_Repository::get( $customer_id ) ) {
			return self::invalid_link( __( 'That customer could not be found.', 'pitdesk-repair-crm' ) );
		}

		if ( 0 === $vehicle_id ) {
			return self::invalid_link( __( 'Choose the vehicle this appointment is for.', 'pitdesk-repair-crm' ) );
		}

		$vehicle = WrenchPress_Vehicles_Repository::get( $vehicle_id );

		if ( ! $vehicle ) {
			return self::invalid_link( __( 'That vehicle could not be found.', 'pitdesk-repair-crm' ) );
		}

		if ( (int) $vehicle->customer_id !== $customer_id ) {
			return self::invalid_link( __( 'That vehicle does not belong to the selected customer.', 'pitdesk-repair-crm' ) );
		}

		return true;
	}

	/**
	 * Build a 400 wrenchpress_invalid_link error with a caller-supplied message.
	 *
	 * @param string $message Human-readable reason.
	 * @return WP_Error
	 */
	private static function invalid_link( $message ) {
		return new WP_Error( 'wrenchpress_invalid_link', $message, array( 'status' => 400 ) );
	}

	/**
	 * Validate that both times are present and the end is strictly after the start.
	 *
	 * @param string $start_at Proposed window start.
	 * @param string $end_at   Proposed window end.
	 * @return true|WP_Error True when valid, else a 400 error.
	 */
	private static function validate_window( $start_at, $end_at ) {
		if ( '' === trim( $start_at ) || '' === trim( $end_at ) ) {
			return new WP_Error(
				'wrenchpress_invalid_window',
				__( 'An appointment needs both a start and an end time.', 'pitdesk-repair-crm' ),
				array( 'status' => 400 )
			);
		}

		if ( (int) strtotime( $end_at ) <= (int) strtotime( $start_at ) ) {
			return new WP_Error(
				'wrenchpress_invalid_window',
				__( 'The end time must be after the start time.', 'pitdesk-repair-crm' ),
				array( 'status' => 400 )
			);
		}

		return true;
	}

	/**
	 * Run the warn-only overlap check and return a 409 payload when it should block.
	 *
	 * The overlap SQL lives in the repository (find_conflicts); this method owns
	 * the warn-vs-block policy: when force is set the check is skipped entirely,
	 * otherwise any overlapping active appointment yields a wrenchpress_conflict
	 * error carrying a data.conflicts array of {id,customer_label,vehicle_label,
	 * start_at,end_at} for the island to render.
	 *
	 * @param string $start_at   Proposed window start.
	 * @param string $end_at     Proposed window end.
	 * @param int    $exclude_id Appointment id to exclude (0 on create).
	 * @param bool   $force      Whether the staff member chose "Save anyway".
	 * @return true|WP_Error True to proceed, else a 409 conflict error.
	 */
	private static function guard_conflicts( $start_at, $end_at, $exclude_id, $force ) {
		if ( $force ) {
			return true;
		}

		$conflicts = WrenchPress_Appointments_Repository::find_conflicts( $start_at, $end_at, $exclude_id );

		if ( empty( $conflicts ) ) {
			return true;
		}

		$rows = array();
		foreach ( $conflicts as $conflict ) {
			$rows[] = array(
				'id'             => (int) $conflict->id,
				'customer_label' => self::customer_label( (int) $conflict->customer_id ),
				'vehicle_label'  => self::vehicle_label( (int) $conflict->vehicle_id ),
				'start_at'       => self::to_iso_local( (string) $conflict->start_at ),
				'end_at'         => self::to_iso_local( (string) $conflict->end_at ),
			);
		}

		return new WP_Error(
			'wrenchpress_conflict',
			__( 'This time overlaps an existing appointment.', 'pitdesk-repair-crm' ),
			array(
				'status'    => 409,
				'conflicts' => $rows,
			)
		);
	}

	/**
	 * Build the shared 404 error for an unknown appointment id (IDOR guard).
	 *
	 * @return WP_Error
	 */
	private static function not_found() {
		return new WP_Error(
			'wrenchpress_not_found',
			__( 'That appointment could not be found.', 'pitdesk-repair-crm' ),
			array( 'status' => 404 )
		);
	}

	/**
	 * Map an appointment row to the island's response shape.
	 *
	 * @param object $row Appointment row from the repository.
	 * @return array<string, mixed>
	 */
	private static function format_appointment( $row ) {
		return array(
			'id'             => (int) $row->id,
			'customer_id'    => (int) $row->customer_id,
			'vehicle_id'     => (int) $row->vehicle_id,
			'customer_label' => self::customer_label( (int) $row->customer_id ),
			'vehicle_label'  => self::vehicle_label( (int) $row->vehicle_id ),
			'start_at'       => self::to_iso_local( (string) $row->start_at ),
			'end_at'         => self::to_iso_local( (string) $row->end_at ),
			'status'         => (string) $row->status,
			'notes'          => (string) $row->notes,
			'work_order_id'  => (int) $row->work_order_id,
		);
	}

	/**
	 * Resolve a customer id to a "Last, First" display label.
	 *
	 * @param int $customer_id Owning customer id.
	 * @return string Empty string when the customer cannot be resolved.
	 */
	private static function customer_label( $customer_id ) {
		$customer = WrenchPress_Customers_Repository::get( $customer_id );

		if ( ! $customer ) {
			return '';
		}

		return trim( sprintf( '%s, %s', $customer->last_name, $customer->first_name ), ', ' );
	}

	/**
	 * Resolve a vehicle id to its "Year Make Model" display label.
	 *
	 * Reuses the single-source vehicle label helper so the calendar and the admin
	 * lists render the same string.
	 *
	 * @param int $vehicle_id Vehicle id.
	 * @return string Empty string when the vehicle cannot be resolved.
	 */
	private static function vehicle_label( $vehicle_id ) {
		$vehicle = WrenchPress_Vehicles_Repository::get( $vehicle_id );

		if ( ! $vehicle ) {
			return '';
		}

		return WrenchPress_Vehicles_List_Table::vehicle_label( $vehicle );
	}

	/**
	 * Emit a stored naive-local 'Y-m-d H:i:s' datetime as 'Y-m-d\TH:i:s'.
	 *
	 * The wall-clock time is preserved exactly (a single space becomes a 'T');
	 * there is deliberately no timezone conversion, matching the plugin's
	 * naive-site-local convention and FullCalendar's timeZone: 'local'.
	 *
	 * @param string $mysql_datetime Stored datetime string.
	 * @return string
	 */
	private static function to_iso_local( $mysql_datetime ) {
		return str_replace( ' ', 'T', trim( (string) $mysql_datetime ) );
	}
}
