<?php
/**
 * Filter parameters DTO.
 *
 * Validates and sanitizes vehicle search query input. Built from a
 * WP_REST_Request or a plain associative array.
 *
 * @package ThemeREX\AutoDealer
 * @version 1.0.0
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\Search;

/**
 * Immutable bag of sanitized filter values.
 *
 * Allowed sources:
 *  - text: make, model, condition, body_style, fuel_type, transmission,
 *          drivetrain, color, vehicle_type, status
 *  - bool: featured, accident_free
 *  - int : dealer_id, year_from, year_to, price_from, price_to,
 *          mileage_from, mileage_to, page, per_page
 *  - text: orderby (whitelisted), order (asc|desc)
 *  - text: search (free-text keyword across title/VIN/description)
 */
final class FilterParams {

	/**
	 * Map of accepted text filter parameters → specs table column.
	 *
	 * @var array<string,string>
	 */
	public const TEXT_FILTERS = array(
		'make'         => 'make',
		'model'        => 'model',
		'condition'    => 'condition_type',
		'body_style'   => 'body_style',
		'fuel_type'    => 'fuel_type',
		'transmission' => 'transmission',
		'drivetrain'   => 'drivetrain',
		'color'        => 'color_ext',
		'vehicle_type' => 'vehicle_type',
		'status'       => 'status',
	);

	/**
	 * Map of accepted boolean filter parameters → specs table column.
	 *
	 * @var array<string,string>
	 */
	public const BOOL_FILTERS = array(
		'featured'      => 'featured',
		'accident_free' => 'accident_free',
	);

	/**
	 * Map of accepted range filter parameters → [column, operator].
	 *
	 * @var array<string,array{0:string,1:string}>
	 */
	public const RANGE_FILTERS = array(
		'year_from'    => array( 'year', '>=' ),
		'year_to'      => array( 'year', '<=' ),
		'price_from'   => array( 'price', '>=' ),
		'price_to'     => array( 'price', '<=' ),
		'mileage_from' => array( 'mileage', '>=' ),
		'mileage_to'   => array( 'mileage', '<=' ),
	);

	/**
	 * Whitelist of orderby keys → SQL expression (with table alias prefix).
	 *
	 * @var array<string,string>
	 */
	public const ORDERBY_MAP = array(
		'date'          => 'p.post_date',
		'title'         => 'p.post_title',
		'price'         => 's.price',
		'year'          => 's.year',
		'mileage'       => 's.mileage',
		// `price_reduced_first`: featured-style sort that puts price-reduced
		// listings ahead, then orders by the most-recent reduction time.
		'price_reduced' => 's.price_reduced DESC, s.price_reduced_at',
	);

	private const DEFAULT_PER_PAGE = 12;
	private const MAX_PER_PAGE     = 100;

	/**
	 * Resolve the configured archive per-page from the plugin settings,
	 * falling back to {@see self::DEFAULT_PER_PAGE} when no admin override
	 * has been saved. Clamped to [1, MAX_PER_PAGE].
	 */
	public static function configuredPerPage(): int {
		$settings = get_option( 'trx_ad_settings', array() );
		if ( ! is_array( $settings ) || ! isset( $settings['listings_per_page'] ) ) {
			return self::DEFAULT_PER_PAGE;
		}
		$value = (int) $settings['listings_per_page'];
		if ( $value <= 0 ) {
			return self::DEFAULT_PER_PAGE;
		}
		return min( self::MAX_PER_PAGE, max( 1, $value ) );
	}

	/** @var array<string,string> */
	private array $text = array();
	/** @var array<string,int> */
	private array $bool = array();
	/** @var array<string,int|float> */
	private array $range    = array();
	private ?int $dealerId  = null;
	private string $search  = '';
	private string $orderby = 'date';
	private string $order   = 'DESC';
	private int $page       = 1;
	private int $perPage    = self::DEFAULT_PER_PAGE;

	/**
	 * Geo-radius — Pro per ADR-11 / Phase 14. Free tier reads but doesn't
	 * apply them; Pro hooks `trx_ad_vehicle_query_where` to inject the
	 * bounding-box SQL clause when all three are present.
	 */
	private ?float $lat      = null;
	private ?float $lng      = null;
	private ?float $radiusKm = null;

	/**
	 * Build from a WP_REST_Request.
	 */
	public static function fromRequest( \WP_REST_Request $request ): self {
		$assoc = array();
		$keys  = array_merge(
			array_keys( self::TEXT_FILTERS ),
			array_keys( self::BOOL_FILTERS ),
			array_keys( self::RANGE_FILTERS ),
			array( 'dealer_id', 'search', 'orderby', 'order', 'page', 'per_page', 'lat', 'lng', 'radius_km' )
		);
		foreach ( $keys as $key ) {
			$value = $request->get_param( $key );
			if ( null !== $value ) {
				$assoc[ $key ] = $value;
			}
		}
		return self::fromArray( $assoc );
	}

