<?php
/**
 * Pricing Preview REST Controller.
 *
 * Thin REST proxy exposing PricingFeedManager::resolve() to the React UI so
 * the F17 provider-cost chip on the new bulk generator surfaces can render a
 * live per-million-token rate + estimated job cost.
 *
 * This endpoint deliberately does NOT touch Ctrl credits — provider cost
 * preview is a completely separate meter from the Ctrl ledger (see root
 * AGENTS.md § 4.6 Ctrl Scope Rule).
 *
 * @package AIPT\Api\PricingPreview
 * @since   2.6.1
 */

declare(strict_types=1);

namespace AIPT\Api\PricingPreview;

defined( 'ABSPATH' ) || exit;

use AIPT\Core\Services\PricingFeedManager;

/**
 * Exposes a small per-request pricing preview payload for the React cost chip.
 *
 * @since 2.6.1
 */
final class PricingPreviewController {

	private const NAMESPACE = 'aipt/v1';
	private const ROUTE     = '/pricing/preview';

	/**
	 * Canonical AIPT provider ids accepted by this endpoint.
	 *
	 * Mirrors {@see \AIPT\Types\credentials.ts} `ApiProvider` union.
	 *
	 * @var array<int, string>
	 */
	private const ALLOWED_PROVIDERS = array( 'openai', 'gemini', 'openrouter', 'claude' );

	/**
	 * Upper bound on per-side token counts (10 million).
	 *
	 * Guards against absurd inputs that would let the UI fabricate
	 * astronomical preview cost numbers.
	 */
	private const MAX_TOKENS = 10000000;

	/**
	 * Callable returning {0:float, 1:float} per-million USD rates.
	 *
	 * Signature: fn(string $provider, string $model): array{0:float,1:float}
	 *
	 * @since 2.6.1
	 * @var callable
	 */
	private $pricing_resolver;

	/**
	 * Callable returning PricingFeedManager::get_snapshot_meta() shape.
	 *
	 * Signature: fn(): array{status:string, ...}
	 *
	 * @since 2.6.1
	 * @var callable
	 */
	private $snapshot_meta_resolver;

	/**
	 * Capability check callable. Defaults to WP's current_user_can().
	 *
	 * Signature: fn(string $capability): bool
	 *
	 * @since 2.6.1
	 * @var callable
	 */
	private $current_user_can;

	/**
	 * Nonce verifier callable. Defaults to WP's wp_verify_nonce().
	 *
	 * Signature: fn(string $nonce, string $action): int|false
	 *
	 * @since 2.6.1
	 * @var callable
	 */
	private $verify_nonce;

	/**
	 * Constructor.
	 *
	 * Accepts an optional overrides map so unit tests can inject pure callables
	 * without hitting the real PricingFeedManager singleton or WP globals.
	 * Mirrors the dependency-injection pattern Phase 0 used in
	 * {@see \AIPT\Core\Services\UiVariantService}.
	 *
	 * Recognised keys: `pricing_resolver`, `snapshot_meta_resolver`,
	 * `current_user_can`, `verify_nonce`.
	 *
	 * @since 2.6.1
	 *
	 * @param array<string, callable> $overrides Optional dependency overrides.
	 */
	public function __construct( array $overrides = array() ) {
		$this->pricing_resolver = isset( $overrides['pricing_resolver'] ) && \is_callable( $overrides['pricing_resolver'] )
			? $overrides['pricing_resolver']
			: array( PricingFeedManager::instance(), 'resolve' );

		$this->snapshot_meta_resolver = isset( $overrides['snapshot_meta_resolver'] ) && \is_callable( $overrides['snapshot_meta_resolver'] )
			? $overrides['snapshot_meta_resolver']
			: array( PricingFeedManager::instance(), 'get_snapshot_meta' );

		$this->current_user_can = isset( $overrides['current_user_can'] ) && \is_callable( $overrides['current_user_can'] )
			? $overrides['current_user_can']
			: 'current_user_can';

		$this->verify_nonce = isset( $overrides['verify_nonce'] ) && \is_callable( $overrides['verify_nonce'] )
			? $overrides['verify_nonce']
			: 'wp_verify_nonce';
	}

