<?php
/**
 * REST API for Estimated Delivery Date.
 *
 * @package EstimatedDeliveryDateWC
 */

declare( strict_types=1 );

defined( 'ABSPATH' ) || exit;

/**
 * REST endpoints (edfw/v1) used by the React settings app.
 *
 * @since 1.0.0
 */
final class EDFW_REST {

	/**
	 * Singleton instance.
	 *
	 * @since 1.0.0
	 * @var EDFW_REST|null
	 */
	private static ?EDFW_REST $instance = null;

	/**
	 * Get the singleton instance.
	 *
	 * @since 1.0.0
	 * @return EDFW_REST
	 */
	public static function instance(): EDFW_REST {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Register routes on rest_api_init.
	 *
	 * @since 1.0.0
	 */
	private function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Only users who can manage WooCommerce may use the settings API.
	 *
	 * @since 1.0.0
	 * @return bool
	 */
	public function permission(): bool {
		return current_user_can( 'manage_woocommerce' );
	}

	/**
	 * Register the edfw/v1 routes: /settings, /preview and /search.
	 *
	 * @since 1.0.0
	 */
	public function register_routes(): void {
		register_rest_route(
			'edfw/v1',
			'/settings',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'get_settings' ),
					'permission_callback' => array( $this, 'permission' ),
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'save_settings' ),
					'permission_callback' => array( $this, 'permission' ),
				),
			)
		);

		register_rest_route(
			'edfw/v1',
			'/preview',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'preview' ),
				'permission_callback' => array( $this, 'permission' ),
			)
		);

		register_rest_route(
			'edfw/v1',
			'/search',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'search' ),
				'permission_callback' => array( $this, 'permission' ),
			)
		);
	}

	/**
	 * Paginated search for rule condition values (products, categories, classes).
	 * Never loads everything at once — the React UI requests pages on demand.
	 *
	 * Params: type, q (query), page, ids (comma list to resolve saved values → labels).
	 *
	 * @since 1.0.0
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response
	 */
	public function search( WP_REST_Request $request ): WP_REST_Response {
		$type = sanitize_key( (string) $request->get_param( 'type' ) );
		$per  = 20;

		// Resolve a set of saved values back to labels (for showing chips on load).
		$ids = $request->get_param( 'ids' );
		if ( null !== $ids && '' !== $ids ) {
			$values = array_filter( array_map( 'sanitize_text_field', explode( ',', (string) $ids ) ) );
			return new WP_REST_Response( array( 'results' => $this->resolve_values( $type, $values ) ), 200 );
		}

		$q       = sanitize_text_field( (string) $request->get_param( 'q' ) );
		$page    = max( 1, (int) $request->get_param( 'page' ) );
		$results = array();
		$more    = false;

		if ( 'product' === $type ) {
			$query = new WP_Query(
				array(
					'post_type'      => array( 'product' ),
					'post_status'    => 'publish',
					's'              => $q,
					'posts_per_page' => $per,
					'paged'          => $page,
					'fields'         => 'ids',
					'orderby'        => 'title',
					'order'          => 'ASC',
					'no_found_rows'  => false,
				)
			);
			foreach ( $query->posts as $pid ) {
				$results[] = array(
					'value' => (string) $pid,
					'label' => html_entity_decode( wp_strip_all_tags( get_the_title( $pid ) ) ),
				);
			}
			$more = $page < (int) $query->max_num_pages;
		} elseif ( in_array( $type, array( 'product_cat', 'shipping_class' ), true ) ) {
			$tax   = 'product_cat' === $type ? 'product_cat' : 'product_shipping_class';
			$terms = get_terms(
				array(
					'taxonomy'   => $tax,
					'hide_empty' => false,
					'search'     => $q,
					'number'     => $per + 1, // fetch one extra to detect "more".
					'offset'     => ( $page - 1 ) * $per,
					'orderby'    => 'name',
				)
			);
			if ( is_array( $terms ) ) {
				$more  = count( $terms ) > $per;
				$terms = array_slice( $terms, 0, $per );
				foreach ( $terms as $t ) {
					$results[] = array(
						'value' => $t->slug,
						'label' => $t->name,
					);
				}
			}
		}

		return new WP_REST_Response(
			array(
				'results' => $results,
				'more'    => $more,
			),
			200
		);
	}

	/**
	 * Map saved condition values (product IDs / term slugs) to {value,label} pairs.
	 *
	 * @since 1.0.0
	 * @param string   $type   Condition type.
	 * @param string[] $values Saved values.
	 * @return array<int, array{value:string,label:string}>
	 */
	private function resolve_values( string $type, array $values ): array {
		$out = array();
		if ( 'product' === $type ) {
			foreach ( $values as $v ) {
				$title = get_the_title( (int) $v );
				if ( $title ) {
					$out[] = array(
						'value' => (string) $v,
						'label' => html_entity_decode( wp_strip_all_tags( $title ) ),
					);
				}
			}
		} elseif ( in_array( $type, array( 'product_cat', 'shipping_class' ), true ) ) {
			$tax = 'product_cat' === $type ? 'product_cat' : 'product_shipping_class';
			foreach ( $values as $v ) {
				$term = get_term_by( 'slug', $v, $tax );
				if ( $term ) {
					$out[] = array(
						'value' => $term->slug,
						'label' => $term->name,
					);
				}
			}
		}
		return $out;
	}

	/**
	 * GET /settings — current settings, rules and capabilities.
	 *
	 * @since 1.0.0
	 * @param WP_REST_Request $request Request (unused; required by the callback signature).
	 * @return WP_REST_Response
	 */
	public function get_settings( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Required by the REST callback signature.
		return new WP_REST_Response(
			array(
				'settings'     => EDFW_Backend::get_settings(),
				'rules'        => EDFW_Backend::get_rules(),
				'capabilities' => EDFW_Helper::capabilities(),
				'schema'       => EDFW_Helper::settings_schema(),
			),
			200
		);
	}

	/**
	 * POST /settings — sanitize and persist settings and rules, then bump
	 * the cache version so memoized dates are invalidated.
	 *
	 * @since 1.0.0
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response
	 */
	public function save_settings( WP_REST_Request $request ): WP_REST_Response {
		$backend  = EDFW_Backend::instance();
		$settings = (array) $request->get_param( 'settings' );
		$rules    = (array) $request->get_param( 'rules' );

		update_option( 'edfw_settings', $backend->sanitize_settings( $settings ) );
		update_option( 'edfw_rules', $backend->sanitize_rules( $rules ) );
		EDFW_Backend::bump_cache_version();
		EDFW_Backend::refresh();

		/**
		 * Fires after settings and rules have been saved.
		 *
		 * Add-ons that render fields through `edfw_settings_schema` persist their own
		 * keys here, from the RAW payload — this plugin's sanitizer only keeps keys it
		 * owns, so add-on values never enter its option and cannot be orphaned there
		 * when the add-on is deactivated.
		 *
		 * @since 1.0.0
		 * @param array $settings Raw submitted settings.
		 * @param array $rules    Raw submitted rules.
		 */
		do_action( 'edfw_after_save_settings', $settings, $rules );

		// Return the merged view the app should render, not just the saved keys, so
		// add-on values survive the round-trip.
		return new WP_REST_Response(
			array(
				'settings' => EDFW_Backend::get_settings(),
				'rules'    => EDFW_Backend::get_rules(),
				'schema'   => EDFW_Helper::settings_schema(),
				'saved'    => true,
			),
			200
		);
	}

	/**
	 * POST /preview — server-authoritative date preview for the admin app.
	 *
	 * @since 1.0.0
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response
	 */
	public function preview( WP_REST_Request $request ): WP_REST_Response {
		$settings = (array) $request->get_param( 'settings' );
		$min_days = isset( $settings['min_days'] ) ? max( 0, (int) $settings['min_days'] ) : 2;
		$max_days = isset( $settings['max_days'] ) ? max( $min_days, (int) $settings['max_days'] ) : 5;
		$skipped  = isset( $settings['skipped_dates'] ) ? (array) $settings['skipped_dates'] : array();
		$format   = ! empty( $settings['date_format_custom'] ) ? (string) $settings['date_format_custom'] : 'D, M j';

		// phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- store-local timestamp mirrors the storefront calculation.
		$now    = (int) current_time( 'timestamp' );
		$min_ts = EDFW_Frontend::calculate_business_date( $min_days, $now, $skipped );
		$max_ts = EDFW_Frontend::calculate_business_date( $max_days, $now, $skipped );

		return new WP_REST_Response(
			array(
				'min'             => $min_ts ? date_i18n( $format, $min_ts ) : '',
				'max'             => $max_ts ? date_i18n( $format, $max_ts ) : '',
				'cutoffRemaining' => $this->cutoff_remaining( (string) ( $settings['cutoff_time'] ?? '' ), $now ),
				'today'           => date_i18n( 'l, M j', $now ),
				'timezone'        => wp_timezone_string(),
			),
			200
		);
	}

	/**
	 * Seconds until today's cutoff (or null if none / passed).
	 *
	 * @since 1.0.0
	 * @param string $cutoff HH:MM (24h) or empty.
	 * @param int    $now    Now timestamp.
	 * @return int|null
	 */
	private function cutoff_remaining( string $cutoff, int $now ): ?int {
		if ( '' === $cutoff || false === strpos( $cutoff, ':' ) ) {
			return null;
		}
		list( $h, $m ) = array_map( 'intval', explode( ':', $cutoff ) );
		$cutoff_ts     = strtotime( date_i18n( 'Y-m-d', $now ) . " {$h}:{$m}:00" );
		$diff          = $cutoff_ts - $now;
		return $diff > 0 ? $diff : null;
	}
}
