lib/fastdecimal.ex

defmodule FastDecimal do
  @moduledoc """
  Fast arbitrary-precision decimal arithmetic for Elixir.

  A decimal is represented as `coef * 10^exp` where `coef` is a BEAM integer
  (sign carried inline) and `exp` is an integer exponent. Operations work on
  raw integers in the hot path; values that exceed 60-bit immediate ints
  promote to BEAM bignums automatically.

  ## Design: exact arithmetic, explicit precision

  Unlike `Decimal`, FastDecimal does **not** maintain an implicit per-process
  precision context. `add`, `sub`, and `mult` are mathematically exact — the
  result coefficient grows to whatever size is needed. Only `div/3` takes a
  precision argument, because division is the only operation that can produce
  a non-terminating decimal.

  If you want bounded precision after an arithmetic chain, call `round/2` or
  `normalize/1` explicitly. Trading implicit context for explicit calls is
  faster (no process-dict lookup per op) and easier to reason about.

  ## Construction

      iex> FastDecimal.new("1.23")
      %FastDecimal{coef: 123, exp: -2}

      iex> FastDecimal.new(123, -2)
      %FastDecimal{coef: 123, exp: -2}

  Use the `~d` sigil for compile-time literals (zero runtime parse cost):

      import FastDecimal
      ~d"1.23"   # => %FastDecimal{coef: 123, exp: -2}

  ## Arithmetic

      iex> FastDecimal.add(FastDecimal.new("1.23"), FastDecimal.new("4.567"))
      %FastDecimal{coef: 5797, exp: -3}

      iex> FastDecimal.div(FastDecimal.new("10"), FastDecimal.new("3"), precision: 5)
      %FastDecimal{coef: 33333, exp: -4}

  ## Comparison

      iex> FastDecimal.compare(FastDecimal.new("1.10"), FastDecimal.new("1.1"))
      :eq

      iex> FastDecimal.equal?(FastDecimal.new("1.10"), FastDecimal.new("1.1"))
      true
  """

  alias FastDecimal.Parser

  @enforce_keys [:coef, :exp]
  defstruct [:coef, :exp]

  @type coef :: integer() | :nan | :inf | :neg_inf
  @type t :: %__MODULE__{coef: coef(), exp: integer()}
  @type rounding_mode :: :half_even | :half_up | :half_down | :down | :up | :floor | :ceiling

  # Security: exponent-amplification DoS bound. CVE-2026-32686 (in `decimal`)
  # showed that compact inputs like `1e1000000000` could force multi-second
  # expansions or OOM at materialization time (to_string, add-with-huge-gap,
  # etc). Decimal v2.4.0 mitigated by sticky-bit precision-bounded scaling;
  # we cap `pow10/1` at @max_safe_pow10 instead, which catches the same
  # attack vector at a single chokepoint. 100_000 keeps every legitimate
  # use case in the fast path (fintech tops out around exp ±30, IEEE 754
  # decimal128 tops at ±6144) while killing the runaway path.
  @max_safe_pow10 100_000

  # Note: the parser has its own `@max_parse_exponent` constant (matching
  # this module's intent) — it lives there so it can early-exit during
  # digit accumulation. Defense in depth: even if parsing slipped a huge
  # value through, the `pow10` cap above would still catch downstream ops.

  # to_string output cap. Refuse to materialize binaries larger than this.
  # 1 MB is way above any reasonable printed-decimal size.
  @max_to_string_bytes 1_048_576

  # We can't use `%__MODULE__{}` in module attributes (struct not yet defined
  # at that point). Use the raw map form — it's identical at runtime and
  # pattern-matches as a FastDecimal struct.
  @nan %{__struct__: __MODULE__, coef: :nan, exp: 0}
  @inf %{__struct__: __MODULE__, coef: :inf, exp: 0}
  @neg_inf %{__struct__: __MODULE__, coef: :neg_inf, exp: 0}

  @compile {:inline,
            new: 1,
            new: 2,
            add: 2,
            sub: 2,
            mult: 2,
            negate: 1,
            abs: 1,
            zero?: 1,
            positive?: 1,
            negative?: 1,
            compare: 2,
            equal?: 2,
            nan?: 1,
            inf?: 1,
            finite?: 1,
            pow10: 1}

  # ---- Guard-safe macro ---------------------------------------------------

  @doc """
  Guard-safe predicate. True when the argument is a `%FastDecimal{}` struct.

      defmodule MyMod do
        require FastDecimal

        def total(d) when FastDecimal.is_decimal(d) do
          # ...
        end
      end

  Mirrors `Decimal.Macros.is_decimal/1` so it can be drop-in-substituted.
  """
  defmacro is_decimal(value) do
    quote do
      is_struct(unquote(value), FastDecimal)
    end
  end

  # ---- Special-value constants --------------------------------------------

  @doc "Returns the NaN sentinel value."
  @spec nan() :: t()
  def nan, do: @nan

  @doc "Returns the +∞ sentinel value."
  @spec inf() :: t()
  def inf, do: @inf

  @doc "Returns the -∞ sentinel value."
  @spec neg_inf() :: t()
  def neg_inf, do: @neg_inf

  # ---- Construction --------------------------------------------------------

  @spec new(String.t() | integer()) :: t()
  def new(int) when is_integer(int), do: %__MODULE__{coef: int, exp: 0}

  def new(str) when is_binary(str) do
    case Parser.parse(str) do
      {:ok, {coef, exp}} -> %__MODULE__{coef: coef, exp: exp}
      :error -> raise ArgumentError, "could not parse #{inspect(str)} as a FastDecimal"
    end
  end

  @spec new(integer(), integer()) :: t()
  def new(coef, exp) when is_integer(coef) and is_integer(exp),
    do: %__MODULE__{coef: coef, exp: exp}

  @spec from_integer(integer()) :: t()
  def from_integer(int) when is_integer(int), do: %__MODULE__{coef: int, exp: 0}

  @doc """
  Convert an Elixir float to a FastDecimal via `Float.to_string/1`. The result
  is the decimal value that `Float.to_string/1` would print for the float —
  not the exact rational represented by the IEEE 754 bits.

      iex> FastDecimal.from_float(1.5)
      %FastDecimal{coef: 15, exp: -1}

      iex> FastDecimal.from_float(0.1)
      %FastDecimal{coef: 1, exp: -1}

  Mirrors `Decimal.from_float/1` for drop-in compatibility. For literal-float
  inputs in code, prefer the `~d` sigil — it parses at compile time with no
  runtime cost.
  """
  @spec from_float(float()) :: t()
  def from_float(float) when is_float(float) do
    case parse(Float.to_string(float)) do
      {:ok, d} -> d
      :error -> raise ArgumentError, "could not convert #{inspect(float)} to a FastDecimal"
    end
  end

  @spec parse(String.t()) :: {:ok, t()} | :error
  def parse(str) when is_binary(str) do
    case Parser.parse(str) do
      {:ok, {coef, exp}} -> {:ok, %__MODULE__{coef: coef, exp: exp}}
      :error -> :error
    end
  end

  # ---- Sigil ---------------------------------------------------------------

  @doc """
  Compile-time literal sigil. `~d"1.23"` becomes a `%FastDecimal{}` at compile
  time, paying zero parse cost at runtime.

  Use by importing: `import FastDecimal`, then `~d"1.23"`.
  """
  defmacro sigil_d({:<<>>, _, [string]}, _modifiers) when is_binary(string) do
    case Parser.parse(string) do
      {:ok, {coef, exp}} ->
        quote do
          %FastDecimal{coef: unquote(coef), exp: unquote(exp)}
        end

      :error ->
        raise ArgumentError, "could not parse #{inspect(string)} as a FastDecimal"
    end
  end

  defmacro sigil_d({:<<>>, _, _parts}, _modifiers) do
    raise ArgumentError, "~d sigil only supports literal binaries (no interpolation)"
  end

  # ---- Arithmetic ----------------------------------------------------------

  # Implementation note: fast path is gated with `is_integer(c1) and is_integer(c2)`
  # so the cheap-int case stays at ~42 ns. Operations involving NaN/Inf fall
  # through to the `_special` clauses, which are correct but slower.
  #
  # Earlier we tried `%{a | coef: c1 + c2}` (Elixir's strict-update form) to
  # coax BEAM into emitting `put_map_exact` instead of `put_map_assoc`. The
  # bytecode change happened but wall-time didn't move — actually got 1%
  # slower in the head-to-head. BEAMAsm has tight impls of both ops for tiny
  # structs. We use the literal-struct form because every field is explicit
  # at the call site.

  @spec add(t(), t()) :: t()
  def add(%__MODULE__{coef: c1, exp: e}, %__MODULE__{coef: c2, exp: e})
      when is_integer(c1) and is_integer(c2),
      do: %__MODULE__{coef: c1 + c2, exp: e}

  def add(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) and e1 < e2,
      do: %__MODULE__{coef: c1 + c2 * pow10(e2 - e1), exp: e1}

  def add(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2),
      do: %__MODULE__{coef: c1 * pow10(e1 - e2) + c2, exp: e2}

  def add(a, b), do: add_special(a, b)

  defp add_special(%__MODULE__{coef: :nan}, _), do: @nan
  defp add_special(_, %__MODULE__{coef: :nan}), do: @nan
  defp add_special(%__MODULE__{coef: :inf}, %__MODULE__{coef: :neg_inf}), do: @nan
  defp add_special(%__MODULE__{coef: :neg_inf}, %__MODULE__{coef: :inf}), do: @nan
  defp add_special(%__MODULE__{coef: :inf}, _), do: @inf
  defp add_special(_, %__MODULE__{coef: :inf}), do: @inf
  defp add_special(%__MODULE__{coef: :neg_inf}, _), do: @neg_inf
  defp add_special(_, %__MODULE__{coef: :neg_inf}), do: @neg_inf

  @spec sub(t(), t()) :: t()
  def sub(%__MODULE__{coef: c1, exp: e}, %__MODULE__{coef: c2, exp: e})
      when is_integer(c1) and is_integer(c2),
      do: %__MODULE__{coef: c1 - c2, exp: e}

  def sub(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) and e1 < e2,
      do: %__MODULE__{coef: c1 - c2 * pow10(e2 - e1), exp: e1}

  def sub(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2),
      do: %__MODULE__{coef: c1 * pow10(e1 - e2) - c2, exp: e2}

  def sub(a, b), do: add_special(a, negate(b))

  @spec mult(t(), t()) :: t()
  def mult(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2),
      do: %__MODULE__{coef: c1 * c2, exp: e1 + e2}

  def mult(a, b), do: mult_special(a, b)

  defp mult_special(%__MODULE__{coef: :nan}, _), do: @nan
  defp mult_special(_, %__MODULE__{coef: :nan}), do: @nan
  defp mult_special(%__MODULE__{coef: 0}, %__MODULE__{coef: :inf}), do: @nan
  defp mult_special(%__MODULE__{coef: 0}, %__MODULE__{coef: :neg_inf}), do: @nan
  defp mult_special(%__MODULE__{coef: :inf}, %__MODULE__{coef: 0}), do: @nan
  defp mult_special(%__MODULE__{coef: :neg_inf}, %__MODULE__{coef: 0}), do: @nan
  defp mult_special(%__MODULE__{coef: :inf}, b), do: if(negative?(b), do: @neg_inf, else: @inf)

  defp mult_special(%__MODULE__{coef: :neg_inf}, b),
    do: if(negative?(b), do: @inf, else: @neg_inf)

  defp mult_special(a, %__MODULE__{coef: :inf}), do: if(negative?(a), do: @neg_inf, else: @inf)

  defp mult_special(a, %__MODULE__{coef: :neg_inf}),
    do: if(negative?(a), do: @inf, else: @neg_inf)

  defdelegate multiply(a, b), to: __MODULE__, as: :mult

  @doc """
  Division with configurable precision and rounding.

  Options:
    * `:precision` — number of significant digits to keep in the result (default `28`)
    * `:rounding` — `:half_even` (default, banker's), `:half_up`, `:half_down`,
      `:down`, `:up`, `:floor`, `:ceiling`
  """
  @spec div(t(), t(), keyword()) :: t()
  def div(a, b, opts \\ [])

  def div(_, %__MODULE__{coef: 0}, _opts), do: raise(ArithmeticError, "decimal division by zero")

  def div(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2}, opts)
      when is_integer(c1) and is_integer(c2) do
    precision = Keyword.get(opts, :precision, 28)
    mode = Keyword.get(opts, :rounding, :half_even)

    # Shift c1 so the integer quotient `c1 * 10^shift / c2` has at least
    # precision + 1 digits — the extra "scratch" digit is used to round.
    # The natural quotient digit count is digits(c1) - digits(c2) + {0,1},
    # so we pick a generous shift and trim down to exactly precision + 1.
    shift = precision + 1 + digits(Kernel.abs(c2)) - digits(Kernel.abs(c1))
    shift = if shift < 0, do: 0, else: shift

    dividend = c1 * pow10(shift)
    quot = Kernel.div(dividend, c2)
    rem = Kernel.rem(dividend, c2)

    # Trim down to exactly precision + 1 digits in the quotient, folding any
    # trimmed digits into the "has tail" signal for rounding.
    quot_digits = digits(Kernel.abs(quot))
    excess = quot_digits - (precision + 1)

    {quot, has_tail, shift} =
      if excess > 0 do
        divisor = pow10(excess)
        trimmed_nonzero = Kernel.rem(quot, divisor) != 0
        {Kernel.div(quot, divisor), trimmed_nonzero or rem != 0, shift - excess}
      else
        {quot, rem != 0, shift}
      end

    rounded = round_div(quot, has_tail, mode)
    %__MODULE__{coef: rounded, exp: e1 - e2 - shift + 1}
  end

  def div(a, b, _opts), do: div_special(a, b)

  defp div_special(%__MODULE__{coef: :nan}, _), do: @nan
  defp div_special(_, %__MODULE__{coef: :nan}), do: @nan
  defp div_special(%__MODULE__{coef: :inf}, %__MODULE__{coef: :inf}), do: @nan
  defp div_special(%__MODULE__{coef: :inf}, %__MODULE__{coef: :neg_inf}), do: @nan
  defp div_special(%__MODULE__{coef: :neg_inf}, %__MODULE__{coef: :inf}), do: @nan
  defp div_special(%__MODULE__{coef: :neg_inf}, %__MODULE__{coef: :neg_inf}), do: @nan
  defp div_special(%__MODULE__{coef: :inf}, b), do: if(negative?(b), do: @neg_inf, else: @inf)
  defp div_special(%__MODULE__{coef: :neg_inf}, b), do: if(negative?(b), do: @inf, else: @neg_inf)
  defp div_special(_, %__MODULE__{coef: :inf}), do: %__MODULE__{coef: 0, exp: 0}
  defp div_special(_, %__MODULE__{coef: :neg_inf}), do: %__MODULE__{coef: 0, exp: 0}

  @doc """
  Integer (truncated) division. Like `Kernel.div/2` for integers — drops the
  fractional part, truncating toward zero. Result always has `exp: 0`.

      iex> FastDecimal.div_int(FastDecimal.new("10.5"), FastDecimal.new("3"))
      %FastDecimal{coef: 3, exp: 0}
  """
  @spec div_int(t(), t()) :: t()
  def div_int(_, %__MODULE__{coef: 0}), do: raise(ArithmeticError, "decimal division by zero")

  def div_int(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) do
    {ac1, ac2} =
      cond do
        e1 == e2 -> {c1, c2}
        e1 < e2 -> {c1, c2 * pow10(e2 - e1)}
        e1 > e2 -> {c1 * pow10(e1 - e2), c2}
      end

    %__MODULE__{coef: Kernel.div(ac1, ac2), exp: 0}
  end

  def div_int(a, b), do: div_special(a, b)

  @doc """
  Returns `{quotient, remainder}` such that `a == quotient * b + remainder`.
  Quotient is computed by `div_int/2`.

      iex> FastDecimal.div_rem(FastDecimal.new("10"), FastDecimal.new("3"))
      {%FastDecimal{coef: 3, exp: 0}, %FastDecimal{coef: 1, exp: 0}}
  """
  @spec div_rem(t(), t()) :: {t(), t()}
  def div_rem(_, %__MODULE__{coef: 0}), do: raise(ArithmeticError, "decimal division by zero")

  def div_rem(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) do
    # Direct computation: align coefs to a common exp, then use BEAM's
    # `div` and `rem` BIFs in one pass. Avoids the previous "call div_int,
    # then mult, then sub" three-step approach.
    target_exp = Kernel.min(e1, e2)

    {ac1, ac2} =
      cond do
        e1 == e2 -> {c1, c2}
        e1 < e2 -> {c1, c2 * pow10(e2 - e1)}
        true -> {c1 * pow10(e1 - e2), c2}
      end

    q = Kernel.div(ac1, ac2)
    r = Kernel.rem(ac1, ac2)
    {%__MODULE__{coef: q, exp: 0}, %__MODULE__{coef: r, exp: target_exp}}
  end

  @doc "Remainder of decimal division (same sign as the dividend)."
  @spec rem(t(), t()) :: t()
  def rem(a, b) do
    {_q, r} = div_rem(a, b)
    r
  end

  @doc """
  Square root with configurable precision (default 28 significant digits).

  Newton-Raphson on bigints; converges in ~log(digits) iterations because
  the initial guess uses the number's digit count.

      iex> FastDecimal.sqrt(FastDecimal.new("4"))
      %FastDecimal{coef: 2, exp: 0}

      iex> FastDecimal.sqrt(FastDecimal.new("2"), precision: 10)
      %FastDecimal{coef: 1414213562, exp: -9}
  """
  @spec sqrt(t(), keyword()) :: t()
  def sqrt(decimal, opts \\ [])

  def sqrt(%__MODULE__{coef: :nan}, _), do: @nan
  def sqrt(%__MODULE__{coef: :inf}, _), do: @inf
  def sqrt(%__MODULE__{coef: :neg_inf}, _), do: @nan
  def sqrt(%__MODULE__{coef: 0}, _), do: %__MODULE__{coef: 0, exp: 0}
  def sqrt(%__MODULE__{coef: c}, _) when is_integer(c) and c < 0, do: @nan

  def sqrt(%__MODULE__{coef: c, exp: e}, opts) when is_integer(c) and c > 0 do
    precision = Keyword.get(opts, :precision, 28)

    # Normalize so exponent is even: sqrt(c·10^e) = sqrt(c)·10^(e/2).
    {c, e} = if Kernel.rem(e, 2) == 0, do: {c, e}, else: {c * 10, e - 1}

    # We want the result coefficient to have exactly `precision` digits.
    # Scaling `c` by 10^(2·(precision-1)) puts the isqrt result in that range.
    shift = precision - 1
    scaled = c * pow10(2 * shift)
    root = isqrt(scaled)
    # Normalize to strip trailing zeros (so sqrt(4) reads "2", not "2.000...0").
    normalize(%__MODULE__{coef: root, exp: Kernel.div(e, 2) - shift})
  end

  # `sqrt/2` filters coef: 0 and coef: <0 above, so isqrt is only ever called
  # with `pos_integer()` (specifically `c * pow10(2 * shift) >= 1`). The
  # `isqrt(1)` base case handles the smallest possible input.
  defp isqrt(1), do: 1

  defp isqrt(n) when n > 1 do
    # Initial guess: 10^ceil(digits/2). Good enough that Newton-Raphson
    # converges in a handful of iterations for any input size.
    guess = pow10(Kernel.div(digits(n) + 1, 2))
    isqrt_iter(n, guess)
  end

  defp isqrt_iter(n, x) do
    x_new = Kernel.div(x + Kernel.div(n, x), 2)
    if x_new >= x, do: x, else: isqrt_iter(n, x_new)
  end

  @doc """
  Sum a list of FastDecimals. Equivalent to `Enum.reduce(list, new(0), &add/2)`
  but inlined and recursion-flat. The tight inner loop avoids `Enum`'s anonymous
  function call overhead.
  """
  @spec sum([t()]) :: t()
  # Allocation-free accumulator: walks the list carrying raw {coef, exp} —
  # only builds the final %FastDecimal{} struct at the end. For sum of N
  # values this is N-1 fewer struct allocations than the pairwise-add loop,
  # saving ~5 kB of garbage on a 100-element sum.
  #
  # Special values (NaN, Inf) trip the fast path's `is_integer` guard and
  # fall through to the pairwise slow path.
  def sum([]), do: %__MODULE__{coef: 0, exp: 0}

  def sum([%__MODULE__{coef: c, exp: e} | rest]) when is_integer(c),
    do: sum_fast(rest, c, e)

  def sum([first | rest]), do: sum_slow(rest, first)

  defp sum_fast([], acc, exp), do: %__MODULE__{coef: acc, exp: exp}

  defp sum_fast([%__MODULE__{coef: c, exp: e} | rest], acc, exp)
       when is_integer(c) and e == exp,
       do: sum_fast(rest, acc + c, exp)

  defp sum_fast([%__MODULE__{coef: c, exp: e} | rest], acc, exp)
       when is_integer(c) and exp < e,
       do: sum_fast(rest, acc + c * pow10(e - exp), exp)

  defp sum_fast([%__MODULE__{coef: c, exp: e} | rest], acc, exp)
       when is_integer(c),
       do: sum_fast(rest, acc * pow10(exp - e) + c, e)

  defp sum_fast(list, acc, exp),
    # First special value seen — switch to the pairwise add path which knows
    # how to propagate NaN/Inf correctly.
    do: sum_slow(list, %__MODULE__{coef: acc, exp: exp})

  defp sum_slow([], acc), do: acc
  defp sum_slow([h | t], acc), do: sum_slow(t, add(acc, h))

  @doc """
  Product of a list of FastDecimals.
  """
  @spec product([t()]) :: t()
  # Same trick as `sum/1`: accumulate raw coef * exp pairs, build struct at end.
  def product([]), do: %__MODULE__{coef: 1, exp: 0}

  def product([%__MODULE__{coef: c, exp: e} | rest]) when is_integer(c),
    do: product_fast(rest, c, e)

  def product([first | rest]), do: product_slow(rest, first)

  defp product_fast([], acc, exp), do: %__MODULE__{coef: acc, exp: exp}

  defp product_fast([%__MODULE__{coef: c, exp: e} | rest], acc, exp)
       when is_integer(c),
       do: product_fast(rest, acc * c, exp + e)

  defp product_fast(list, acc, exp),
    do: product_slow(list, %__MODULE__{coef: acc, exp: exp})

  defp product_slow([], acc), do: acc
  defp product_slow([h | t], acc), do: product_slow(t, mult(acc, h))

  @spec negate(t()) :: t()
  def negate(%__MODULE__{coef: c, exp: e}) when is_integer(c),
    do: %__MODULE__{coef: -c, exp: e}

  def negate(%__MODULE__{coef: :inf}), do: @neg_inf
  def negate(%__MODULE__{coef: :neg_inf}), do: @inf
  def negate(%__MODULE__{coef: :nan}), do: @nan

  @spec abs(t()) :: t()
  def abs(%__MODULE__{coef: c, exp: e}) when is_integer(c),
    do: %__MODULE__{coef: Kernel.abs(c), exp: e}

  def abs(%__MODULE__{coef: :neg_inf}), do: @inf
  def abs(%__MODULE__{coef: :inf}), do: @inf
  def abs(%__MODULE__{coef: :nan}), do: @nan

  @spec min(t(), t()) :: t()
  def min(a, b) do
    case compare(a, b) do
      :gt -> b
      _ -> a
    end
  end

  @spec max(t(), t()) :: t()
  def max(a, b) do
    case compare(a, b) do
      :lt -> b
      _ -> a
    end
  end

  # ---- Comparison & predicates --------------------------------------------

  # Fast path: both coefficients are integers (the 99% case).
  # Special values (NaN / ±Inf) fall through to compare_special/2.

  @spec compare(t(), t()) :: :lt | :eq | :gt | :nan
  def compare(%__MODULE__{coef: c1, exp: e}, %__MODULE__{coef: c2, exp: e})
      when is_integer(c1) and is_integer(c2) do
    cond do
      c1 < c2 -> :lt
      c1 > c2 -> :gt
      true -> :eq
    end
  end

  def compare(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) and e1 < e2 do
    aligned = c2 * pow10(e2 - e1)

    cond do
      c1 < aligned -> :lt
      c1 > aligned -> :gt
      true -> :eq
    end
  end

  def compare(%__MODULE__{coef: c1, exp: e1}, %__MODULE__{coef: c2, exp: e2})
      when is_integer(c1) and is_integer(c2) do
    aligned = c1 * pow10(e1 - e2)

    cond do
      aligned < c2 -> :lt
      aligned > c2 -> :gt
      true -> :eq
    end
  end

  def compare(a, b), do: compare_special(a, b)

  # Special-value comparison: any NaN ⇒ :nan; ±Inf ordered as expected.
  defp compare_special(%__MODULE__{coef: :nan}, _), do: :nan
  defp compare_special(_, %__MODULE__{coef: :nan}), do: :nan
  defp compare_special(%__MODULE__{coef: :inf}, %__MODULE__{coef: :inf}), do: :eq
  defp compare_special(%__MODULE__{coef: :neg_inf}, %__MODULE__{coef: :neg_inf}), do: :eq
  defp compare_special(%__MODULE__{coef: :inf}, _), do: :gt
  defp compare_special(_, %__MODULE__{coef: :inf}), do: :lt
  defp compare_special(%__MODULE__{coef: :neg_inf}, _), do: :lt
  defp compare_special(_, %__MODULE__{coef: :neg_inf}), do: :gt

  @doc """
  Returns true if both decimals compare as equal. NaN never compares equal to
  anything (matches IEEE 754 behavior for floating-point NaN).
  """
  @spec equal?(t(), t()) :: boolean()
  # Identical-struct short-circuit. Saves the compare/2 call when both args
  # have the same coef and exp (common for `equal?(a, a)` checks and for
  # comparing a stored value to a fresh literal that landed in the same
  # representation).
  def equal?(%__MODULE__{coef: c, exp: e}, %__MODULE__{coef: c, exp: e})
      when is_integer(c),
      do: true

  def equal?(a, b), do: compare(a, b) == :eq

  @spec lt?(t(), t()) :: boolean()
  def lt?(%__MODULE__{coef: c, exp: e}, %__MODULE__{coef: c, exp: e})
      when is_integer(c),
      do: false

  def lt?(a, b), do: compare(a, b) == :lt

  @spec gt?(t(), t()) :: boolean()
  def gt?(%__MODULE__{coef: c, exp: e}, %__MODULE__{coef: c, exp: e})
      when is_integer(c),
      do: false

  def gt?(a, b), do: compare(a, b) == :gt

  @spec zero?(t()) :: boolean()
  def zero?(%__MODULE__{coef: 0}), do: true
  def zero?(%__MODULE__{}), do: false

  @spec positive?(t()) :: boolean()
  def positive?(%__MODULE__{coef: c}) when is_integer(c), do: c > 0
  def positive?(%__MODULE__{coef: :inf}), do: true
  def positive?(%__MODULE__{}), do: false

  @spec negative?(t()) :: boolean()
  def negative?(%__MODULE__{coef: c}) when is_integer(c), do: c < 0
  def negative?(%__MODULE__{coef: :neg_inf}), do: true
  def negative?(%__MODULE__{}), do: false

  @doc "Returns true if the value is NaN (not a number)."
  @spec nan?(t()) :: boolean()
  def nan?(%__MODULE__{coef: :nan}), do: true
  def nan?(%__MODULE__{}), do: false

  @doc "Returns true if the value is +∞ or -∞."
  @spec inf?(t()) :: boolean()
  def inf?(%__MODULE__{coef: :inf}), do: true
  def inf?(%__MODULE__{coef: :neg_inf}), do: true
  def inf?(%__MODULE__{}), do: false

  @doc "Returns true if the value is a finite number (not NaN, not infinity)."
  @spec finite?(t()) :: boolean()
  def finite?(%__MODULE__{coef: c}) when is_integer(c), do: true
  def finite?(%__MODULE__{}), do: false

  # ---- Rounding / normalization -------------------------------------------

  @doc """
  Round to `places` decimal places using the given rounding mode.

  Default: 0 places, `:half_even` (banker's rounding).

  Supported modes: `:half_even`, `:half_up`, `:half_down`, `:down`, `:up`,
  `:floor`, `:ceiling`.

      iex> FastDecimal.round(FastDecimal.new("1.235"), 2)
      %FastDecimal{coef: 124, exp: -2}

      iex> FastDecimal.round(FastDecimal.new("1.236"), 2, :down)
      %FastDecimal{coef: 123, exp: -2}

      iex> FastDecimal.round(FastDecimal.new("123.456"), -1)
      %FastDecimal{coef: 12, exp: 1}
  """
  @spec round(t(), integer(), rounding_mode()) :: t()
  def round(decimal, places \\ 0, mode \\ :half_even)

  def round(%__MODULE__{coef: :nan} = nan, _places, _mode), do: nan
  def round(%__MODULE__{coef: :inf} = inf, _places, _mode), do: inf
  def round(%__MODULE__{coef: :neg_inf} = neg_inf, _places, _mode), do: neg_inf

  def round(%__MODULE__{coef: c, exp: e} = d, places, _mode)
      when is_integer(c) and e >= -places do
    # Already at or above target precision; nothing to drop.
    d
  end

  def round(%__MODULE__{coef: c, exp: e}, places, mode) when is_integer(c) do
    # We need to drop `-e - places` digits from the coefficient.
    excess = -e - places
    # Leave one scratch digit for round_div to handle.
    pre_div = pow10(excess - 1)
    pre_quot = Kernel.div(c, pre_div)
    pre_rem = Kernel.rem(c, pre_div)
    rounded = round_div(pre_quot, pre_rem != 0, mode)
    %__MODULE__{coef: rounded, exp: -places}
  end

  # ---- Conversion ---------------------------------------------------------

  @doc """
  Soft parse: returns `{:ok, t()}` or `:error` without raising. Accepts the
  same inputs as `new/1` plus existing `FastDecimal` and `Decimal` structs.

  This is what Ecto's `Ecto.Type` machinery calls — exposing it directly
  makes user code that needs "try to coerce, otherwise complain" pleasant.
  """
  @spec cast(t() | integer() | binary() | Decimal.t() | float() | nil) ::
          {:ok, t()} | :error
  def cast(%__MODULE__{} = d), do: {:ok, d}
  def cast(nil), do: :error
  def cast(int) when is_integer(int), do: {:ok, %__MODULE__{coef: int, exp: 0}}
  def cast(str) when is_binary(str), do: parse(str)
  def cast(float) when is_float(float), do: parse(Float.to_string(float))

  def cast(%Decimal{sign: 1, coef: c, exp: e}) when is_integer(c),
    do: {:ok, %__MODULE__{coef: c, exp: e}}

  def cast(%Decimal{sign: -1, coef: c, exp: e}) when is_integer(c),
    do: {:ok, %__MODULE__{coef: -c, exp: e}}

  def cast(%Decimal{coef: :NaN}), do: {:ok, @nan}
  def cast(%Decimal{coef: :inf, sign: 1}), do: {:ok, @inf}
  def cast(%Decimal{coef: :inf, sign: -1}), do: {:ok, @neg_inf}
  def cast(_), do: :error

  @doc """
  Strip trailing zeros from the coefficient, raising the exponent.
  `~d"1.10"` (coef=110, exp=-2) becomes `~d"1.1"` (coef=11, exp=-1).
  """
  @spec normalize(t()) :: t()
  def normalize(%__MODULE__{coef: c} = d) when not is_integer(c), do: d
  def normalize(%__MODULE__{coef: 0}), do: %__MODULE__{coef: 0, exp: 0}

  def normalize(%__MODULE__{coef: c, exp: e}) do
    {c, e} = strip_trailing_zeros(c, e)
    %__MODULE__{coef: c, exp: e}
  end

  defp strip_trailing_zeros(c, e) do
    case Kernel.rem(c, 10) do
      0 -> strip_trailing_zeros(Kernel.div(c, 10), e + 1)
      _ -> {c, e}
    end
  end

  @typedoc "Output format for `to_string/2`. `:normal` is the default."
  @type to_string_format :: :normal | :scientific | :raw | :xsd

  @doc """
  Format a decimal as a string. `format` defaults to `:normal`.

    * `:normal` — `"1234.5678"`, `"0.001"`, `"123"` (decimal-point form when
      there are fractional digits, plain integer otherwise)
    * `:scientific` — `"1.2345678E+3"` (one digit before decimal, signed `E`
      exponent). Matches Decimal's `:scientific` format.
    * `:raw` — `"1234E-5"` (raw coefficient + `E` + raw exponent). Useful for
      debugging the internal representation.
    * `:xsd` — XML Schema canonical decimal form. Same as `:normal` for our
      representation since we don't use scientific in XSD.

  Special values (`NaN`, `Infinity`, `-Infinity`) print the same in every format.
  """
  @spec to_string(t(), to_string_format()) :: String.t()
  def to_string(decimal, format \\ :normal)

  def to_string(%__MODULE__{coef: :nan}, _), do: "NaN"
  def to_string(%__MODULE__{coef: :inf}, _), do: "Infinity"
  def to_string(%__MODULE__{coef: :neg_inf}, _), do: "-Infinity"

  def to_string(%__MODULE__{coef: 0, exp: e}, :normal) when e >= 0, do: "0"

  def to_string(%__MODULE__{coef: 0, exp: e}, :normal) when e < 0,
    do: "0." <> safe_zeros(-e)

  def to_string(%__MODULE__{coef: c, exp: 0}, :normal), do: Integer.to_string(c)

  def to_string(%__MODULE__{coef: c, exp: e}, :normal) when e > 0 do
    # SECURITY: refuse to materialize >@max_to_string_bytes bytes (CVE-2026-32686
    # class). Caller can use :scientific or :raw format if they need to see the
    # representation of a very-large-exp value.
    digits_count = digits(Kernel.abs(c))

    if digits_count + e > @max_to_string_bytes do
      raise_to_string_too_big(digits_count + e)
    end

    IO.iodata_to_binary([Integer.to_string(c), :binary.copy("0", e)])
  end

  # Note: in this code path we use `Integer.to_string/1` rather than
  # `:erlang.integer_to_binary/1`. Standalone the BIF is ~28% faster, but
  # inside this function-call shape `Integer.to_string` measured 7-10% faster
  # — looks like BEAM's JIT does something nicer for the Elixir wrapper here
  # (possibly inlining the call site). Bench/disasm and you'll see.
  #
  # Earlier I also tried bit-syntax `<<sign::binary, ...>>` to avoid the
  # iolist cons cells; measured ~20% SLOWER. `iodata_to_binary` is a BIF
  # that pre-computes total size and allocates once. Iolist stays.
  def to_string(%__MODULE__{coef: c, exp: e}, :normal) when e < 0 do
    {sign, abs_c} = if c < 0, do: {"-", -c}, else: {"", c}
    s = Integer.to_string(abs_c)
    digits = byte_size(s)
    shift = -e

    cond do
      digits > shift ->
        split_at = digits - shift
        # `binary_part` is a BIF that returns a sub-binary reference without
        # going through the bit-syntax matcher. Measured ~5% faster than the
        # `<<int::binary-size(N), frac::binary>>` pattern match here.
        IO.iodata_to_binary([
          sign,
          binary_part(s, 0, split_at),
          ?.,
          binary_part(s, split_at, digits - split_at)
        ])

      true ->
        # SECURITY: cap leading-zero pad. See `safe_zeros/1`.
        IO.iodata_to_binary([sign, "0.", safe_zeros(shift - digits), s])
    end
  end

  # ---- :scientific format -------------------------------------------------

  def to_string(%__MODULE__{coef: 0}, :scientific), do: "0E+0"

  # IEEE 754-2008 "to-scientific-string" — the compact form `decimal` also
  # emits. Three branches:
  #   1. exp == 0          → just the digits
  #   2. exp<0, adj>=-6    → normal "decimal point" form (no E notation)
  #   3. otherwise         → "d.dddE+NN" scientific form
  # The threshold (adj >= -6) is from IEEE 754-2008 §5.12.
  def to_string(%__MODULE__{coef: c, exp: e}, :scientific) do
    abs_c = Kernel.abs(c)
    s = :erlang.integer_to_binary(abs_c)
    digits = byte_size(s)
    adj_exp = e + digits - 1

    iodata =
      cond do
        e == 0 ->
          s

        e < 0 and adj_exp >= -6 ->
          # diff = how many "0."-padding zeros to emit (negative ⇒ skip)
          diff = -digits + -e + 1

          if diff > 0 do
            ["0.", :binary.copy("0", diff - 1), s]
          else
            split = digits + e
            [binary_part(s, 0, split), ?., binary_part(s, split, digits - split)]
          end

        true ->
          mantissa =
            if digits == 1 do
              s
            else
              [binary_part(s, 0, 1), ?., binary_part(s, 1, digits - 1)]
            end

          exp_sign = if adj_exp >= 0, do: ?+, else: []
          [mantissa, ?E, exp_sign, :erlang.integer_to_binary(adj_exp)]
      end

    iodata = if c < 0, do: [?-, iodata], else: iodata
    IO.iodata_to_binary(iodata)
  end

  # ---- :raw format (just the internal coef + exp, no formatting) ----------

  def to_string(%__MODULE__{coef: c, exp: 0}, :raw), do: Integer.to_string(c)

  def to_string(%__MODULE__{coef: c, exp: e}, :raw) do
    exp_sign = if e >= 0, do: ?+, else: ?-
    IO.iodata_to_binary([Integer.to_string(c), ?E, exp_sign, Integer.to_string(Kernel.abs(e))])
  end

  # ---- :xsd format (XML Schema canonical decimal — same as :normal here) ---

  def to_string(d, :xsd), do: to_string(d, :normal)

  @spec to_integer(t()) :: integer()
  # Zero short-circuit: 0×10^e is 0 for any e. Skips pow10 allocation and
  # avoids tripping the pow10 cap on `%FastDecimal{coef: 0, exp: -1_000_000}`.
  def to_integer(%__MODULE__{coef: 0}), do: 0
  def to_integer(%__MODULE__{coef: c, exp: 0}), do: c
  def to_integer(%__MODULE__{coef: c, exp: e}) when e > 0, do: c * pow10(e)

  def to_integer(%__MODULE__{coef: c, exp: e}) when e < 0 do
    case Kernel.rem(c, pow10(-e)) do
      0 -> Kernel.div(c, pow10(-e))
      _ -> raise ArgumentError, "FastDecimal is not an integer (has fractional part)"
    end
  end

  @spec to_float(t()) :: float()
  def to_float(%__MODULE__{coef: 0}), do: 0.0
  def to_float(%__MODULE__{coef: c, exp: 0}), do: c * 1.0
  def to_float(%__MODULE__{coef: c, exp: e}) when e > 0, do: c * pow10(e) * 1.0
  def to_float(%__MODULE__{coef: c, exp: e}) when e < 0, do: c / pow10(-e)

  # ---- Internal: round one scratch digit -----------------------------------

  defp round_div(quot, has_tail, mode) do
    scratch = Kernel.rem(quot, 10)
    abs_scratch = Kernel.abs(scratch)
    base = Kernel.div(quot, 10)

    bump =
      case mode do
        :down ->
          0

        :up ->
          if abs_scratch > 0 or has_tail, do: 1, else: 0

        :floor ->
          if (abs_scratch > 0 or has_tail) and quot < 0, do: 1, else: 0

        :ceiling ->
          if (abs_scratch > 0 or has_tail) and quot > 0, do: 1, else: 0

        :half_up ->
          cond do
            abs_scratch > 5 -> 1
            abs_scratch < 5 -> 0
            true -> 1
          end

        :half_down ->
          cond do
            abs_scratch > 5 -> 1
            abs_scratch < 5 -> 0
            has_tail -> 1
            true -> 0
          end

        :half_even ->
          cond do
            abs_scratch > 5 -> 1
            abs_scratch < 5 -> 0
            has_tail -> 1
            true -> if Kernel.rem(Kernel.abs(base), 2) == 1, do: 1, else: 0
          end
      end

    cond do
      bump == 0 -> base
      quot >= 0 -> base + 1
      true -> base - 1
    end
  end

  # SECURITY: bounded zero-padding for to_string. CVE-2026-32686 class
  # vector — a value like `1e1000000000` parses to coef=1, exp=10^9, and
  # to_string normal-form output would `:binary.copy("0", 10^9)`, allocating
  # 1 GB. Cap at @max_to_string_bytes.
  defp safe_zeros(n) when n > @max_to_string_bytes, do: raise_to_string_too_big(n)
  defp safe_zeros(n), do: :binary.copy("0", n)

  defp raise_to_string_too_big(size) do
    raise ArgumentError,
          "to_string(_, :normal) would emit a #{size}-byte string " <>
            "(~#{Kernel.div(size, 1_048_576)} MB). Use `:scientific` or `:raw` format " <>
            "for very-large-exp values, or sanitize input upstream — this is the " <>
            "CVE-2026-32686-class exponent-amplification DoS vector."
  end

  defp digits(0), do: 1
  defp digits(n) when n > 0, do: digits(n, 0)
  defp digits(n, acc) when n < 10, do: acc + 1
  defp digits(n, acc) when n < 100, do: acc + 2
  defp digits(n, acc) when n < 1_000, do: acc + 3
  defp digits(n, acc) when n < 10_000, do: acc + 4
  defp digits(n, acc) when n < 100_000, do: acc + 5
  defp digits(n, acc) when n < 1_000_000, do: acc + 6
  defp digits(n, acc) when n < 10_000_000, do: acc + 7
  defp digits(n, acc) when n < 100_000_000, do: acc + 8
  defp digits(n, acc) when n < 1_000_000_000, do: acc + 9
  defp digits(n, acc), do: digits(Kernel.div(n, 1_000_000_000), acc + 9)

  # Lookup table for pow10(N). The size matters: div/3 at precision 28 calls
  # `pow10(shift)` with shift typically 28-32 (precision + 1 + digits(c2) -
  # digits(c1)). sqrt/2 at precision 50 calls pow10(98). We extend the table
  # so the common-case ops never fall through to the recursive case.
  defp pow10(0), do: 1
  defp pow10(1), do: 10
  defp pow10(2), do: 100
  defp pow10(3), do: 1_000
  defp pow10(4), do: 10_000
  defp pow10(5), do: 100_000
  defp pow10(6), do: 1_000_000
  defp pow10(7), do: 10_000_000
  defp pow10(8), do: 100_000_000
  defp pow10(9), do: 1_000_000_000
  defp pow10(10), do: 10_000_000_000
  defp pow10(11), do: 100_000_000_000
  defp pow10(12), do: 1_000_000_000_000
  defp pow10(13), do: 10_000_000_000_000
  defp pow10(14), do: 100_000_000_000_000
  defp pow10(15), do: 1_000_000_000_000_000
  defp pow10(16), do: 10_000_000_000_000_000
  defp pow10(17), do: 100_000_000_000_000_000
  defp pow10(18), do: 1_000_000_000_000_000_000
  defp pow10(19), do: 10_000_000_000_000_000_000
  defp pow10(20), do: 100_000_000_000_000_000_000
  defp pow10(21), do: 1_000_000_000_000_000_000_000
  defp pow10(22), do: 10_000_000_000_000_000_000_000
  defp pow10(23), do: 100_000_000_000_000_000_000_000
  defp pow10(24), do: 1_000_000_000_000_000_000_000_000
  defp pow10(25), do: 10_000_000_000_000_000_000_000_000
  defp pow10(26), do: 100_000_000_000_000_000_000_000_000
  defp pow10(27), do: 1_000_000_000_000_000_000_000_000_000
  defp pow10(28), do: 10_000_000_000_000_000_000_000_000_000
  defp pow10(29), do: 100_000_000_000_000_000_000_000_000_000
  defp pow10(30), do: 1_000_000_000_000_000_000_000_000_000_000
  defp pow10(31), do: 10_000_000_000_000_000_000_000_000_000_000
  defp pow10(32), do: 100_000_000_000_000_000_000_000_000_000_000
  defp pow10(33), do: 1_000_000_000_000_000_000_000_000_000_000_000
  defp pow10(34), do: 10_000_000_000_000_000_000_000_000_000_000_000
  defp pow10(35), do: 100_000_000_000_000_000_000_000_000_000_000_000
  defp pow10(36), do: 1_000_000_000_000_000_000_000_000_000_000_000_000
  defp pow10(37), do: 10_000_000_000_000_000_000_000_000_000_000_000_000
  defp pow10(38), do: 100_000_000_000_000_000_000_000_000_000_000_000_000

  # SECURITY: refuse pow10 with absurdly large `n`. Catches CVE-2026-32686-
  # class inputs (`1e1000000` etc) at the chokepoint they ultimately route
  # through — every operation that "materializes" a large-exp value calls
  # pow10(huge_n). Single guard, single point of defense.
  defp pow10(n) when n > @max_safe_pow10 do
    raise ArgumentError,
          "pow10(#{n}) would materialize a #{n}-digit bignum (~#{Kernel.div(n, 1024)} KB). " <>
            "This is far beyond any practical use and is likely a denial-of-service " <>
            "attempt via exponent amplification (CVE-2026-32686 in `decimal`). " <>
            "FastDecimal caps pow10 at #{@max_safe_pow10}. Sanitize inputs before " <>
            "passing them to arithmetic / to_string."
  end

  # Binary exponentiation for n > 38. O(log n) multiplications instead of O(n).
  # Hit by sqrt at precision > ~20 (pow10(2 × shift) with shift = precision - 1).
  defp pow10(n) when n > 38 do
    half = pow10(Kernel.div(n, 2))

    if Kernel.rem(n, 2) == 0 do
      half * half
    else
      half * half * 10
    end
  end
end

defimpl Inspect, for: FastDecimal do
  def inspect(decimal, _opts) do
    "~d\"" <> FastDecimal.to_string(decimal) <> "\""
  end
end

defimpl String.Chars, for: FastDecimal do
  def to_string(decimal), do: FastDecimal.to_string(decimal)
end