	/**
	 * Register REST routes.
	 *
	 * @since 2.6.1
	 *
	 * @return void
	 */
	public function register_routes(): void {
		\register_rest_route(
			self::NAMESPACE,
			self::ROUTE,
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_preview' ),
				'permission_callback' => array( $this, 'check_permissions' ),
				'args'                => array(
					'provider'      => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_key',
					),
					'model'         => array(
						'type'              => 'string',
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'input_tokens'  => array(
						'type'              => 'integer',
						'required'          => false,
						'default'           => 0,
						'sanitize_callback' => 'absint',
					),
					'output_tokens' => array(
						'type'              => 'integer',
						'required'          => false,
						'default'           => 0,
						'sanitize_callback' => 'absint',
					),
				),
			)
		);
	}

	/**
	 * Handle GET aipt/v1/pricing/preview.
	 *
	 * @since 2.6.1
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return \WP_REST_Response|\WP_Error Pricing preview payload or error.
	 */
	public function get_preview( \WP_REST_Request $request ) {

		$provider = (string) $request->get_param( 'provider' );
		$model    = (string) $request->get_param( 'model' );

		if ( '' === $provider ) {
			return new \WP_Error(
				'missing_provider',
				\__( 'The provider parameter is required.', 'ai-product-tools' ),
				array( 'status' => 400 )
			);
		}

		if ( '' === $model ) {
			return new \WP_Error(
				'missing_model',
				\__( 'The model parameter is required.', 'ai-product-tools' ),
				array( 'status' => 400 )
			);
		}

		if ( ! \in_array( $provider, self::ALLOWED_PROVIDERS, true ) ) {
			return new \WP_Error(
				'invalid_provider',
				\__( 'The provider is not one of the supported AIPT providers.', 'ai-product-tools' ),
				array( 'status' => 400 )
			);
		}

		$input_tokens  = (int) $request->get_param( 'input_tokens' );
		$output_tokens = (int) $request->get_param( 'output_tokens' );

		if ( $input_tokens > self::MAX_TOKENS || $output_tokens > self::MAX_TOKENS ) {
			return new \WP_Error(
				'tokens_out_of_range',
				\__( 'Token counts must be at most 10,000,000 per side.', 'ai-product-tools' ),
				array( 'status' => 400 )
			);
		}

		$rate            = \call_user_func( $this->pricing_resolver, $provider, $model );
		$rate_resolved   = \is_array( $rate ) && isset( $rate[0], $rate[1] );
		$input_per_mil   = $rate_resolved ? (float) $rate[0] : 0.0;
		$output_per_mil  = $rate_resolved ? (float) $rate[1] : 0.0;
		$is_unresolved   = ! $rate_resolved || ( 0.0 === $input_per_mil && 0.0 === $output_per_mil );

		if ( $is_unresolved ) {
			return new \WP_REST_Response(
				array(
					'provider'            => $provider,
					'model'               => $model,
					'inputTokens'         => $input_tokens,
					'outputTokens'        => $output_tokens,
					'inputPerMillionUsd'  => null,
					'outputPerMillionUsd' => null,
					'estimatedUsd'        => null,
					'source'              => 'fallback',
				),
				200
			);
		}

		$source        = $this->detect_source();
		$estimated_usd = ( $input_tokens / 1000000 ) * $input_per_mil
			+ ( $output_tokens / 1000000 ) * $output_per_mil;

		return new \WP_REST_Response(
			array(
				'provider'            => $provider,
				'model'               => $model,
				'inputTokens'         => $input_tokens,
				'outputTokens'        => $output_tokens,
				'inputPerMillionUsd'  => $input_per_mil,
				'outputPerMillionUsd' => $output_per_mil,
				'estimatedUsd'        => round( $estimated_usd, 6 ),
				'source'              => $source,
			),
			200
		);
	}

	/**
	 * Permission callback.
	 *
	 * React UI is mounted under WooCommerce-managed product screens which run
	 * under shop_manager/editor too, so we gate on `edit_products` rather than
	 * `manage_options`. Nonce verification mirrors
	 * {@see \AIPT\Core\CreditSystem\CreditRest::check_permissions_admin()}.
	 *
	 * @since 2.6.1
	 *
	 * @param \WP_REST_Request $request Incoming REST request.
	 * @return bool|\WP_Error True when capability + nonce both pass. \WP_Error with
	 *                       status 403 when `edit_products` capability is missing
	 *                       or the `$this->verify_nonce` callback rejects the
	 *                       X-WP-Nonce / _wpnonce value.
	 */
	public function check_permissions( \WP_REST_Request $request ) {
		if ( ! \call_user_func( $this->current_user_can, 'edit_products' ) ) {
			return new \WP_Error(
				'rest_forbidden',
				\__( 'You do not have permission to access this endpoint.', 'ai-product-tools' ),
				array( 'status' => 403 )
			);
		}

		$nonce = $request->get_header( 'X-WP-Nonce' );
		if ( empty( $nonce ) ) {
			$nonce = (string) $request->get_param( '_wpnonce' );
		}

		if ( ! \call_user_func( $this->verify_nonce, (string) $nonce, 'wp_rest' ) ) {
			return new \WP_Error(
				'rest_forbidden_nonce',
				\__( 'Nonce verification failed.', 'ai-product-tools' ),
				array( 'status' => 403 )
			);
		}

		return true;
	}

	/**
	 * Best-effort source attribution for the resolved response payload.
	 *
	 * Reports `snapshot` when the cached remote snapshot is fresh, `baseline`
	 * otherwise. Callers must only invoke this on the resolved code path; the
	 * unresolved branch hardcodes `fallback` upstream. The full source chain
	 * inside PricingFeedManager (snapshot/filter/baseline/zero) is collapsed
	 * here because the manager does not expose introspection hooks — see the
	 * task playbook for the agreed approximation.
	 *
	 * @since 2.6.1
	 *
	 * @return string Either `snapshot` or `baseline`.
	 */
	private function detect_source(): string {
		$meta = \call_user_func( $this->snapshot_meta_resolver );
		if ( ! \is_array( $meta ) ) {
			return 'baseline';
		}

		$status = isset( $meta['status'] ) ? (string) $meta['status'] : '';

		return 'fresh' === $status ? 'snapshot' : 'baseline';
	}
}
