<?php

declare(strict_types=1);

namespace App\Support\Money;

use InvalidArgumentException;
use JsonSerializable;
use Stringable;

/**
 * An immutable monetary amount held as integer minor units.
 *
 * Floating point is never used for money anywhere in this application: a
 * repeated 2.5% commission on a float balance drifts, and a marketplace ledger
 * that does not reconcile to the cent is not defensible in a dispute. Every
 * monetary column is a BIGINT of minor units, and every calculation on those
 * columns goes through this class so that rounding happens in exactly one
 * place, with an explicit, documented rule.
 *
 * @implements JsonSerializable
 */
final readonly class Money implements JsonSerializable, Stringable
{
    /**
     * Minor-unit exponent per currency. Most currencies are two-decimal; the
     * exceptions matter because dividing by the wrong factor silently
     * misprices an entire market.
     *
     * @var array<string, int>
     */
    private const EXPONENTS = [
        'BHD' => 3, 'BIF' => 0, 'CLP' => 0, 'DJF' => 0, 'GNF' => 0,
        'IQD' => 3, 'ISK' => 0, 'JOD' => 3, 'JPY' => 0, 'KMF' => 0,
        'KRW' => 0, 'KWD' => 3, 'LYD' => 3, 'OMR' => 3, 'PYG' => 0,
        'RWF' => 0, 'TND' => 3, 'UGX' => 0, 'UYI' => 0, 'VND' => 0,
        'VUV' => 0, 'XAF' => 0, 'XOF' => 0, 'XPF' => 0,
    ];

    /**
     * @param  int  $minor  Amount in minor units. May be negative (refunds, adjustments).
     * @param  string  $currency  Upper-case ISO 4217 code.
     */
    public function __construct(
        public int $minor,
        public string $currency,
    ) {
        if (! preg_match('/^[A-Z]{3}$/', $currency)) {
            throw new InvalidArgumentException("Invalid currency code [{$currency}].");
        }
    }

    public static function of(int $minor, ?string $currency = null): self
    {
        return new self($minor, strtoupper($currency ?? self::baseCurrency()));
    }

    public static function zero(?string $currency = null): self
    {
        return self::of(0, $currency);
    }

    /**
     * Build from a major-unit decimal string, e.g. "12500.00".
     *
     * Accepts a string rather than a float on purpose: a float argument would
     * already have lost precision before this method could defend against it.
     */
    public static function fromMajorString(string $amount, ?string $currency = null): self
    {
        $currency = strtoupper($currency ?? self::baseCurrency());
        $amount = trim($amount);

        if (! preg_match('/^(-?)(\d+)(?:\.(\d+))?$/', $amount, $m)) {
            throw new InvalidArgumentException("Invalid decimal amount [{$amount}].");
        }

        $exponent = self::exponentFor($currency);
        $fraction = $m[3] ?? '';

        // More decimals than the currency supports is a caller error, not
        // something to silently round away.
        if (strlen($fraction) > $exponent) {
            if (rtrim(substr($fraction, $exponent), '0') !== '') {
                throw new InvalidArgumentException(
                    "Amount [{$amount}] has more precision than {$currency} supports."
                );
            }
            $fraction = substr($fraction, 0, $exponent);
        }

        $fraction = str_pad($fraction, $exponent, '0');
        $minor = (int) ($m[2].$fraction);

        return new self($m[1] === '-' ? -$minor : $minor, $currency);
    }

    public static function baseCurrency(): string
    {
        return strtoupper((string) config('marketplace.base_currency', 'USD'));
    }

    public static function exponentFor(string $currency): int
    {
        return self::EXPONENTS[strtoupper($currency)] ?? 2;
    }

    public function exponent(): int
    {
        return self::exponentFor($this->currency);
    }

    public function plus(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->minor + $other->minor, $this->currency);
    }

    public function minus(self $other): self
    {
        $this->assertSameCurrency($other);

        return new self($this->minor - $other->minor, $this->currency);
    }

    /**
     * Apply a rate expressed in basis points (100 bps = 1%).
     *
     * Basis points keep rates as integers, so a 2.5% commission is 250 -- there
     * is no 0.025 float anywhere in the fee pipeline. Rounding is half-up on
     * the absolute value so that a fee and its mirror-image refund round to the
     * same magnitude instead of drifting by a cent.
     */
    public function percentageBps(int $bps): self
    {
        if ($bps < 0) {
            throw new InvalidArgumentException('Basis points may not be negative.');
        }

        $product = abs($this->minor) * $bps;
        $result = intdiv($product + 5_000, 10_000);

        return new self($this->minor < 0 ? -$result : $result, $this->currency);
    }

    /** Multiply by a whole number, e.g. a line-item quantity. */
    public function multipliedBy(int $factor): self
    {
        return new self($this->minor * $factor, $this->currency);
    }

    public function negated(): self
    {
        return new self(-$this->minor, $this->currency);
    }

    public function absolute(): self
    {
        return new self(abs($this->minor), $this->currency);
    }

    /** Clamp into an inclusive range. Either bound may be omitted. */
    public function clamp(?self $min, ?self $max): self
    {
        $result = $this;

        if ($min !== null) {
            $this->assertSameCurrency($min);
            $result = $result->isLessThan($min) ? $min : $result;
        }

        if ($max !== null) {
            $this->assertSameCurrency($max);
            $result = $result->isGreaterThan($max) ? $max : $result;
        }

        return $result;
    }

    public function isZero(): bool
    {
        return $this->minor === 0;
    }

    public function isPositive(): bool
    {
        return $this->minor > 0;
    }

    public function isNegative(): bool
    {
        return $this->minor < 0;
    }

    public function equals(self $other): bool
    {
        return $this->currency === $other->currency && $this->minor === $other->minor;
    }

    public function isGreaterThan(self $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->minor > $other->minor;
    }

    public function isGreaterThanOrEqualTo(self $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->minor >= $other->minor;
    }

    public function isLessThan(self $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->minor < $other->minor;
    }

    public function isLessThanOrEqualTo(self $other): bool
    {
        $this->assertSameCurrency($other);

        return $this->minor <= $other->minor;
    }

    /**
     * Sum a list of amounts.
     *
     * @param  iterable<self>  $amounts
     */
    public static function sum(iterable $amounts, ?string $currency = null): self
    {
        $total = null;

        foreach ($amounts as $amount) {
            $total = $total === null ? $amount : $total->plus($amount);
        }

        return $total ?? self::zero($currency);
    }

    /**
     * Split into $parts as evenly as possible, distributing the remainder one
     * minor unit at a time so the parts always sum back to the original. Used
     * for splitting a fee across order lines without losing or inventing cents.
     *
     * @return array<int, self>
     */
    public function allocateEvenly(int $parts): array
    {
        if ($parts < 1) {
            throw new InvalidArgumentException('Cannot allocate across fewer than one part.');
        }

        $sign = $this->minor < 0 ? -1 : 1;
        $total = abs($this->minor);
        $base = intdiv($total, $parts);
        $remainder = $total % $parts;

        $result = [];
        for ($i = 0; $i < $parts; $i++) {
            $share = $base + ($i < $remainder ? 1 : 0);
            $result[] = new self($sign * $share, $this->currency);
        }

        return $result;
    }

    /** Decimal string in major units, e.g. "12500.00". Safe for display and JSON. */
    public function toMajorString(): string
    {
        $exponent = $this->exponent();
        $sign = $this->minor < 0 ? '-' : '';
        $digits = str_pad((string) abs($this->minor), $exponent + 1, '0', STR_PAD_LEFT);

        if ($exponent === 0) {
            return $sign.$digits;
        }

        return $sign.substr($digits, 0, -$exponent).'.'.substr($digits, -$exponent);
    }

    /**
     * Human-readable amount. Presentation only -- never parse this back.
     *
     * Falls back to the application's active locale rather than a hardcoded
     * one, so a request from a user whose locale is set gets thousands
     * separators and currency placement they recognise. The float conversion is
     * confined to this method and never feeds a calculation: NumberFormatter
     * needs one, and by this point the value is being rendered, not computed.
     */
    public function format(?string $locale = null): string
    {
        $formatter = new \NumberFormatter($locale ?? self::displayLocale(), \NumberFormatter::CURRENCY);

        return (string) $formatter->formatCurrency(
            (float) $this->toMajorString(),
            $this->currency
        );
    }

    /**
     * The locale to render amounts in.
     *
     * Reads the application locale, which the SetLocale middleware sets from the
     * authenticated user's preference. Normalised to an ICU-friendly form, since
     * Laravel locales are often bare language tags like "en" or "ne".
     */
    public static function displayLocale(): string
    {
        $locale = app()->getLocale();

        return match ($locale) {
            '', 'en' => 'en_US',
            default => str_replace('-', '_', $locale),
        };
    }

    /**
     * @return array{minor: int, currency: string, amount: string, formatted: string}
     */
    public function toArray(?string $locale = null): array
    {
        return [
            'minor' => $this->minor,
            'currency' => $this->currency,
            'amount' => $this->toMajorString(),
            'formatted' => $this->format($locale),
        ];
    }

    /** @return array{minor: int, currency: string, amount: string, formatted: string} */
    public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    public function __toString(): string
    {
        return $this->currency.' '.$this->toMajorString();
    }

    private function assertSameCurrency(self $other): void
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException(
                "Cannot combine {$this->currency} with {$other->currency}."
            );
        }
    }
}
