<?php

namespace CommerceGuys\Intl\Formatter;

use CommerceGuys\Intl\Currency\Currency;
use CommerceGuys\Intl\Currency\CurrencyRepositoryInterface;
use CommerceGuys\Intl\Exception\InvalidArgumentException;
use CommerceGuys\Intl\Exception\UnknownCurrencyException;
use CommerceGuys\Intl\NumberFormat\NumberFormat;
use CommerceGuys\Intl\NumberFormat\NumberFormatRepositoryInterface;

/**
 * Formats currency amounts using locale-specific patterns.
 */
class CurrencyFormatter implements CurrencyFormatterInterface
{
    use FormatterTrait;

    /**
     * The number format repository.
     *
     * @var NumberFormatRepositoryInterface
     */
    protected NumberFormatRepositoryInterface $numberFormatRepository;

    /**
     * The currency repository.
     *
     * @var CurrencyRepositoryInterface
     */
    protected CurrencyRepositoryInterface $currencyRepository;

    /**
     * The default locale.
     *
     * @var string
     */
    protected string $defaultLocale;

    /**
     * The loaded number formats.
     *
     * @var NumberFormat[]
     */
    protected array $numberFormats = [];

    /**
     * The loaded currencies.
     *
     * @var Currency[]
     */
    protected array $currencies = [];

    /**
     * The default options.
     *
     * @var array
     */
    protected array $defaultOptions = [
        'locale' => 'en',
        'use_grouping' => true,
        'minimum_fraction_digits' => null,
        'maximum_fraction_digits' => null,
        'rounding_mode' => PHP_ROUND_HALF_UP,
        'style' => 'standard',
        'currency_display' => 'symbol',
    ];

