<?php
declare(strict_types=1);

namespace Wohno\Plugin\Api;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Maps sanitised shortcode/block attributes to the validated query params
 * accepted by `GET /api/v1/listings`.
 *
 * Param surface (Wohno contract): city, zip, rooms_min, rooms_max, rent_max,
 * property_type, wbs_only, q, limit (1–50). No `pets` param.
 */
final class ListingQuery {

	private const PROPERTY_TYPES = array( 'apartment', 'house', 'studio', 'wg_room', 'other' );

	/**
	 * Build the API query-param array from raw attributes.
	 *
	 * @param array<string, mixed> $atts Raw shortcode/block/widget attributes.
	 * @return array<string, scalar> Sanitised query params for the listings endpoint.
	 */
	public static function from_atts( array $atts ): array {
		$params = array();

		// The API requires `city` to be 2–80 chars; drop shorter values so a
		// stray single character does not become a guaranteed 400 error.
		$city = isset( $atts['city'] ) ? sanitize_text_field( (string) $atts['city'] ) : '';
		if ( mb_strlen( $city ) >= 2 ) {
			$params['city'] = mb_substr( $city, 0, 80 );
		}

		$zip = isset( $atts['zip'] ) ? preg_replace( '/\D/', '', (string) $atts['zip'] ) : '';
		if ( '' !== $zip ) {
			$params['zip'] = substr( $zip, 0, 5 );
		}

		foreach ( array( 'rooms_min', 'rooms_max' ) as $k ) {
			if ( isset( $atts[ $k ] ) && '' !== $atts[ $k ] ) {
				$params[ $k ] = max( 0, min( 20, (int) $atts[ $k ] ) );
			}
		}

		if ( isset( $atts['rent_max'] ) && '' !== $atts['rent_max'] ) {
			// The makler/visitor enters EUR in the UI; the Wohno API expects the
			// filter in CENT (same unit it returns). Clamp the euro value to the
			// API cap (100.000 €), then convert to cents.
			$rent_max_euros     = max( 0, min( 100000, (int) $atts['rent_max'] ) );
			$params['rent_max'] = $rent_max_euros * 100;
		}

		if ( isset( $atts['property_type'] ) && in_array( $atts['property_type'], self::PROPERTY_TYPES, true ) ) {
			$params['property_type'] = (string) $atts['property_type'];
		}

		if ( ! empty( $atts['wbs_only'] ) ) {
			$params['wbs_only'] = 'true';
		}

		if ( isset( $atts['q'] ) && '' !== $atts['q'] ) {
			$params['q'] = substr( sanitize_text_field( (string) $atts['q'] ), 0, 100 );
		}

		$limit           = isset( $atts['limit'] ) ? (int) $atts['limit'] : 9;
		$params['limit'] = max( 1, min( 50, $limit ) );

		// Opaque cursor (base64url) for "load more" — restricted charset + length.
		if ( isset( $atts['cursor'] ) && '' !== $atts['cursor'] ) {
			$cursor = preg_replace( '/[^A-Za-z0-9_=-]/', '', (string) $atts['cursor'] );
			if ( '' !== $cursor ) {
				$params['cursor'] = substr( (string) $cursor, 0, 512 );
			}
		}

		return $params;
	}

	/**
	 * GET parameter prefix for the public front-end search form.
	 */
	public const REQUEST_PREFIX = 'wohno_';

	/**
	 * Read the visitor's search-form selection from the request query string.
	 *
	 * Returns attribute overrides (same keys as {@see self::from_atts()}) ONLY
	 * when the form's marker (`wohno_search=1`) is present — i.e. the visitor
	 * actually submitted the filter bar. When present, every managed field is
	 * returned (empty string = "cleared"), so the selection fully overrides the
	 * shortcode/block defaults instead of falling back to them.
	 *
	 * @return array<string, string> Attribute overrides, or [] when not submitted.
	 */
	public static function request_values(): array {
		$p = self::REQUEST_PREFIX;

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public, read-only search via GET (no state change).
		if ( ! isset( $_GET[ $p . 'search' ] ) || '1' !== sanitize_text_field( wp_unslash( (string) $_GET[ $p . 'search' ] ) ) ) {
			return array();
		}

		$get = static function ( string $key ): string {
			$name = self::REQUEST_PREFIX . $key;
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public, read-only search via GET (no state change).
			return isset( $_GET[ $name ] ) ? sanitize_text_field( wp_unslash( (string) $_GET[ $name ] ) ) : '';
		};

		$rooms_min = $get( 'rooms_min' );
		$rent_max  = $get( 'rent_max' );

		return array(
			'q'             => $get( 'q' ),
			'city'          => $get( 'city' ),
			'zip'           => $get( 'zip' ),
			'rooms_min'     => '' !== $rooms_min ? (string) (int) $rooms_min : '',
			'rent_max'      => '' !== $rent_max ? (string) (int) $rent_max : '',
			'property_type' => $get( 'property_type' ),
			'wbs_only'      => '1' === $get( 'wbs_only' ) ? '1' : '',
		);
	}

	/**
	 * Non-filter query parameters present in the request, so a GET search form
	 * can preserve page context (e.g. `page_id`/`p` under plain permalinks) via
	 * hidden inputs. Excludes the form's own `wohno_*` parameters.
	 *
	 * @return array<string, string> name => value (sanitised).
	 */
	public static function preserved_query(): array {
		$preserved = array();
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public, read-only search via GET (no state change).
		foreach ( $_GET as $raw_key => $raw_value ) {
			$key = sanitize_key( (string) $raw_key );
			if ( '' === $key || str_starts_with( $key, self::REQUEST_PREFIX ) ) {
				continue;
			}
			$preserved[ $key ] = sanitize_text_field( wp_unslash( (string) $raw_value ) );
		}

		return $preserved;
	}

	/**
	 * Sanitise a listing id. The API's single-listing endpoint accepts ONLY a
	 * canonical UUID (`/api/v1/listings/{uuid}`; anything else → HTTP 400), so we
	 * pin to that exact shape. This both prevents path break-out and shrinks the
	 * probe space of the public detail AJAX endpoint to real ids (an attacker
	 * can no longer enumerate arbitrary 64-char strings against the upstream API).
	 *
	 * @param mixed $raw Raw id attribute.
	 * @return string The canonical UUID, or '' if the input is not one.
	 */
	public static function sanitize_id( $raw ): string {
		$id = strtolower( trim( (string) $raw ) );

		return preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/', $id ) === 1
			? $id
			: '';
	}
}
