<?php

declare(strict_types=1);

namespace App\Support\Money;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;

/**
 * Casts a BIGINT minor-unit column into a {@see Money}.
 *
 * Declare it with the name of the sibling column holding the ISO currency code,
 * so an amount can never be read without the currency it is denominated in:
 *
 *     protected function casts(): array
 *     {
 *         return ['price_minor' => MoneyCast::class.':currency'];
 *     }
 *
 * If the model has no currency column, the marketplace base currency is used.
 *
 * @implements CastsAttributes<Money|null, Money|int|string|null>
 */
final class MoneyCast implements CastsAttributes
{
    public function __construct(
        private readonly ?string $currencyColumn = null,
    ) {}

    /** @param  array<string, mixed>  $attributes */
    public function get(Model $model, string $key, mixed $value, array $attributes): ?Money
    {
        if ($value === null) {
            return null;
        }

        return new Money((int) $value, $this->currencyFor($attributes));
    }

    /**
     * @param  array<string, mixed>  $attributes
     * @return array<string, int|null>
     */
    public function set(Model $model, string $key, mixed $value, array $attributes): array
    {
        if ($value === null) {
            return [$key => null];
        }

        if (is_int($value)) {
            return [$key => $value];
        }

        if (is_string($value)) {
            /*
             * A decimal string is accepted; a bare integer string is not.
             *
             * "1900" is genuinely ambiguous here -- it could be $19.00 in minor
             * units or $1,900.00 in major units, and guessing wrong is a
             * hundredfold pricing error. Money::fromMajorString() is permissive
             * because its name states the caller's intent; this cast is fed by
             * request payloads and model assignment, where no such intent is
             * expressed. Callers who mean minor units pass an int; callers who
             * mean major units include the separator.
             *
             * Zero-decimal currencies (JPY, KRW) have no separator to give, so
             * an integer string is unambiguous for them and is accepted.
             */
            $currency = $this->currencyFor($attributes);

            if (Money::exponentFor($currency) > 0 && ! str_contains($value, '.')) {
                throw new InvalidArgumentException(sprintf(
                    'Ambiguous amount [%s] for [%s]: pass an integer for minor units, '.
                    'or include a decimal separator for major units (e.g. "%s.00").',
                    $value,
                    $key,
                    $value,
                ));
            }

            return [$key => Money::fromMajorString($value, $currency)->minor];
        }

        if (! $value instanceof Money) {
            throw new InvalidArgumentException(
                sprintf('Attribute [%s] expects a Money instance, %s given.', $key, get_debug_type($value))
            );
        }

        $expected = $this->currencyFor($attributes);

        if ($value->currency !== $expected) {
            throw new InvalidArgumentException(
                "Cannot store {$value->currency} in [{$key}]; the record is denominated in {$expected}."
            );
        }

        return [$key => $value->minor];
    }

    /** @param  array<string, mixed>  $attributes */
    private function currencyFor(array $attributes): string
    {
        if ($this->currencyColumn !== null && ! empty($attributes[$this->currencyColumn])) {
            return strtoupper((string) $attributes[$this->currencyColumn]);
        }

        return Money::baseCurrency();
    }
}
