<?php
/**
 * Pricing Feed Manager
 *
 * Fetches the normalized model pricing snapshot from api.aiforwoo.com and
 * caches it in a non-autoloaded wp_option. All complexity (LiteLLM key
 * naming, vertex_ai dedup, per-token → per-1M conversion, fallback chain)
 * lives on the backend; this class is intentionally thin.
 *
 * Lookup fallback chain inside `resolve()`:
 *   1. Remote snapshot (wp_option `aipt_pricing_snapshot`)
 *   2. `aipt_api_pricing` filter (back-compat for site overrides)
 *   3. Hardcoded baseline in ApiPricing::PRICING (last-resort offline data)
 *   4. Provider fallback in ApiPricing::FALLBACK_PRICING
 *   5. [0, 0] (silent zero rather than throwing)
 *
 * @package AIPT\Core\Services
 * @since   2.6.0
 */

namespace AIPT\Core\Services;

use AIPT\Api\Constants\ApiPricing;

class PricingFeedManager {

	public const CRON_HOOK         = 'aipt_refresh_pricing_feed';
	public const OPTION_SNAPSHOT   = 'aipt_pricing_snapshot';
	public const OPTION_META       = 'aipt_pricing_snapshot_meta';
	private const LOCK_TRANSIENT   = 'aipt_pricing_refresh_lock';
	private const DEFAULT_FEED_URL = 'https://api.aiforwoo.com/api/v1/pricing';
	private const FETCH_TIMEOUT    = 20;
	private const STALE_AFTER_DAYS = 7;

	/** @var self|null */
	private static $instance = null;

	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Resolve [input_per_million_usd, output_per_million_usd] for a provider/model pair.
	 *
	 * @param string $provider Plugin provider id (openai/claude/gemini/openrouter).
	 * @param string $model    Model identifier as sent to the provider.
	 * @return array{0: float, 1: float}
	 */
	public function resolve( string $provider, string $model ): array {
		$snapshot = $this->read_snapshot_models();
		$rate     = $this->lookup_in_snapshot( $snapshot, $provider, $model );

		if ( null === $rate ) {
			$rate = $this->lookup_in_baseline( $provider, $model );
		}

		/**
		 * Filter the resolved [input_per_million, output_per_million] pair.
		 *
		 * Existing sites that hooked into `aipt_api_pricing` to override prices
		 * continue to work — the filter is applied after the snapshot lookup
		 * so overrides always win.
		 *
		 * @since 2.6.0
		 * @param array{0: float, 1: float} $rate     Current per-1M USD tuple.
		 * @param string                    $provider Plugin provider id.
		 * @param string                    $model    Model identifier.
		 */
		$rate = apply_filters( 'aipt_pricing_resolved_rate', $rate, $provider, $model );

		$rate = $this->apply_legacy_filter( $rate, $provider, $model );

		if ( ! is_array( $rate ) || count( $rate ) < 2 ) {
			return array( 0.0, 0.0 );
		}

		return array( max( 0.0, (float) $rate[0] ), max( 0.0, (float) $rate[1] ) );
	}

