<?php
/**
 * REST API controller for search options / filter values.
 *
 * @package ThemeREX\AutoDealer
 * @version 1.0.0
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\REST;

defined( 'ABSPATH' ) || exit;
use ThemeREX\AutoDealer\Cache\AggregationCache;
use ThemeREX\AutoDealer\Search\FilterParams;
use ThemeREX\AutoDealer\Search\VehicleQuery;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * Returns available filter values for the vehicle search interface.
 */
class SearchController extends WP_REST_Controller {

	/**
	 * Route namespace.
	 *
	 * @var string
	 */
	protected $namespace = 'trx-auto-dealer/v1';

	/**
	 * Route base.
	 *
	 * @var string
	 */
	protected $rest_base = 'search-options';

	/**
	 * Logical cache name (resolved through AggregationCache namespace).
	 */
	private const CACHE_NAME = 'search_options';

	/**
	 * Aggregation cache instance.
	 */
	private AggregationCache $cache;

	public function __construct( ?AggregationCache $cache = null ) {
		$this->cache = $cache ?? new AggregationCache();
	}

	/**
	 * Register REST routes.
	 *
	 * @return void
	 */
	public function register_routes(): void {
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => '__return_true',
				),
			)
		);
	}

	/**
	 * Register the cache-invalidation hook.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
		// AggregationCache handles its own invalidation hooks (save_post_trx_ad_listing,
		// deleted_post, trx_ad_vehicle_specs_synced, trx_ad_cache_flush_aggregations).
	}

	/**
	 * Retrieve search option values (makes, body styles, ranges, etc.).
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response|WP_Error Response or error.
	 */
	public function get_items( $request ) {
		$narrow = $this->collectNarrowingFilters( $request );

		// Cache key folds the active narrowing context in so each filter
		// permutation caches independently. With no filters this is just
		// `search_options`, identical to the pre-narrowing key, so
		// existing transients stay valid.
		$cache_name = self::CACHE_NAME;
		if ( ! empty( $narrow ) ) {
			$cache_name .= ':' . sha1( (string) wp_json_encode( $narrow ) );
		}

		$self = $this;
		$data = $this->cache->remember(
			$cache_name,
			static function () use ( $self, $narrow ): array {
				$post_ids = ! empty( $narrow ) ? $self->resolveNarrowingPostIds( $narrow ) : null;

				$query = new VehicleQuery();
				$data  = array(
					'makes'         => $self->getTaxonomyOptions( 'trx_ad_make', $post_ids ),
					'body_styles'   => $self->getTaxonomyOptions( 'trx_ad_body_style', $post_ids ),
					'fuel_types'    => $self->getTaxonomyOptions( 'trx_ad_fuel_type', $post_ids ),
					'conditions'    => $self->getTaxonomyOptions( 'trx_ad_condition', $post_ids ),
					'price_range'   => $self->rangeFor( $query, 'price', $post_ids ),
					'year_range'    => $self->rangeFor( $query, 'year', $post_ids ),
					'mileage_range' => $self->rangeFor( $query, 'mileage', $post_ids ),
				);

				/**
				 * Filter the search options response data.
				 *
				 * @param array<string, mixed> $data The search options data.
				 */
				return apply_filters( 'trx_ad_search_options_data', $data );
			}
		);

		return new WP_REST_Response( $data, 200 );
	}

	/**
	 * Read whitelisted narrowing parameters off the request. Anything not
	 * listed here is ignored — keeps the cache-key surface bounded.
	 *
	 * @return array<string, string>
	 */
	private function collectNarrowingFilters( WP_REST_Request $request ): array {
		$keys = array( 'make', 'model', 'body_style', 'fuel_type', 'condition', 'transmission', 'drivetrain' );
		$out  = array();
		foreach ( $keys as $k ) {
			$v = $request->get_param( $k );
			if ( null === $v ) {
				continue;
			}
			$v = is_string( $v ) ? trim( $v ) : (string) $v;
			if ( '' === $v ) {
				continue;
			}
			$out[ $k ] = $v;
		}
		return $out;
	}

	/**
	 * Get the post IDs of all listings matching the narrowing filters.
	 * Capped at 5K to bound memory; large fleets should use a real search
	 * server (matches BUILD-ORDER §3 capacity target).
	 *
	 * @param array<string, string> $narrow
	 * @return array<int, int>|null Null when narrowing yields no listings —
	 *                              caller treats this as "no matches".
	 */
	public function resolveNarrowingPostIds( array $narrow ): ?array {
		$params = FilterParams::fromArray( $narrow );
		$query  = new VehicleQuery();
		$rows   = $query->fetch( $params );
		$ids    = array();
		foreach ( $rows as $r ) {
			if ( ! empty( $r['ID'] ) ) {
				$ids[] = (int) $r['ID'];
			}
		}
		return empty( $ids ) ? null : array_slice( array_unique( $ids ), 0, 5000 );
	}

	/**
	 * Wrap VehicleQuery::range() with optional post-ID narrowing. When
	 * $post_ids is null, falls through to the global range. When it's a
	 * non-empty array, scopes MIN/MAX to that subset.
	 *
	 * @param array<int,int>|null $post_ids
	 * @return array{min:int|float|null,max:int|float|null}
	 */
	public function rangeFor( VehicleQuery $query, string $column, ?array $post_ids ): array {
		if ( null === $post_ids ) {
			return $query->range( $column );
		}
		if ( empty( $post_ids ) ) {
			return array(
				'min' => null,
				'max' => null,
			);
		}
		return $query->rangeForPosts( $column, $post_ids );
	}

	/**
	 * Get taxonomy terms with counts. When $post_ids is given, restricts
	 * results to terms tagged on at least one of those listings — that
	 * powers the "narrow body styles by selected make" UX so the dropdown
	 * doesn't surface options that yield zero results.
	 *
	 * @param array<int, int>|null $post_ids Listings to narrow to. Null = global.
	 * @return array<int, array<string, mixed>>
	 */
	public function getTaxonomyOptions( string $taxonomy, ?array $post_ids = null ): array {
		$args = array(
			'taxonomy'   => $taxonomy,
			'hide_empty' => true,
			'orderby'    => 'name',
			'order'      => 'ASC',
		);
		if ( null !== $post_ids ) {
			if ( empty( $post_ids ) ) {
				return array();
			}
			$args['object_ids'] = $post_ids;
			// `object_ids` flips counts to be number of matches per term so
			// `hide_empty` becomes meaningful in scope.
			$args['hide_empty'] = false;
		}

		$terms = get_terms( $args );
		if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
			return array();
		}

		// When narrowing, `get_terms` with `object_ids` deduplicates terms
		// — so a 2-listing Ford make returns one row, not two — and the
		// per-row "++" count was always 1. Count the actual intersection
		// between the post IDs tagged with this term and our narrowing
		// set so "Ford (2)" reads true.
		if ( null !== $post_ids ) {
			$post_id_set = array_flip( $post_ids );
			$by_id       = array();
			foreach ( $terms as $t ) {
				$tid = (int) $t->term_id;
				if ( isset( $by_id[ $tid ] ) ) {
					continue;
				}
				$tagged    = get_objects_in_term( $tid, $taxonomy );
				$tagged    = is_array( $tagged ) ? $tagged : array();
				$intersect = 0;
				foreach ( $tagged as $oid ) {
					if ( isset( $post_id_set[ (int) $oid ] ) ) {
						++$intersect;
					}
				}
				if ( $intersect > 0 ) {
					$by_id[ $tid ] = array(
						'id'    => $tid,
						'name'  => $t->name,
						'slug'  => $t->slug,
						'count' => $intersect,
					);
				}
			}
			return array_values( $by_id );
		}

		$options = array();
		foreach ( $terms as $term ) {
			$options[] = array(
				'id'    => $term->term_id,
				'name'  => $term->name,
				'slug'  => $term->slug,
				'count' => $term->count,
			);
		}
		return $options;
	}
}