    /**
     * Creates a CurrencyFormatter instance.
     *
     * @param NumberFormatRepositoryInterface $numberFormatRepository The number format repository.
     * @param CurrencyRepositoryInterface     $currencyRepository     The currency repository.
     * @param array                           $defaultOptions         The default options.
     *
     * @throws \RuntimeException
     */
    public function __construct(NumberFormatRepositoryInterface $numberFormatRepository, CurrencyRepositoryInterface $currencyRepository, array $defaultOptions = [])
    {
        if (!extension_loaded('bcmath')) {
            throw new \RuntimeException('The bcmath extension is required by CurrencyFormatter.');
        }
        $this->validateOptions($defaultOptions);

        $this->numberFormatRepository = $numberFormatRepository;
        $this->currencyRepository = $currencyRepository;
        $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions);
    }

    /**
     * {@inheritdoc}
     */
    public function format(string $number, string $currencyCode, array $options = []): string
    {
        if (!is_numeric($number)) {
            $message = sprintf('The provided value "%s" is not a valid number or numeric string.', $number);
            throw new InvalidArgumentException($message);
        }

        $this->validateOptions($options);
        $options = array_replace($this->defaultOptions, $options);
        $numberFormat = $this->getNumberFormat($options['locale']);
        $currency = $this->getCurrency($currencyCode, $options['locale']);
        // Use the currency defaults if the values weren't set by the caller.
        if (!isset($options['minimum_fraction_digits'])) {
            $options['minimum_fraction_digits'] = $currency->getFractionDigits();
        }
        if (!isset($options['maximum_fraction_digits'])) {
            $options['maximum_fraction_digits'] = $currency->getFractionDigits();
        }

        $number = (string) $number;
        $number = $this->formatNumber($number, $numberFormat, $options);
        if ($options['currency_display'] == 'symbol') {
            $number = str_replace('¤', $currency->getSymbol(), $number);
        } elseif ($options['currency_display'] == 'code') {
            $number = str_replace('¤', $currency->getCurrencyCode(), $number);
        } else {
            // No symbol should be displayed. Remove leftover whitespace.
            $number = str_replace('¤', '', $number);
            $number = trim($number, " \xC2\xA0");
        }

        return $number;
    }

    /**
     * {@inheritdoc}
     */
    public function parse(string $number, string $currencyCode, array $options = []): string|bool
    {
        $this->validateOptions($options);
        $options = array_replace($this->defaultOptions, $options);
        $numberFormat = $this->getNumberFormat($options['locale']);
        $currency = $this->getCurrency($currencyCode, $options['locale']);
        $replacements = [
            // Strip the currency code or symbol.
            $currency->getCurrencyCode() => '',
            $currency->getSymbol() => '',
        ];
        $number = strtr($number, $replacements);
        $number = $this->parseNumber($number, $numberFormat);

        return $number;
    }

    /**
     * Gets the number format for the provided locale.
     *
     * @param string $locale The locale.
     *
     * @return NumberFormat
     */
    protected function getNumberFormat(string $locale): NumberFormat
    {
        if (!isset($this->numberFormats[$locale])) {
            $this->numberFormats[$locale] = $this->numberFormatRepository->get($locale);
        }

        return $this->numberFormats[$locale];
    }

    /**
     * Gets the currency for the provided currency code and locale.
     *
     * @param string $currencyCode The currency code.
     * @param string $locale       The locale.
     *
     * @return Currency
     */
    protected function getCurrency(string $currencyCode, string $locale): Currency
    {
        if (!isset($this->currencies[$currencyCode][$locale])) {
            try {
                $currency = $this->currencyRepository->get($currencyCode, $locale);
            } catch (UnknownCurrencyException $e) {
                // The requested currency was not found. Fall back
                // to a dummy object to show just the currency code.
                $currency = new Currency([
                   'currency_code' => $currencyCode,
                   'name' => $currencyCode,
                   'numeric_code' => '000',
                   'locale' => $locale,
                ]);
            }
            $this->currencies[$currencyCode][$locale] = $currency;
        }

        return $this->currencies[$currencyCode][$locale];
    }

    /**
     * {@inheritdoc}
     */
    protected function getAvailablePatterns(NumberFormat $numberFormat): array
    {
        return [
            'standard' => $numberFormat->getCurrencyPattern(),
            'accounting' => $numberFormat->getAccountingCurrencyPattern(),
        ];
    }

    /**
     * Validates the provided options.
     *
     * Ensures the absence of unknown keys, correct data types and values.
     *
     * @param array $options The options.
     *
     * @throws \InvalidArgumentException
     */
    protected function validateOptions(array $options)
    {
        foreach ($options as $option => $value) {
            if (!array_key_exists($option, $this->defaultOptions)) {
                throw new InvalidArgumentException(sprintf('Unrecognized option "%s".', $option));
            }
        }
        if (isset($options['use_grouping']) && !is_bool($options['use_grouping'])) {
            throw new InvalidArgumentException('The option "use_grouping" must be a boolean.');
        }
        foreach (['minimum_fraction_digits', 'maximum_fraction_digits'] as $option) {
            if (array_key_exists($option, $options) && !is_numeric($options[$option])) {
                throw new InvalidArgumentException(sprintf('The option "%s" must be numeric.', $option));
            }
        }
        $roundingModes = [
            PHP_ROUND_HALF_UP, PHP_ROUND_HALF_DOWN,
            PHP_ROUND_HALF_EVEN, PHP_ROUND_HALF_ODD, 'none',
        ];
        if (!empty($options['rounding_mode']) && !in_array($options['rounding_mode'], $roundingModes)) {
            throw new InvalidArgumentException(sprintf('Unrecognized rounding mode "%s".', $options['rounding_mode']));
        }
        if (!empty($options['style']) && !in_array($options['style'], ['standard', 'accounting'])) {
            throw new InvalidArgumentException(sprintf('Unrecognized style "%s".', $options['style']));
        }
        if (!empty($options['currency_display']) && !in_array($options['currency_display'], ['code', 'symbol', 'none'])) {
            throw new InvalidArgumentException(sprintf('Unrecognized currency display "%s".', $options['currency_display']));
        }
    }

    /**
     * {@inheritdoc}
     */
    protected function getLocalizedSymbols(NumberFormat $numberFormat): array
    {
        return [
            '.' => $numberFormat->getDecimalCurrencySeparator(),
            ',' => $numberFormat->getGroupingCurrencySeparator(),
            '+' => $numberFormat->getPlusSign(),
            '-' => $numberFormat->getMinusSign(),
            '%' => $numberFormat->getPercentSign(),
        ];
    }
}
