<?php
defined('ABSPATH') || exit;

/**
 * Currency handling.
 *
 * Metal quotes arrive from the price service in USD and are converted to the
 * currency configured on the settings page. That currency should always match
 * the WooCommerce store currency: the prices this plugin computes are written
 * as ordinary WooCommerce product prices, which carry no currency of their own,
 * so a mismatch silently mis-prices the shop. The settings page warns about it
 * rather than forcing a value, because a shop may legitimately be mid-migration.
 */
class GSPSYNC_Currency
{
    const OPTION           = 'gspsync_currency';
    const DEFAULT_CURRENCY = 'EUR';

    const RATES_TRANSIENT = 'gspsync_fx_rates';
    const RATES_OPTION    = 'gspsync_fx_rates_last_good';

    /**
     * Currencies the plugin can price in, as code => English name.
     *
     * The major traded currencies only, which keeps the setting readable and
     * every entry backed by a rate the feed always quotes. Names are a fallback:
     * WooCommerce provides translated ones. Shops needing another currency can
     * add it through the `gspsync_supported_currencies` filter.
     */
    private static $supported = array(
        'AUD' => 'Australian dollar',
        'CAD' => 'Canadian dollar',
        'CHF' => 'Swiss franc',
        'EUR' => 'Euro',
        'GBP' => 'Pound sterling',
        'JPY' => 'Japanese yen',
        'NZD' => 'New Zealand dollar',
        'USD' => 'United States dollar',
    );

    /**
     * Supported currencies as code => display name.
     *
     * @return array
     */
    public static function get_supported()
    {
        static $list = null;

        if ($list === null) {
            $wc_names = function_exists('get_woocommerce_currencies') ? get_woocommerce_currencies() : array();

            $list = array();
            foreach (self::$supported as $code => $name) {
                $list[$code] = isset($wc_names[$code]) ? $wc_names[$code] : $name;
            }

            $list = apply_filters('gspsync_supported_currencies', $list);
        }

        return $list;
    }

    /**
     * @param string $currency Currency code.
     * @return bool
     */
    public static function is_supported($currency)
    {
        return is_string($currency) && isset(self::get_supported()[strtoupper($currency)]);
    }

    /**
     * The currency prices are calculated in.
     *
     * Falls back to the store currency, so a fresh install is correct before the
     * setting is ever opened.
     *
     * @return string Currency code.
     */
    public static function get()
    {
        $currency = get_option(self::OPTION, '');

        if (self::is_supported($currency)) {
            return strtoupper($currency);
        }

        $store_currency = self::get_store_currency();

        return self::is_supported($store_currency) ? $store_currency : self::DEFAULT_CURRENCY;
    }

    /**
     * @return string The WooCommerce store currency code.
     */
    public static function get_store_currency()
    {
        return function_exists('get_woocommerce_currency')
            ? strtoupper(get_woocommerce_currency())
            : self::DEFAULT_CURRENCY;
    }

    /**
     * Whether the configured currency matches the store one. When it does not,
     * every price this plugin writes is off by the exchange rate.
     *
     * @return bool
     */
    public static function matches_store()
    {
        return self::get() === self::get_store_currency();
    }

    /**
     * Currency symbol, as returned by WooCommerce (may contain HTML entities).
     *
     * @param string|null $currency Defaults to the configured currency.
     * @return string
     */
    public static function get_symbol($currency = null)
    {
        $currency = $currency ? strtoupper($currency) : self::get();

        if (function_exists('get_woocommerce_currency_symbol')) {
            $symbol = get_woocommerce_currency_symbol($currency);
            if (!empty($symbol)) {
                return $symbol;
            }
        }

        return $currency;
    }

    /**
     * Currency symbol as plain text, for JSON payloads and attribute values.
     *
     * @param string|null $currency Defaults to the configured currency.
     * @return string
     */
    public static function get_symbol_text($currency = null)
    {
        return html_entity_decode(self::get_symbol($currency), ENT_QUOTES, 'UTF-8');
    }