	/**
	 * Build from a plain associative array.
	 *
	 * @param array<string,mixed> $input Raw input.
	 */
	public static function fromArray( array $input ): self {
		$f = new self();

		foreach ( self::TEXT_FILTERS as $key => $_column ) {
			if ( isset( $input[ $key ] ) && '' !== $input[ $key ] ) {
				$f->text[ $key ] = sanitize_text_field( (string) $input[ $key ] );
			}
		}

		foreach ( self::BOOL_FILTERS as $key => $_column ) {
			if ( isset( $input[ $key ] ) && '' !== $input[ $key ] ) {
				$raw             = $input[ $key ];
				$f->bool[ $key ] = ( ! empty( $raw ) && '0' !== (string) $raw ) ? 1 : 0;
			}
		}

		foreach ( self::RANGE_FILTERS as $key => $_config ) {
			if ( isset( $input[ $key ] ) && '' !== $input[ $key ] ) {
				$f->range[ $key ] = (int) $input[ $key ];
			}
		}

		if ( isset( $input['dealer_id'] ) && '' !== $input['dealer_id'] ) {
			$f->dealerId = (int) $input['dealer_id'];
		}

		if ( isset( $input['search'] ) && '' !== $input['search'] ) {
			$f->search = sanitize_text_field( (string) $input['search'] );
		}

		if ( isset( $input['orderby'] ) ) {
			$candidate = sanitize_key( (string) $input['orderby'] );
			// Composite friendly tokens emitted by the archive sort dropdown +
			// the admin "default sort" setting. Map to canonical orderby+order
			// pairs so the URL stays human-readable while the SQL builder only
			// sees vetted values.
			$composite = array(
				'newest'     => array( 'date', 'DESC' ),
				'price_asc'  => array( 'price', 'ASC' ),
				'price_desc' => array( 'price', 'DESC' ),
				'year_desc'  => array( 'year', 'DESC' ),
			);
			if ( isset( $composite[ $candidate ] ) ) {
				$f->orderby = $composite[ $candidate ][0];
				$f->order   = $composite[ $candidate ][1];
			} elseif ( isset( self::ORDERBY_MAP[ $candidate ] ) ) {
				$f->orderby = $candidate;
			}
		}

		if ( isset( $input['order'] ) ) {
			$candidate = strtoupper( sanitize_text_field( (string) $input['order'] ) );
			$f->order  = 'ASC' === $candidate ? 'ASC' : 'DESC';
		}

		if ( isset( $input['page'] ) ) {
			$f->page = max( 1, (int) $input['page'] );
		}

		if ( isset( $input['per_page'] ) && '' !== (string) $input['per_page'] ) {
			$f->perPage = max( 1, min( self::MAX_PER_PAGE, (int) $input['per_page'] ) );
		} else {
			// No explicit per-page in the input: respect the admin setting so
			// the archive's "Listings per page" option actually does something.
			$f->perPage = self::configuredPerPage();
		}

		if ( isset( $input['lat'], $input['lng'], $input['radius_km'] ) && '' !== (string) $input['lat'] && '' !== (string) $input['lng'] && '' !== (string) $input['radius_km'] ) {
			$lat    = (float) $input['lat'];
			$lng    = (float) $input['lng'];
			$radius = (float) $input['radius_km'];
			if ( $lat >= -90.0 && $lat <= 90.0 && $lng >= -180.0 && $lng <= 180.0 && $radius > 0 ) {
				$f->lat      = $lat;
				$f->lng      = $lng;
				$f->radiusKm = $radius;
			}
		}

		return $f;
	}

	/** @return array<string,string> */
	public function getText(): array {
		return $this->text;
	}

	/** @return array<string,int> */
	public function getBool(): array {
		return $this->bool;
	}

	/** @return array<string,int|float> */
	public function getRange(): array {
		return $this->range;
	}

	public function getDealerId(): ?int {
		return $this->dealerId;
	}

	public function getSearch(): string {
		return $this->search;
	}

	public function getOrderby(): string {
		return $this->orderby;
	}

	public function getOrder(): string {
		return $this->order;
	}

	public function getPage(): int {
		return $this->page;
	}

	public function getPerPage(): int {
		return $this->perPage;
	}

	public function getOffset(): int {
		return ( $this->page - 1 ) * $this->perPage;
	}

	public function getLat(): ?float {
		return $this->lat;
	}

	public function getLng(): ?float {
		return $this->lng;
	}

	public function getRadiusKm(): ?float {
		return $this->radiusKm;
	}

	public function hasGeoFilter(): bool {
		return null !== $this->lat && null !== $this->lng && null !== $this->radiusKm;
	}

	/**
	 * Stable hash of the filter set, suitable as a cache key.
	 */
	public function hash(): string {
		$state = array(
			'text'   => $this->text,
			'bool'   => $this->bool,
			'range'  => $this->range,
			'dealer' => $this->dealerId,
			'search' => $this->search,
			'order'  => $this->orderby . ':' . $this->order,
			'page'   => $this->page,
			'pp'     => $this->perPage,
			'geo'    => $this->hasGeoFilter() ? array( $this->lat, $this->lng, $this->radiusKm ) : null,
		);
		ksort( $state );
		return md5( (string) wp_json_encode( $state ) );
	}
}