	/**
	 * Trigger an HTTP refresh of the remote snapshot.
	 *
	 * @param bool $force When true, ignores the in-progress lock (used by
	 *                    manual admin button after a stuck cron).
	 * @return array{ok: bool, status: string, message: string, model_count: int}
	 */
	public function refresh( bool $force = false ): array {
		if ( ! $force && get_transient( self::LOCK_TRANSIENT ) ) {
			return array(
				'ok'          => false,
				'status'      => 'locked',
				'message'     => 'A refresh is already in progress; try again shortly.',
				'model_count' => $this->get_model_count(),
			);
		}

		set_transient( self::LOCK_TRANSIENT, 1, 5 * MINUTE_IN_SECONDS );

		try {
			$url      = $this->get_feed_url();
			$response = wp_remote_get(
				$url,
				array(
					'timeout' => self::FETCH_TIMEOUT,
					'headers' => array(
						'accept'     => 'application/json',
						'user-agent' => 'AIPT/' . AIPT_VERSION . '; ' . home_url( '/' ),
					),
				)
			);

			if ( $response instanceof \WP_Error ) {
				$message = $response->get_error_message();
				$this->record_failure( $message );
				return array(
					'ok'          => false,
					'status'      => 'error',
					'message'     => $message,
					'model_count' => $this->get_model_count(),
				);
			}

			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code < 200 || $code >= 300 ) {
				$message = sprintf( 'HTTP %d from pricing feed', $code );
				$this->record_failure( $message );
				return array(
					'ok'          => false,
					'status'      => 'error',
					'message'     => $message,
					'model_count' => $this->get_model_count(),
				);
			}

			$body    = (string) wp_remote_retrieve_body( $response );
			$decoded = json_decode( $body, true );
			if ( ! is_array( $decoded ) || empty( $decoded['models'] ) || ! is_array( $decoded['models'] ) ) {
				$this->record_failure( 'Malformed response: missing models map.' );
				return array(
					'ok'          => false,
					'status'      => 'error',
					'message'     => 'Pricing feed returned a malformed payload.',
					'model_count' => $this->get_model_count(),
				);
			}

			$models      = $decoded['models'];
			$source      = isset( $decoded['source'] ) ? sanitize_key( (string) $decoded['source'] ) : 'unknown';
			$upstream_ts = isset( $decoded['updated_at'] ) ? sanitize_text_field( (string) $decoded['updated_at'] ) : '';

			update_option( self::OPTION_SNAPSHOT, $models, false );
			update_option(
				self::OPTION_META,
				array(
					'updated_at'    => time(),
					'upstream_at'   => $upstream_ts,
					'source'        => $source,
					'status'        => 'fresh',
					'model_count'   => count( $models ),
					'failures'      => 0,
					'last_error'    => '',
					'feed_url'      => $url,
				),
				false
			);

			return array(
				'ok'          => true,
				'status'      => 'fresh',
				'message'     => sprintf( 'Pricing snapshot refreshed (%d models).', count( $models ) ),
				'model_count' => count( $models ),
			);
		} finally {
			delete_transient( self::LOCK_TRANSIENT );
		}
	}

	/**
	 * Snapshot metadata for the dashboard UI.
	 *
	 * @return array{
	 *   updated_at:int|null,
	 *   upstream_at:string,
	 *   source:string,
	 *   status:string,
	 *   model_count:int,
	 *   failures:int,
	 *   last_error:string,
	 *   feed_url:string
	 * }
	 */
	public function get_snapshot_meta(): array {
		$meta = get_option( self::OPTION_META, array() );
		if ( ! is_array( $meta ) ) {
			$meta = array();
		}

		$updated_at = isset( $meta['updated_at'] ) ? (int) $meta['updated_at'] : 0;
		$status     = isset( $meta['status'] ) ? (string) $meta['status'] : 'never';

		if ( 'fresh' === $status && $updated_at > 0 ) {
			if ( ( time() - $updated_at ) > self::STALE_AFTER_DAYS * DAY_IN_SECONDS ) {
				$status = 'stale';
			}
		}

		return array(
			'updated_at'  => $updated_at > 0 ? $updated_at : null,
			'upstream_at' => isset( $meta['upstream_at'] ) ? (string) $meta['upstream_at'] : '',
			'source'      => isset( $meta['source'] ) ? (string) $meta['source'] : '',
			'status'      => $status,
			'model_count' => isset( $meta['model_count'] ) ? (int) $meta['model_count'] : 0,
			'failures'    => isset( $meta['failures'] ) ? (int) $meta['failures'] : 0,
			'last_error'  => isset( $meta['last_error'] ) ? (string) $meta['last_error'] : '',
			'feed_url'    => isset( $meta['feed_url'] ) ? (string) $meta['feed_url'] : $this->get_feed_url(),
		);
	}

	/**
	 * Cron entry point — wraps refresh() so a thrown exception never bubbles
	 * into the cron runner.
	 *
	 * @return void
	 */
	public static function cron_callback(): void {
		try {
			self::instance()->refresh( false );
		} catch ( \Throwable $e ) {
			if ( function_exists( 'error_log' ) ) {

			}
		}
	}

	/**
	 * @return array<string, array{in:float, out:float}>
	 */
	private function read_snapshot_models(): array {
		$snapshot = get_option( self::OPTION_SNAPSHOT, array() );
		return is_array( $snapshot ) ? $snapshot : array();
	}

	private function get_model_count(): int {
		$meta = $this->get_snapshot_meta();
		return (int) $meta['model_count'];
	}

	/**
	 * @param array<string, array{in:float, out:float}> $snapshot
	 * @return array{0: float, 1: float}|null
	 */
	private function lookup_in_snapshot( array $snapshot, string $provider, string $model ): ?array {
		if ( empty( $snapshot ) || '' === $model ) {
			return null;
		}

		$candidates = $this->build_lookup_keys( $provider, $model );
		foreach ( $candidates as $key ) {
			if ( isset( $snapshot[ $key ]['in'], $snapshot[ $key ]['out'] ) ) {
				return array(
					(float) $snapshot[ $key ]['in'],
					(float) $snapshot[ $key ]['out'],
				);
			}
		}

		$primary = sprintf( '%s/%s', $provider, $model );
		$best    = null;
		$best_len = 0;
		foreach ( $snapshot as $key => $value ) {
			if ( ! is_string( $key ) || ! isset( $value['in'], $value['out'] ) ) {
				continue;
			}
			if ( strlen( $key ) >= strlen( $primary ) ) {
				continue;
			}
			if ( 0 !== strpos( $primary, $key . '-' ) && 0 !== strpos( $primary, $key ) ) {
				continue;
			}
			if ( strlen( $key ) > $best_len ) {
				$best     = $value;
				$best_len = strlen( $key );
			}
		}
		if ( $best ) {
			return array( (float) $best['in'], (float) $best['out'] );
		}

		return null;
	}

	/**
	 * @return array{0: float, 1: float}
	 */
	private function lookup_in_baseline( string $provider, string $model ): array {
		$baseline = ApiPricing::get_baseline_table();
		$key      = sprintf( '%s/%s', strtolower( $provider ), strtolower( $model ) );
		if ( isset( $baseline[ $key ] ) && is_array( $baseline[ $key ] ) ) {
			return array(
				(float) ( $baseline[ $key ][0] ?? 0 ),
				(float) ( $baseline[ $key ][1] ?? 0 ),
			);
		}

		$best     = null;
		$best_len = 0;
		foreach ( $baseline as $candidate_key => $rate ) {
			if ( ! is_array( $rate ) ) {
				continue;
			}
			if ( strlen( $candidate_key ) >= strlen( $key ) ) {
				continue;
			}
			if ( 0 !== strpos( $key, $candidate_key . '-' ) && 0 !== strpos( $key, $candidate_key ) ) {
				continue;
			}
			if ( strlen( $candidate_key ) > $best_len ) {
				$best     = $rate;
				$best_len = strlen( $candidate_key );
			}
		}
		if ( $best ) {
			return array( (float) ( $best[0] ?? 0 ), (float) ( $best[1] ?? 0 ) );
		}

		return ApiPricing::get_provider_fallback( $provider );
	}

	/**
	 * @return string[]
	 */
	private function build_lookup_keys( string $provider, string $model ): array {
		$provider = strtolower( trim( $provider ) );
		$model    = strtolower( trim( $model ) );
		if ( '' === $provider || '' === $model ) {
			return array();
		}

		$keys = array( sprintf( '%s/%s', $provider, $model ) );

		if ( 'openrouter' === $provider && false === strpos( $model, '/' ) ) {
			$keys[] = sprintf( 'openrouter/%s', $model );
		}

		return $keys;
	}

	/**
	 * Apply the legacy `aipt_api_pricing` filter to preserve overrides set
	 * by sites that wired into the old ApiPricing::PRICING shape.
	 *
	 * @param array{0: float, 1: float} $rate
	 * @return array{0: float, 1: float}
	 */
	private function apply_legacy_filter( array $rate, string $provider, string $model ): array {
		$legacy_table = apply_filters( 'aipt_api_pricing', ApiPricing::get_baseline_table() );
		if ( ! is_array( $legacy_table ) ) {
			return $rate;
		}
		$key = sprintf( '%s/%s', strtolower( $provider ), strtolower( $model ) );
		if ( isset( $legacy_table[ $key ] ) && is_array( $legacy_table[ $key ] ) ) {
			return array(
				(float) ( $legacy_table[ $key ][0] ?? $rate[0] ),
				(float) ( $legacy_table[ $key ][1] ?? $rate[1] ),
			);
		}
		return $rate;
	}

	private function get_feed_url(): string {
		/**
		 * Filter the upstream pricing feed URL.
		 *
		 * Defaults to api.aiforwoo.com — override for self-hosted backends or
		 * test fixtures.
		 *
		 * @since 2.6.0
		 * @param string $url Default feed URL.
		 */
		$url = apply_filters( 'aipt_pricing_feed_url', self::DEFAULT_FEED_URL );
		if ( ! is_string( $url ) || '' === trim( $url ) ) {
			return self::DEFAULT_FEED_URL;
		}
		return esc_url_raw( $url );
	}

	private function record_failure( string $reason ): void {
		$meta = get_option( self::OPTION_META, array() );
		if ( ! is_array( $meta ) ) {
			$meta = array();
		}
		$meta['failures']   = isset( $meta['failures'] ) ? (int) $meta['failures'] + 1 : 1;
		$meta['status']     = 'error';
		$meta['last_error'] = substr( $reason, 0, 500 );
		update_option( self::OPTION_META, $meta, false );
	}
}
