<?php
/**
 * Pricing resolver + ConvertPlus buy-link builder.
 *
 * Two sources: 'mock' (illustrative, no API needed) and 'live' (2Checkout API — wired once
 * account API access is granted). Source is chosen by the 'pricing_source' setting.
 *
 * @package BW_2Checkout_Pricing
 */

defined( 'ABSPATH' ) || exit;

class BW_2Checkout_Pricing_Pricing {

	/** Illustrative FX multipliers for mock mode only. Real rates come from the API. */
	const MOCK_FX = array(
		'USD' => 1.0,
		'EUR' => 0.92,
		'GBP' => 0.79,
		'CAD' => 1.37,
		'AUD' => 1.52,
		'JPY' => 157.0,
	);

	/**
	 * Resolve a price for a product + selection + currency.
	 *
	 * @param string $product_id Local product id (key in the products config).
	 * @param array  $options    Map of option-group code => selected value.
	 * @param string $currency   ISO 4217 code.
	 * @return array|WP_Error
	 */
	public function get_price( $product_id, array $options, $currency, $with_savings = true ) {
		$products = bw_2checkout_pricing_get_products();
		if ( ! isset( $products[ $product_id ] ) ) {
			return new WP_Error( 'bw_2checkout_pricing_unknown_product', __( 'Unknown product.', 'bw-2checkout-pricing' ) );
		}

		$currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) $currency ) );
		if ( '' === $currency ) {
			// No explicit currency (e.g. the selector is hidden) — use the visitor's own.
			$currency = bw_2checkout_pricing_resolve_currency();
		}

		$source = ( 'live' === bw_2checkout_pricing_get_setting( 'pricing_source', 'mock' ) )
			? 'live'
			: 'mock';

		$price = null;
		if ( 'live' === $source ) {
			$live = $this->get_live_price( $products[ $product_id ], $options, $currency );
			if ( ! is_wp_error( $live ) ) {
				$price = $live;
			}
			// Fall through to mock on error so the widget still renders something.
		}
		if ( null === $price ) {
			$price = $this->get_mock_price( $options, $currency );
		}

		$price['savings_percent'] = $with_savings
			? $this->savings_percent( $product_id, $products[ $product_id ], $options, $currency, $price )
			: 0;

		return $price;
	}

	/**
	 * How much cheaper per month this term is than the 1-year term, for the same product, edition,
	 * user count and currency. Comparing monthly rates keeps it meaningful whatever the term length.
	 *
	 * Returns 0 for the 1-year term itself, or if the baseline can't be priced.
	 *
	 * @param string $product_id Local product id.
	 * @param array  $product    Product config.
	 * @param array  $options    Current selection.
	 * @param string $currency   ISO code.
	 * @param array  $price      The already-resolved price for this selection.
	 * @return int Whole-percent saving (0 when there is none).
	 */
	protected function savings_percent( $product_id, $product, array $options, $currency, array $price ) {
		$months = max( 1, (int) ( isset( $price['term_months'] ) ? $price['term_months'] : 12 ) );
		if ( $months <= 12 ) {
			return 0;
		}

		// Swap the term selection for its 1-year equivalent.
		$baseline = $options;
		$found    = false;
		foreach ( (array) $product['selectors'] as $sel ) {
			$group = isset( $sel['group'] ) ? (string) $sel['group'] : '';
			if ( '' === $group || ! isset( $baseline[ $group ] ) ) {
				continue;
			}
			foreach ( (array) ( isset( $sel['values'] ) ? $sel['values'] : array() ) as $opt ) {
				$code = isset( $opt['code'] ) ? (string) $opt['code'] : '';
				if ( preg_match( '/^\s*1\s*Y\s*$/i', $code ) ) {
					$baseline[ $group ] = $code;
					$found              = true;
					break 2;
				}
			}
		}
		if ( ! $found ) {
			return 0;
		}

		$base = $this->get_price( $product_id, $baseline, $currency, false );
		if ( is_wp_error( $base ) || empty( $base['amount'] ) ) {
			return 0;
		}

		$base_monthly = $base['amount'] / max( 1, (int) $base['term_months'] );
		$this_monthly = $price['amount'] / $months;
		if ( $base_monthly <= 0 ) {
			return 0;
		}

		$saving = (int) round( ( 1 - ( $this_monthly / $base_monthly ) ) * 100 );
		return $saving > 0 ? $saving : 0;
	}

	/**
	 * Illustrative pricing so the widget is fully clickable before API access exists.
	 * Per-seat rate for the tier, multiplied by the user count. Clearly labelled 'mock'.
	 *
	 * @param array  $options  Option-group => value.
	 * @param string $currency ISO code.
	 * @return array
	 */
	protected function get_mock_price( array $options, $currency ) {
		$edition = isset( $options['EDITION_2'] ) ? (string) $options['EDITION_2'] : 'advanced';
		$term    = isset( $options['TERM'] ) ? (string) $options['TERM'] : '1Y';
		$users   = isset( $options['users'] ) ? max( 1, (int) $options['users'] ) : 1;

		$per_seat_base = array(
			'basic'    => 34.99,
			'advanced' => 44.99,
			'elite'    => 54.99,
		);
		$term_mult = array(
			'1Y' => 1.0,
			'2Y' => 1.8,
			'3Y' => 2.5,
		);

		$base  = isset( $per_seat_base[ $edition ] ) ? $per_seat_base[ $edition ] : 44.99;
		$tmult = isset( $term_mult[ $term ] ) ? $term_mult[ $term ] : 1.0;
		$vol   = $this->mock_volume_factor( $users );
		$fx    = isset( self::MOCK_FX[ $currency ] ) ? self::MOCK_FX[ $currency ] : 1.0;

		$unit  = round( $base * $tmult * $vol * $fx, 2 );
		$total = round( $unit * $users, 2 );

		return array(
			'source'         => 'mock',
			'currency'       => $currency,
			'symbol'         => bw_2checkout_pricing_currency_symbol( $currency ),
			'symbol_side'    => bw_2checkout_pricing_symbol_side( $currency ),
			'users'          => $users,
			'unit'           => $unit,
			'amount'         => $total,
			'term_months'    => $this->term_months( $options ),
			'net'            => true,
			'unit_formatted' => $this->format( $unit, $currency ),
			'formatted'      => $this->format( $total, $currency ),
		);
	}

	/**
	 * Per-seat volume discount curve for mock mode (matches the shape seen in discovery).
	 *
	 * @param int $users User count.
	 * @return float
	 */
	protected function mock_volume_factor( $users ) {
		if ( $users <= 4 ) {
			return 1.0;
		}
		if ( $users <= 25 ) {
			return 0.978;
		}
		if ( $users <= 50 ) {
			return 0.955;
		}
		if ( $users <= 100 ) {
			return 0.933;
		}
		if ( $users <= 500 ) {
			return 0.911;
		}
		if ( $users <= 1000 ) {
			return 0.889;
		}
		return 0.867;
	}

	/**
	 * Billing length of the selected term, in months. Derived by scanning the selection for a
	 * term-style code ("1Y", "2Y", "3Y"), so no option-group name is hardcoded. Used to present
	 * the price monthly: monthly = total / (12 x term years).
	 *
	 * @param array $options Option-group => value.
	 * @return int Months (defaults to 12).
	 */
	protected function term_months( array $options ) {
		foreach ( $options as $value ) {
			if ( preg_match( '/^\s*(\d+)\s*Y\s*$/i', (string) $value, $m ) ) {
				return max( 1, (int) $m[1] ) * 12;
			}
		}
		return 12;
	}

	/**
	 * Convert a raw "number of users" count into its Scale option bucket code, e.g. 5 => 'users-5-25'.
	 * Matches the 7-tier CDS "Users" scale confirmed in the live pricingconfigurations response.
	 *
	 * @param int $count Raw user count.
	 * @return string
	 */
	protected function bucket_for_users( $count ) {
		$count = max( 1, (int) $count );
		if ( $count <= 4 ) {
			return 'users-1-4';
		}
		if ( $count <= 25 ) {
			return 'users-5-25';
		}
		if ( $count <= 50 ) {
			return 'users-26-50';
		}
		if ( $count <= 100 ) {
			return 'users-51-100';
		}
		if ( $count <= 500 ) {
			return 'users-101-500';
		}
		if ( $count <= 1000 ) {
			return 'users-501-1000';
		}
		return 'users-1001-2000';
	}

	/**
	 * Replace a raw numeric "users" value with its bucket code. Leaves already-coded values
	 * (from <select> options like 'basic', '1Y') untouched.
	 *
	 * @param array $options Option-group => value.
	 * @return array
	 */
	protected function normalize_options( array $options ) {
		if ( isset( $options['users'] ) && ctype_digit( (string) $options['users'] ) ) {
			$options['users'] = $this->bucket_for_users( (int) $options['users'] );
		}
		return $options;
	}

	/**
	 * Does a price row's option codes match the requested selection?
	 *
	 * @param array $row    A row from Prices.Regular.
	 * @param array $wanted Option-group => value (already normalised).
	 * @return bool
	 */
	protected function row_matches( $row, array $wanted ) {
		$row_opts = array();
		foreach ( (array) ( $row['OptionCodes'] ?? array() ) as $oc ) {
			$row_opts[ $oc['Code'] ] = isset( $oc['Options'][0] ) ? $oc['Options'][0] : null;
		}
		foreach ( $wanted as $group => $value ) {
			if ( ! isset( $row_opts[ $group ] ) || (string) $row_opts[ $group ] !== (string) $value ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Choose the pricing configuration that applies to this visitor.
	 *
	 * 2Checkout lets a merchant attach billing countries to a configuration; one configuration is
	 * the eStore default and covers everywhere else. So a country-specific configuration wins over
	 * the default when the visitor's country is listed on it.
	 *
	 * @param array  $configs Pricing configurations for the product.
	 * @param string $country Two-letter country code ('' if unknown).
	 * @return array|null
	 */
	protected function select_config( $configs, $country ) {
		$default = null;
		foreach ( (array) $configs as $cfg ) {
			if ( $country && ! empty( $cfg['BillingCountries'] ) ) {
				foreach ( (array) $cfg['BillingCountries'] as $cc ) {
					if ( strtoupper( $cc ) === $country ) {
						return $cfg;
					}
				}
			}
			if ( ! empty( $cfg['Default'] ) && null === $default ) {
				$default = $cfg;
			}
		}
		return $default;
	}

	/**
	 * Live price via the 2Checkout API.
	 *
	 * A price entered by hand for the visitor's own currency always wins — that is the merchant
	 * deliberately setting a local price, and it must be shown exactly as entered. Only when no such
	 * price exists do we fall back to converting the USD price at 2Checkout's own exchange rate,
	 * which is what the cart would charge anyway.
	 *
	 * The matched Amount is the PER-USER rate for that volume bucket — confirmed per-seat (not
	 * flat-per-tier) by rian's live checkout test (2026-07-13): incrementing 1→2 users increases the
	 * total by exactly the annual per-user rate. Total = Amount × user count.
	 *
	 * @param array  $product  Product config.
	 * @param array  $options  Option-group => value.
	 * @param string $currency ISO code.
	 * @return array|WP_Error
	 */
	protected function get_live_price( $product, array $options, $currency ) {
		$client = new BW_2Checkout_Pricing_Client();
		if ( ! $client->is_configured() ) {
			return new WP_Error( 'bw_2checkout_pricing_not_configured', __( 'API credentials not configured.', 'bw-2checkout-pricing' ) );
		}

		$code    = isset( $product['product_code'] ) ? (string) $product['product_code'] : '';
		$configs = $client->get_pricing_configurations( $code );
		if ( is_wp_error( $configs ) ) {
			return $configs;
		}

		$config = $this->select_config( $configs, bw_2checkout_pricing_detect_country() );
		if ( ! $config ) {
			return new WP_Error( 'bw_2checkout_pricing_no_default', __( 'No applicable pricing configuration found.', 'bw-2checkout-pricing' ) );
		}

		$currency = strtoupper( $currency );
		$wanted   = $this->normalize_options( $options );
		$rows     = isset( $config['Prices']['Regular'] ) ? (array) $config['Prices']['Regular'] : array();

		// Pass 1: a price entered explicitly in the visitor's currency. Pass 2: the USD baseline.
		$exact = null;
		$usd   = null;
		foreach ( $rows as $row ) {
			$row_cur = strtoupper( $row['Currency'] ?? '' );
			if ( $row_cur !== $currency && 'USD' !== $row_cur ) {
				continue;
			}
			if ( ! $this->row_matches( $row, $wanted ) ) {
				continue;
			}
			if ( $row_cur === $currency ) {
				$exact = (float) $row['Amount'];
			} elseif ( null === $usd ) {
				$usd = (float) $row['Amount'];
			}
		}

		if ( null !== $exact ) {
			$unit   = $exact;
			$origin = 'entered';
		} elseif ( null !== $usd ) {
			if ( 'USD' === $currency ) {
				$unit = $usd;
			} else {
				$fx = $this->fx_multiplier( $client, $product, $code, $currency );
				if ( is_wp_error( $fx ) ) {
					return $fx;
				}
				$unit = round( $usd * $fx, 2 );
			}
			$origin = 'converted';
		} else {
			return new WP_Error( 'bw_2checkout_pricing_no_match', __( 'No live price found for that selection.', 'bw-2checkout-pricing' ) );
		}

		$users = isset( $options['users'] ) ? max( 1, (int) $options['users'] ) : 1;
		$total = round( $unit * $users, 2 );

		return array(
			'source'         => 'live',
			'price_origin'   => $origin,
			'config_code'    => isset( $config['Code'] ) ? $config['Code'] : '',
			'currency'       => $currency,
			'symbol'         => bw_2checkout_pricing_currency_symbol( $currency ),
			'symbol_side'    => bw_2checkout_pricing_symbol_side( $currency ),
			'users'          => $users,
			'unit'           => $unit,
			'amount'         => $total,
			'term_months'    => $this->term_months( $options ),
			'net'            => ( 'NET' === strtoupper( $config['PriceType'] ?? 'NET' ) ),
			'unit_formatted' => $this->format( $unit, $currency ),
			'formatted'      => $this->format( $total, $currency ),
		);
	}

	/**
	 * Prime the caches for a product in a currency, using its default selection. Everything the
	 * front end needs (the product's price data and the currency's exchange rate) is shared, so
	 * one call per product/currency is enough.
	 *
	 * @param string $product_id Local product id.
	 * @param string $currency   ISO code.
	 * @return bool True if a price resolved.
	 */
	public function warm( $product_id, $currency ) {
		$products = bw_2checkout_pricing_get_products();
		if ( ! isset( $products[ $product_id ] ) ) {
			return false;
		}

		$options = array();
		foreach ( (array) $products[ $product_id ]['selectors'] as $sel ) {
			$group = isset( $sel['group'] ) ? (string) $sel['group'] : '';
			if ( '' === $group ) {
				continue;
			}
			if ( 'number' === ( isset( $sel['type'] ) ? $sel['type'] : 'select' ) ) {
				$options[ $group ] = isset( $sel['default'] ) ? (int) $sel['default'] : 1;
			} elseif ( isset( $sel['values'][0]['code'] ) ) {
				$options[ $group ] = $sel['values'][0]['code'];
			}
		}

		$price = $this->get_price( $product_id, $options, $currency );
		return ! is_wp_error( $price );
	}

	/**
	 * 2Checkout's conversion rate from USD into the given currency.
	 *
	 * The rate is a property of the currency, not of the selection, so it's measured once against a
	 * fixed reference selection and cached per product+currency. That keeps every other term /
	 * edition / user combination free — previously each new combination cost two API round-trips,
	 * which is what made switching options feel slow.
	 *
	 * @param BW_2Checkout_Pricing_Client $client   API client.
	 * @param array                       $product  Product config.
	 * @param string                      $code     2Checkout product code.
	 * @param string                      $currency Target ISO code.
	 * @return float|WP_Error
	 */
	protected function fx_multiplier( $client, $product, $code, $currency ) {
		$currency = strtoupper( $currency );
		$key      = 'bw_2checkout_pricing_fx_' . md5( $code . '|' . $currency );
		$cached   = get_transient( $key );
		if ( false !== $cached ) {
			return (float) $cached;
		}

		// A single fixed selection (first value of each option) is enough to read the rate off.
		$reference = array();
		foreach ( (array) $product['selectors'] as $sel ) {
			$group = isset( $sel['group'] ) ? (string) $sel['group'] : '';
			if ( '' === $group || 'number' === ( isset( $sel['type'] ) ? $sel['type'] : 'select' ) ) {
				continue;
			}
			if ( isset( $sel['values'][0]['code'] ) ) {
				$reference[ $group ] = $sel['values'][0]['code'];
			}
		}

		$cur_base = $client->order_preview_unit_net( $code, $reference, $currency );
		$usd_base = $client->order_preview_unit_net( $code, $reference, 'USD' );
		if ( is_wp_error( $cur_base ) ) {
			return $cur_base;
		}
		if ( is_wp_error( $usd_base ) || $usd_base <= 0 ) {
			return new WP_Error( 'bw_2checkout_pricing_fx_unavailable', __( 'Currency conversion unavailable.', 'bw-2checkout-pricing' ) );
		}

		$fx = (float) $cur_base / (float) $usd_base;
		// Exchange rates move slowly, so hold this longer than the price cache.
		set_transient( $key, $fx, 12 * HOUR_IN_SECONDS );
		return $fx;
	}

	/**
	 * Build a ConvertPlus buy-link from a selection.
	 *
	 * @param string $product_id Local product id.
	 * @param array  $options    Option-group => value.
	 * @param string $currency   ISO code.
	 * @return string
	 */
	public function build_buy_link( $product_id, array $options, $currency, $tpl = null ) {
		$products = bw_2checkout_pricing_get_products();
		if ( ! isset( $products[ $product_id ]['product_code'] ) ) {
			return '';
		}

		$base     = (string) bw_2checkout_pricing_get_setting( 'checkout_base', 'https://secure.2checkout.com/checkout/buy' );
		$merchant = (string) bw_2checkout_pricing_get_setting( 'checkout_merchant', '' );
		if ( '' === $merchant ) {
			$merchant = bw_2checkout_pricing_get_merchant_code();
		}
		// Per-block template override (e.g. COPERNIC-two-columns) falls back to the plugin setting.
		$tpl = ( null !== $tpl && '' !== $tpl )
			? (string) $tpl
			: (string) bw_2checkout_pricing_get_setting( 'checkout_tpl', 'default' );
		$prod = (string) $products[ $product_id ]['product_code'];

		// The "users" Scale option takes the RAW user count (e.g. users:5); the cart both selects
		// the volume tier and multiplies by it, so product qty stays 1. Mirrors the buy-link
		// construct Copernic provided (qty=1 & opt=...,users:5). NOTE: still to be confirmed with
		// one real test order that the resulting cart total matches the displayed price.
		$opt_parts = array();
		foreach ( $options as $group => $value ) {
			$opt_parts[] = rawurlencode( (string) $group ) . ':' . rawurlencode( (string) $value );
		}

		$query = 'merchant=' . rawurlencode( $merchant )
			. '&prod=' . rawurlencode( $prod )
			. '&qty=1'
			. '&tpl=' . rawurlencode( $tpl )
			. '&currency=' . rawurlencode( strtoupper( $currency ) );

		if ( ! empty( $opt_parts ) ) {
			$query .= '&opt=' . implode( ',', $opt_parts );
		}
		if ( bw_2checkout_pricing_get_setting( 'test_mode', true ) ) {
			$query .= '&DOTEST=1';
		}

		return $base . '?' . $query;
	}

	/**
	 * Format an amount for display. Rough — a proper locale/symbol map comes with the polish pass.
	 *
	 * @param float  $amount   Amount.
	 * @param string $currency ISO code.
	 * @return string
	 */
	protected function format( $amount, $currency ) {
		return number_format( (float) $amount, 2 ) . ' ' . strtoupper( $currency );
	}
}