    /**
     * Format an amount as plain text, honouring the WooCommerce separators and
     * symbol position so plugin output matches the rest of the shop.
     *
     * @param float       $amount   Amount to format.
     * @param int         $decimals Decimal places.
     * @param string|null $currency Defaults to the configured currency.
     * @return string
     */
    public static function format($amount, $decimals = 2, $currency = null)
    {
        $decimal_sep  = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : ',';
        $thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : '.';

        $formatted = number_format((float) $amount, $decimals, $decimal_sep, $thousand_sep);
        $symbol    = self::get_symbol_text($currency);

        switch (get_option('woocommerce_currency_pos', 'left')) {
            case 'right':
                return $formatted . $symbol;
            case 'right_space':
                return $formatted . ' ' . $symbol;
            case 'left_space':
                return $symbol . ' ' . $formatted;
            case 'left':
            default:
                return $symbol . $formatted;
        }
    }

    /**
     * Formatting parameters for the admin and cart scripts, so JavaScript can
     * format amounts the same way PHP does without a round trip.
     *
     * @return array
     */
    public static function get_js_params()
    {
        return array(
            'code'          => self::get(),
            'symbol'        => self::get_symbol_text(),
            'position'      => get_option('woocommerce_currency_pos', 'left'),
            'decimal_sep'   => function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : ',',
            'thousand_sep'  => function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : '.',
        );
    }

    /**
     * Conversion rate from USD to the given currency.
     *
     * The feed quotes every currency in one response, so all rates are cached
     * together and switching currency costs no extra remote call. Returns false
     * when no rate can be obtained: callers must then leave the stored prices
     * alone rather than write a wrong one.
     *
     * @param string $currency Target currency code.
     * @return float|false
     */
    public static function get_usd_rate($currency)
    {
        $currency = strtoupper($currency);

        if ('USD' === $currency) {
            return 1.0;
        }

        $rates = get_transient(self::RATES_TRANSIENT);

        if (!is_array($rates) || !isset($rates[$currency])) {
            $rates = self::fetch_rates();
        }

        if (is_array($rates) && isset($rates[$currency])) {
            return (float) $rates[$currency];
        }

        // Last known good rates: an outage of the rate feed should not stop a
        // price update, since rates move far less than metal quotes do.
        $stored = get_option(self::RATES_OPTION, array());
        if (is_array($stored) && isset($stored[$currency]) && self::is_valid_rate($stored[$currency])) {
            gspsync_log("GSPSYNC: exchange rate feed unavailable, using last known USD/{$currency} rate");
            return (float) $stored[$currency];
        }

        gspsync_log("GSPSYNC: no USD/{$currency} exchange rate available");

        return false;
    }

    /**
     * Fetch and cache every rate quoted against USD.
     *
     * @return array|false Rates as code => float, or false on failure.
     */
    private static function fetch_rates()
    {
        $url = apply_filters('gspsync_exchange_rate_api_url', 'https://api.exchangerate-api.com/v4/latest/USD');

        $response = wp_remote_get($url, array(
            'headers' => array('Accept' => 'application/json'),
            'timeout' => 10,
        ));

        if (is_wp_error($response)) {
            gspsync_log('GSPSYNC: exchange rate request failed: ' . $response->get_error_message());
            return false;
        }

        $data = json_decode(wp_remote_retrieve_body($response), true);

        if (!is_array($data) || empty($data['rates']) || !is_array($data['rates'])) {
            gspsync_log('GSPSYNC: unexpected exchange rate response');
            return false;
        }

        $rates = array();
        foreach ($data['rates'] as $code => $rate) {
            if (self::is_valid_rate($rate)) {
                $rates[strtoupper($code)] = (float) $rate;
            }
        }

        if (empty($rates)) {
            return false;
        }

        set_transient(self::RATES_TRANSIENT, $rates, HOUR_IN_SECONDS);
        update_option(self::RATES_OPTION, $rates);

        return $rates;
    }

    /**
     * Plausibility check on a single rate. The band is deliberately wide: one
     * USD buys well under a euro but tens of thousands of some other units.
     *
     * @param mixed $rate Rate to check.
     * @return bool
     */
    private static function is_valid_rate($rate)
    {
        return is_numeric($rate) && $rate > 0 && $rate < 1000000;
    }

    /**
     * Drop cached rates, so the next lookup hits the feed.
     */
    public static function clear_rates_cache()
    {
        delete_transient(self::RATES_TRANSIENT);
    }
}
