defmodule Pyex.Builtins do
  @compile {:no_warn_undefined, [Explorer.Series, Explorer.DataFrame]}
  @moduledoc """
  Built-in Python functions available without import.

  Provides `len()`, `range()`, `print()`, `str()`, `int()`,
  `float()`, `type()`, `abs()`, `min()`, `max()`, `sum()`,
  `sorted()`, `reversed()`, `enumerate()`, `zip()`, `map()`,
  `filter()`, `list()`, `dict()`, `bool()`, `isinstance()`,
  and `append()` (as a method-like helper).
  """

  alias Pyex.{Ctx, Env, Interpreter, PyDict}
  alias Pyex.Interpreter.FstringFormat

  @doc """
  Returns an environment pre-populated with all builtins.

  By default, the environment is rebuilt on each call (~14μs).
  Call `cache!/0` at application startup to store the result
  in `:persistent_term` for zero-cost subsequent reads.
  """
  @spec env() :: Env.t()
  def env do
    case :persistent_term.get({__MODULE__, :env}, :miss) do
      :miss -> build_env()
      cached -> cached
    end
  end

  @doc """
  Returns a top-level runtime environment for executing user code.

  Includes the builtins plus `__name__ == "__main__"` and, when present,
  `__file__` from the provided context.
  """
  @spec runtime_env(Ctx.t()) :: Env.t()
  def runtime_env(ctx) do
    env = Env.put(env(), "__name__", "__main__")

    if is_binary(ctx.file) do
      Env.put(env, "__file__", ctx.file)
    else
      env
    end
  end

  @doc """
  Pre-computes the builtins environment and stores it in
  `:persistent_term` for zero-cost reads. Triggers a single
  global GC across the VM -- call once at application startup.

      # In your Application.start/2:
      Pyex.Builtins.cache!()
  """
  @spec cache!() :: :ok
  def cache! do
    :persistent_term.put({__MODULE__, :env}, build_env())
    :ok
  end

  @spec build_env() :: Env.t()
  defp build_env do
    env =
      Enum.reduce(all(), Env.new(), fn
        {name, {:kw, fun}}, env ->
          Env.put(env, name, {:builtin_kw, fun})

        {name, fun}, env ->
          Env.put(env, name, {:builtin, fun})
      end)

    env =
      Enum.reduce(type_constructors(), env, fn {name, fun}, env ->
        Env.put(env, name, {:builtin_type, name, fun})
      end)

    env = Enum.reduce(exception_class_names(), env, &Env.put(&2, &1, {:exception_class, &1}))

    env = Env.put(env, "Ellipsis", :ellipsis)

    # The NotImplemented singleton. Guests `return NotImplemented` from a
    # binary dunder to signal "I don't handle this operand"; the binop
    # dispatcher then tries the reflected dunder. (Bound here, not in
    # `names/0`, since CPython lists it as a value, not in dir(builtins).)
    env = Env.put(env, "NotImplemented", :not_implemented)

    # `type` is simultaneously a class and a callable. We bind it to
    # `type_class()` so `type(x) is type` holds for class arguments, and
    # the class-call dispatch in `invocation.ex` intercepts it to run
    # `builtin_type/1`.
    Env.put(env, "type", type_class())
  end

  @doc """
  Returns the full list of Python builtin exception class names.

  Each becomes a first-class `{:exception_class, name}` runtime value in
  the environment, so it can appear in expressions like `isinstance(e,
  ValueError)`, `except (A, B) as e`, and `[ValueError, TypeError]`.
  Sourced from `Pyex.ExceptionsHierarchy.all_names/0`.
  """
  @spec exception_class_names() :: [String.t()]
  def exception_class_names, do: Pyex.ExceptionsHierarchy.all_names()

  @doc """
  Returns the names of every builtin bound in the runtime environment:
  functions, type constructors, exception classes, and the `type` /
  `Ellipsis` singletons. This is the introspection seam used by
  surface-parity checks against CPython's `dir(builtins)`.
  """
  @spec names() :: [String.t()]
  def names do
    functions = Enum.map(all(), fn {name, _} -> name end)
    types = Enum.map(type_constructors(), fn {name, _} -> name end)

    (functions ++ types ++ exception_class_names() ++ ["type", "Ellipsis", "NotImplemented"])
    |> Enum.uniq()
    |> Enum.sort()
  end

  @doc """
  True for builtins that must receive a generator iterator *as itself* —
  not its drained contents: anything that reflects on identity or type,
  or treats the iterator as opaque (`iter`, `next`, `isinstance`, `id`,
  `repr`, `len`), plus `itertools.islice`, which needs lazy access to an
  iterator arg (draining an infinite generator first would loop forever).

  (`type` is in the same conceptual group but isn't dispatched through the
  builtin call path — it's the `type` class — so it needs no entry here.)

  The captures must match the local-capture form bound in the env, so the
  list is built here. It's seven entries; rebuilding per call is free
  relative to the generator drain it gates.
  """
  @spec no_drain_builtin?(function()) :: boolean()
  def no_drain_builtin?(fun) do
    fun in [
      &builtin_iter/1,
      &builtin_next/1,
      &builtin_isinstance/1,
      &builtin_id/1,
      &builtin_repr/1,
      &builtin_len/1,
      Pyex.Stdlib.Itertools.islice_capture()
    ]
  end

  @doc """
  True for builtins that materialize a new sequence from an iterable and
  must preserve their elements' *identity* (CPython shallow-copy
  semantics): the result holds the same inner objects as the source.
  These receive shallow-deref'd args so nested heap references survive —
  deep-deref would flatten them and force a fresh allocation, silently
  breaking `list(x)[0] is x[0]`.

  The captures must match the local-capture form bound in the env, so the
  list is built here. It's four entries; rebuilding per call is free
  relative to the deref it gates.
  """
  @spec shallow_arg_builtin?(function()) :: boolean()
  def shallow_arg_builtin?(fun) do
    fun in [&builtin_list/1, &builtin_tuple/1, &builtin_reversed/1, &builtin_sorted/2]
  end

  @doc """
  True for builtins that take integer arguments and therefore accept any
  object implementing `__index__` (PEP 357): `range`, `hex`, `oct`, `bin`,
  `chr`. The interpreter coerces their instance/bool args through `__index__`
  before dispatch, matching CPython.
  """
  @spec index_coercing_builtin?(function()) :: boolean()
  def index_coercing_builtin?(fun) do
    fun in [&builtin_range/1, &builtin_hex/1, &builtin_oct/1, &builtin_bin/1, &builtin_chr/1]
  end

  @spec all() :: [{String.t(), ([Interpreter.pyvalue()] -> Interpreter.pyvalue())}]
  defp all do
    [
      {"len", &builtin_len/1},
      {"range", &builtin_range/1},
      {"slice", &builtin_slice/1},
      {"print", {:kw, &builtin_print/2}},
      # "type" is bound to `type_class()` in `build_env/0` so that
      # `type(x) is type` holds via structural class identity. The
      # class-call dispatch in `invocation.ex` routes `type(x)` to
      # `builtin_type/1`.
      {"abs", &builtin_abs/1},
      {"min", {:kw, &builtin_min/2}},
      {"max", {:kw, &builtin_max/2}},
      {"sum", &builtin_sum/1},
      {"sorted", {:kw, &builtin_sorted/2}},
      {"reversed", &builtin_reversed/1},
      {"enumerate", {:kw, &builtin_enumerate/2}},
      {"zip", {:kw, &builtin_zip/2}},
      {"isinstance", &builtin_isinstance/1},
      {"issubclass", &builtin_issubclass/1},
      {"round", &builtin_round/1},
      {"input", &builtin_input/1},
      {"open", {:kw, &builtin_open/2}},
      {"any", &builtin_any/1},
      {"all", &builtin_all/1},
      {"map", &builtin_map/1},
      {"filter", &builtin_filter/1},
      {"chr", &builtin_chr/1},
      {"ord", &builtin_ord/1},
      {"hex", &builtin_hex/1},
      {"oct", &builtin_oct/1},
      {"bin", &builtin_bin/1},
      {"pow", &builtin_pow/1},
      {"divmod", &builtin_divmod/1},
      {"repr", &builtin_repr/1},
      {"format", &builtin_format/1},
      {"callable", &builtin_callable/1},
      {"frozenset", &builtin_frozenset/1},
      {"hasattr", &builtin_hasattr/1},
      {"getattr", &builtin_getattr/1},
      {"setattr", &builtin_setattr/1},
      {"delattr", &builtin_delattr/1},
      {"ascii", &builtin_ascii/1},
      {"super", &builtin_super/1},
      {"iter", &builtin_iter/1},
      {"next", &builtin_next/1},
      {"exec", &builtin_exec/1},
      {"eval", &builtin_eval/1},
      {"compile", &builtin_compile/1},
      {"complex", &builtin_complex/1},
      {"bytes", &builtin_bytes/1},
      {"bytearray", &builtin_bytearray/1},
      {"memoryview", &builtin_memoryview/1},
      {"dir", &builtin_dir/1},
      {"vars", &builtin_vars/1},
      {"globals", &builtin_globals/1},
      {"locals", &builtin_locals/1},
      {"id", &builtin_id/1},
      {"hash", &builtin_hash/1},
      {"property", &builtin_property/1},
      {"staticmethod", &builtin_staticmethod/1},
      {"classmethod", &builtin_classmethod/1},
      {"__import__", &builtin_import/1}
    ]
  end

  @spec type_constructors() :: [{String.t(), ([Interpreter.pyvalue()] -> Interpreter.pyvalue())}]
  defp type_constructors do
    [
      {"object", &builtin_object/1},
      {"str", &builtin_str/1},
      {"int", &builtin_int/1},
      {"float", &builtin_float/1},
      {"bool", &builtin_bool/1},
      {"list", &builtin_list/1},
      {"dict", &builtin_dict/1},
      {"tuple", &builtin_tuple/1},
      {"set", &builtin_set/1}
    ]
  end

  @spec builtin_len([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_len([{:py_list, _reversed, len}]), do: len
  defp builtin_len([val]) when is_list(val), do: length(val)

  defp builtin_len([val]) when is_binary(val) do
    val |> String.codepoints() |> length()
  end

  defp builtin_len([{:py_dict, _, _} = dict]), do: PyDict.size(visible_dict(dict))
  defp builtin_len([val]) when is_map(val), do: map_size(visible_dict(val))
  defp builtin_len([{:tuple, items}]), do: length(items)
  defp builtin_len([{:struct_time, fields}]), do: length(fields)
  defp builtin_len([{:memoryview, b}]), do: byte_size(b)
  defp builtin_len([{:set, s}]), do: MapSet.size(s)
  defp builtin_len([{:frozenset, s}]), do: MapSet.size(s)
  defp builtin_len([{:bytes, b}]), do: byte_size(b)
  defp builtin_len([{:bytearray, b}]), do: byte_size(b)
  defp builtin_len([{:deque, _, _, len, _}]), do: len
  defp builtin_len([{:generator, items}]), do: length(items)
  defp builtin_len([{:range, _, _, _} = r]), do: range_length(r)
  defp builtin_len([{:pandas_series, s}]), do: Explorer.Series.count(s)
  defp builtin_len([{:pandas_dataframe, df}]), do: elem(Explorer.DataFrame.shape(df), 0)

  defp builtin_len([{:instance, _, _} = inst]),
    do: {:dunder_call, inst, "__len__", []}

  defp builtin_len([val]),
    do: {:exception, "TypeError: object of type '#{pytype(val)}' has no len()"}

  @spec builtin_range([Interpreter.pyvalue()]) ::
          {:range, integer(), integer(), integer()} | {:exception, String.t()}
  defp builtin_range([stop]) when is_integer(stop), do: {:range, 0, stop, 1}

  defp builtin_range([start, stop]) when is_integer(start) and is_integer(stop),
    do: {:range, start, stop, 1}

  defp builtin_range([start, stop, step])
       when is_integer(start) and is_integer(stop) and is_integer(step) and step != 0,
       do: {:range, start, stop, step}

  defp builtin_range([_, _, 0]), do: {:exception, "ValueError: range() arg 3 must not be zero"}

  # slice(stop) / slice(start, stop) / slice(start, stop, step) — a slice
  # object usable as a subscript key, with omitted bounds as `nil` (None).
  @spec builtin_slice([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_slice([stop]), do: {:slice_obj, nil, stop, nil}
  defp builtin_slice([start, stop]), do: {:slice_obj, start, stop, nil}
  defp builtin_slice([start, stop, step]), do: {:slice_obj, start, stop, step}

  defp builtin_slice(_),
    do: {:exception, "TypeError: slice expected 1 to 3 arguments"}

  @max_range_len 10_000_000

  @doc false
  @spec range_to_list({:range, integer(), integer(), integer()}) ::
          [integer()] | {:exception, String.t()}
  def range_to_list({:range, _, _, _} = r) do
    len = range_length(r)

    if len > @max_range_len do
      {:exception,
       "MemoryError: range of #{len} elements exceeds maximum allowed size (#{@max_range_len})"}
    else
      do_range_to_list(r)
    end
  end

  @spec do_range_to_list({:range, integer(), integer(), integer()}) :: [integer()]
  defp do_range_to_list({:range, start, stop, step}) when step > 0 do
    if start >= stop, do: [], else: Enum.to_list(start..(stop - 1)//step)
  end

  defp do_range_to_list({:range, start, stop, step}) when step < 0 do
    if start <= stop, do: [], else: Enum.to_list(start..(stop + 1)//step)
  end

  @doc false
  @spec range_length({:range, integer(), integer(), integer()}) :: non_neg_integer()
  def range_length({:range, start, stop, step}) when step > 0 do
    if start >= stop, do: 0, else: div(stop - start + step - 1, step)
  end

  def range_length({:range, start, stop, step}) when step < 0 do
    if start <= stop, do: 0, else: div(start - stop - step - 1, -step)
  end

  @doc false
  @spec visible_dict(map() | PyDict.t()) :: map() | PyDict.t()
  def visible_dict({:py_dict, _, _} = dict) do
    dict
    |> PyDict.delete("__defaultdict_factory__")
    |> PyDict.delete("__counter__")
    |> PyDict.delete("most_common")
    |> PyDict.delete("elements")
  end

  def visible_dict(map) when is_map(map) do
    map
    |> Map.delete("__defaultdict_factory__")
    |> Map.delete("__counter__")
    |> Map.delete("most_common")
    |> Map.delete("elements")
  end

  @doc """
  Materializes an iterable value into a list of items suitable
  for creating an iterator. Returns `{:ok, items}`, `{:pass, iter}`
  if the value is already an iterator, or `:error` if not iterable.
  """
  @spec materialize_iterable(Interpreter.pyvalue()) ::
          {:ok, [Interpreter.pyvalue()]} | {:pass, Interpreter.pyvalue()} | :error
  def materialize_iterable(val) do
    case val do
      {:py_list, reversed, _} ->
        {:ok, Enum.reverse(reversed)}

      list when is_list(list) ->
        {:ok, list}

      str when is_binary(str) ->
        {:ok, String.codepoints(str)}

      {:tuple, elems} ->
        {:ok, elems}

      {:set, s} ->
        {:ok, MapSet.to_list(s)}

      {:generator, elems} ->
        {:ok, elems}

      {:range, _, _, _} = r ->
        case range_to_list(r) do
          {:exception, _} = err -> err
          list -> {:ok, list}
        end

      {:py_dict, _, _} = dict ->
        {:ok, PyDict.keys(visible_dict(dict))}

      map when is_map(map) ->
        {:ok, map |> visible_dict() |> Map.keys()}

      {:iterator, _} = it ->
        {:pass, it}

      _ ->
        :error
    end
  end

  @spec builtin_print(
          [Interpreter.pyvalue()],
          %{optional(String.t()) => Interpreter.pyvalue()}
        ) ::
          {:print_call, [Interpreter.pyvalue()], String.t(), String.t()}
  defp builtin_print(args, kwargs) do
    sep = Map.get(kwargs, "sep", " ")
    end_str = Map.get(kwargs, "end", "\n")
    sep = if is_binary(sep), do: sep, else: " "
    end_str = if is_binary(end_str), do: end_str, else: "\n"
    {:print_call, args, sep, end_str}
  end

  @spec builtin_str([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_str([]), do: ""

  defp builtin_str([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__str__", []}

  # str(bytes, encoding): decode. The pyex bytes payload is the raw binary,
  # which for utf-8/ascii is already the decoded string.
  defp builtin_str([{:bytes, b}, encoding]) when is_binary(encoding) do
    case String.downcase(encoding) do
      e when e in ["utf-8", "utf8", "ascii", "latin-1", "latin1", "iso-8859-1"] -> b
      _ -> {:exception, "LookupError: unknown encoding: #{encoding}"}
    end
  end

  defp builtin_str([val]), do: py_repr(val)

  @doc false
  @spec builtin_int([Interpreter.pyvalue()], map()) :: integer() | {:exception, String.t()}
  def builtin_int(args, kwargs) do
    case Map.fetch(kwargs, "base") do
      {:ok, base} -> builtin_int(args ++ [base])
      :error -> builtin_int(args)
    end
  end

  @spec builtin_int([Interpreter.pyvalue()]) :: integer() | {:exception, String.t()}
  defp builtin_int([]), do: 0
  defp builtin_int([val]) when is_integer(val), do: val
  defp builtin_int([val]) when is_float(val), do: trunc(val)

  defp builtin_int([val]) when is_binary(val) do
    # Python accepts underscores as digit separators: int("1_000_000") == 1000000.
    trimmed = String.trim(val)

    case validate_and_strip_underscores(trimmed) do
      {:ok, cleaned} ->
        case Integer.parse(cleaned) do
          {n, ""} -> n
          _ -> {:exception, "ValueError: invalid literal for int() with base 10: '#{val}'"}
        end

      :error ->
        {:exception, "ValueError: invalid literal for int() with base 10: '#{val}'"}
    end
  end

  defp builtin_int([val, base]) when is_binary(val) and is_integer(base) do
    int_with_base(String.trim(val), base)
  end

  defp builtin_int([true]), do: 1
  defp builtin_int([false]), do: 0

  defp builtin_int([{:pyex_decimal, %Decimal{coef: :NaN}}]),
    do: {:exception, "ValueError: cannot convert NaN to integer"}

  defp builtin_int([{:pyex_decimal, %Decimal{coef: :inf}}]),
    do: {:exception, "OverflowError: cannot convert Infinity to integer"}

  defp builtin_int([{:pyex_decimal, d}]) do
    # CPython int(Decimal) truncates toward zero.
    Decimal.round(d, 0, :down) |> Decimal.to_integer()
  end

  defp builtin_int([{:fraction, _, _} = f]), do: Pyex.Fraction.to_integer(f)

  # int(bytes)/int(bytearray): CPython parses the bytes as an ASCII numeral
  # (ValueError on non-numeric content), it does not reject them outright.
  defp builtin_int([{tag, bin}]) when tag in [:bytes, :bytearray],
    do: builtin_int([bytes_to_latin1(bin)])

  defp builtin_int([{tag, bin}, base]) when tag in [:bytes, :bytearray] and is_integer(base),
    do: builtin_int([bytes_to_latin1(bin), base])

  defp builtin_int([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__int__", []}

  defp builtin_int([val]),
    do:
      {:exception, "TypeError: int() argument must be a string or a number, not '#{pytype(val)}'"}

  @spec validate_and_strip_underscores(String.t()) :: {:ok, String.t()} | :error
  defp validate_and_strip_underscores(""), do: :error

  defp validate_and_strip_underscores(s) do
    cond do
      String.starts_with?(s, "_") or String.ends_with?(s, "_") -> :error
      String.contains?(s, "__") -> :error
      true -> {:ok, String.replace(s, "_", "")}
    end
  end

  @spec int_with_base(String.t(), integer()) :: integer() | {:exception, String.t()}
  defp int_with_base(_str, base) when base != 0 and (base < 2 or base > 36) do
    {:exception, "ValueError: int() base must be >= 2 and <= 36, or 0"}
  end

  defp int_with_base(str, 0) do
    case str do
      "0x" <> rest -> int_with_base(rest, 16)
      "0X" <> rest -> int_with_base(rest, 16)
      "0o" <> rest -> int_with_base(rest, 8)
      "0O" <> rest -> int_with_base(rest, 8)
      "0b" <> rest -> int_with_base(rest, 2)
      "0B" <> rest -> int_with_base(rest, 2)
      _ -> int_with_base(str, 10)
    end
  end

  defp int_with_base(str, base) do
    cleaned =
      str
      |> strip_base_prefix(base)
      |> String.replace("_", "")

    case Integer.parse(cleaned, base) do
      {n, ""} -> n
      _ -> {:exception, "ValueError: invalid literal for int() with base #{base}: '#{str}'"}
    end
  end

  @spec strip_base_prefix(String.t(), integer()) :: String.t()
  defp strip_base_prefix("0x" <> rest, 16), do: rest
  defp strip_base_prefix("0X" <> rest, 16), do: rest
  defp strip_base_prefix("0o" <> rest, 8), do: rest
  defp strip_base_prefix("0O" <> rest, 8), do: rest
  defp strip_base_prefix("0b" <> rest, 2), do: rest
  defp strip_base_prefix("0B" <> rest, 2), do: rest
  defp strip_base_prefix(str, _base), do: str

  @spec builtin_float([Interpreter.pyvalue()]) :: float() | {:exception, String.t()}
  # Drop underscores used as digit separators (Python's "1_000"), only
  # where flanked by digits so malformed inputs ("_1", "1__0") still fail.
  @spec strip_digit_underscores(String.t()) :: String.t()
  defp strip_digit_underscores(s), do: String.replace(s, ~r/(?<=\d)_(?=\d)/, "")

  defp builtin_float([]), do: 0.0
  defp builtin_float([val]) when is_float(val), do: val
  defp builtin_float([val]) when is_integer(val), do: val / 1
  defp builtin_float([true]), do: 1.0
  defp builtin_float([false]), do: 0.0
  defp builtin_float([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__float__", []}

  defp builtin_float([val]) when is_binary(val) do
    trimmed = String.trim(val)

    case String.downcase(trimmed) do
      s when s in ["inf", "+inf", "infinity", "+infinity"] ->
        :infinity

      s when s in ["-inf", "-infinity"] ->
        :neg_infinity

      "nan" ->
        :nan

      _ ->
        # Python allows underscores as digit separators ('1_000').
        case Float.parse(strip_digit_underscores(trimmed)) do
          {f, ""} -> f
          _ -> {:exception, "ValueError: could not convert string to float: '#{val}'"}
        end
    end
  end

  defp builtin_float([{:pyex_decimal, %Decimal{coef: :NaN}}]), do: :nan
  defp builtin_float([{:pyex_decimal, %Decimal{coef: :inf, sign: 1}}]), do: :infinity
  defp builtin_float([{:pyex_decimal, %Decimal{coef: :inf, sign: -1}}]), do: :neg_infinity
  # `Decimal.to_float/1` drops the sign on a zero coefficient, but CPython
  # preserves `-0.0` across `float(Decimal('-0'))`. Restore it explicitly.
  defp builtin_float([{:pyex_decimal, %Decimal{coef: 0, sign: -1}}]), do: -0.0
  defp builtin_float([{:pyex_decimal, d}]), do: Decimal.to_float(d)
  defp builtin_float([{:fraction, _, _} = f]), do: Pyex.Fraction.to_float(f)

  # float(bytes)/float(bytearray): parse as an ASCII numeral, ValueError on
  # failure — CPython accepts bytes-like, it does not raise TypeError.
  defp builtin_float([{tag, bin}]) when tag in [:bytes, :bytearray],
    do: builtin_float([bytes_to_latin1(bin)])

  defp builtin_float([val]),
    do:
      {:exception,
       "TypeError: float() argument must be a string or a number, not '#{pytype(val)}'"}

  @doc """
  The canonical `type` class singleton.  Equal structurally to any other
  `type_class()` call, so `type(x) is type` and `type(X) is type(Y)` for
  classes X, Y work via structural identity.
  """
  @spec type_class() :: Interpreter.pyvalue()
  def type_class do
    {:class, "type", [], %{"__name__" => "type", "__qualname__" => "type"}}
  end

  @doc """
  Returns the Python class/type of a value.

  Used by the class-call dispatch when `type(x)` is invoked
  (`type` itself is the type-class singleton rather than a callable).
  """
  @spec builtin_type_of(Interpreter.pyvalue()) :: Interpreter.pyvalue()
  def builtin_type_of({:class, _, _, _}), do: type_class()
  def builtin_type_of({:builtin_type, _, _}), do: type_class()
  def builtin_type_of({:exception_class, _}), do: type_class()
  def builtin_type_of({:instance, {:class, _, _, _} = class, _}), do: class

  def builtin_type_of(val) do
    # Primitive pyvalues don't carry a real class.  Synthesize one so
    # structural equality makes `type(1) is type(2)` hold.
    name = pytype(val)
    {:class, name, [], %{"__name__" => name, "__qualname__" => name}}
  end

  @spec builtin_abs([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:exception, String.t()} | {:dunder_call, any(), list()}
  defp builtin_abs([true]), do: 1
  defp builtin_abs([false]), do: 0
  defp builtin_abs([val]) when is_number(val), do: abs(val)
  defp builtin_abs([{:pyex_decimal, d}]), do: {:pyex_decimal, Decimal.abs(d)}
  defp builtin_abs([{:fraction, n, d}]), do: {:fraction, abs(n), d}
  defp builtin_abs([{:complex, r, i}]), do: :math.sqrt(r * r + i * i)
  defp builtin_abs([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__abs__", []}

  defp builtin_abs([{:instance, {:class, _name, _bases, class_attrs}, inst_attrs} = inst]) do
    abs_fn = Map.get(inst_attrs, "__abs__") || Map.get(class_attrs, "__abs__")

    case abs_fn do
      {:builtin, fun} -> fun.([inst])
      nil -> {:exception, "TypeError: bad operand type for abs()"}
      _ -> {:exception, "TypeError: bad operand type for abs()"}
    end
  end

  defp builtin_abs([_]), do: {:exception, "TypeError: bad operand type for abs()"}

  @spec builtin_min(
          [Interpreter.pyvalue()],
          %{optional(String.t()) => Interpreter.pyvalue()}
        ) :: Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_min(args, kwargs) do
    key_fn = Map.get(kwargs, "key")
    has_default = Map.has_key?(kwargs, "default")
    default = Map.get(kwargs, "default")

    case extract_minmax_items(args) do
      {:ok, items} when items != [] ->
        if key_fn, do: {:min_call, items, key_fn}, else: Enum.min(items)

      {:ok, []} ->
        if has_default do
          default
        else
          {:exception, "ValueError: min() arg is an empty sequence"}
        end

      {:error, msg} ->
        {:exception, msg}
    end
  end

  @spec builtin_max(
          [Interpreter.pyvalue()],
          %{optional(String.t()) => Interpreter.pyvalue()}
        ) :: Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_max(args, kwargs) do
    key_fn = Map.get(kwargs, "key")
    has_default = Map.has_key?(kwargs, "default")
    default = Map.get(kwargs, "default")

    case extract_minmax_items(args) do
      {:ok, items} when items != [] ->
        if key_fn, do: {:max_call, items, key_fn}, else: Enum.max(items)

      {:ok, []} ->
        if has_default do
          default
        else
          {:exception, "ValueError: max() arg is an empty sequence"}
        end

      {:error, msg} ->
        {:exception, msg}
    end
  end

  @spec extract_minmax_items([Interpreter.pyvalue()]) ::
          {:ok, [Interpreter.pyvalue()]} | {:error, String.t()}
  defp extract_minmax_items([{:py_list, reversed, _}]), do: {:ok, Enum.reverse(reversed)}
  defp extract_minmax_items([list]) when is_list(list), do: {:ok, list}
  defp extract_minmax_items([{:generator, items}]), do: {:ok, items}

  defp extract_minmax_items([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, msg} -> {:error, msg}
      list -> {:ok, list}
    end
  end

  defp extract_minmax_items([{:set, s}]),
    do: {:ok, MapSet.to_list(s)}

  defp extract_minmax_items([{:frozenset, s}]),
    do: {:ok, MapSet.to_list(s)}

  defp extract_minmax_items([{:tuple, items}]), do: {:ok, items}

  # The remaining single-arg iterable types (keys for dicts, ints for
  # bytes, chars for str), matching sorted()/list().
  defp extract_minmax_items([{:py_dict, _, _} = dict]), do: {:ok, PyDict.keys(visible_dict(dict))}

  defp extract_minmax_items([map]) when is_map(map),
    do: {:ok, map |> visible_dict() |> Map.keys()}

  defp extract_minmax_items([{:bytes, bin}]), do: {:ok, :binary.bin_to_list(bin)}
  defp extract_minmax_items([str]) when is_binary(str), do: {:ok, String.codepoints(str)}

  defp extract_minmax_items(args) when length(args) >= 2, do: {:ok, args}
  defp extract_minmax_items(_), do: {:error, "TypeError: expected iterable argument"}

  @spec builtin_sum([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:iter_sum, Interpreter.pyvalue()} | {:exception, String.t()}
  defp builtin_sum([iterable]), do: builtin_sum([iterable, 0])

  defp builtin_sum([{:py_list, reversed, _}, start]),
    do: sum_with_start(Enum.reverse(reversed), start)

  defp builtin_sum([list, start]) when is_list(list), do: sum_with_start(list, start)
  defp builtin_sum([{:tuple, items}, start]), do: sum_with_start(items, start)
  defp builtin_sum([{:generator, items}, start]), do: sum_with_start(items, start)

  defp builtin_sum([{:range, _, _, _} = r, start]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> sum_with_start(list, start)
    end
  end

  defp builtin_sum([val, start]), do: {:iter_sum, val, start}

  @spec sum_with_start([Interpreter.pyvalue()], Interpreter.pyvalue()) ::
          Interpreter.pyvalue() | {:exception, String.t()}
  defp sum_with_start(items, start) do
    cond do
      py_numeric?(start) and Enum.all?(items, &py_numeric?/1) ->
        ints = Enum.map([start | items], &bool_to_int/1)

        if is_float(start) or Enum.any?(items, &is_float/1) do
          neumaier_sum(ints)
        else
          Enum.reduce(tl(ints), hd(ints), &+/2)
        end

      # Custom objects (instances, or heap refs that may point at them) need
      # the full binop protocol — __add__/__radd__ with NotImplemented
      # fallback — which lives in the interpreter, so defer via a ctx call.
      Enum.any?([start | items], &needs_binop_sum?/1) ->
        {:ctx_call, fn env, ctx -> fold_sum_via_binop(items, start, env, ctx) end}

      true ->
        Enum.reduce(items, start, &sum_step/2)
    end
  end

  @spec needs_binop_sum?(Interpreter.pyvalue()) :: boolean()
  defp needs_binop_sum?({:ref, _}), do: true
  defp needs_binop_sum?({:instance, _, _}), do: true
  defp needs_binop_sum?(_), do: false

  # Folds the running total with `+` through the interpreter, so summing
  # objects that define __add__/__radd__ works exactly like `a + b + c …`.
  @spec fold_sum_via_binop([Interpreter.pyvalue()], Interpreter.pyvalue(), Env.t(), Ctx.t()) ::
          {Interpreter.pyvalue() | {:exception, String.t()}, Env.t(), Ctx.t()}
  defp fold_sum_via_binop(items, start, env, ctx) do
    Enum.reduce_while(items, {start, env, ctx}, fn item, {acc, env, ctx} ->
      node = {:binop, [], [:plus, {:__evaluated__, acc}, {:__evaluated__, item}]}

      case Interpreter.eval(node, env, ctx) do
        {{:exception, _} = signal, env, ctx} -> {:halt, {signal, env, ctx}}
        {value, env, ctx} -> {:cont, {value, env, ctx}}
      end
    end)
  end

  @spec py_numeric?(Interpreter.pyvalue()) :: boolean()
  defp py_numeric?(v) when is_number(v), do: true
  defp py_numeric?(true), do: true
  defp py_numeric?(false), do: true
  defp py_numeric?(_), do: false

  # Neumaier summation: a Kahan variant that handles the case where
  # `x` is larger than the running sum.  Matches CPython's `math.fsum`
  # / `sum` precision for the common float-accumulation patterns.
  @spec neumaier_sum([number()]) :: float()
  defp neumaier_sum(items) do
    {sum, c} =
      Enum.reduce(items, {0.0, 0.0}, fn x, {s, c} ->
        t = s + x

        new_c =
          if abs(s) >= abs(x) do
            c + (s - t + x)
          else
            c + (x - t + s)
          end

        {t, new_c}
      end)

    sum + c
  end

  @spec sum_step(Interpreter.pyvalue(), Interpreter.pyvalue()) :: Interpreter.pyvalue()
  defp sum_step(x, acc) when is_number(x) and is_number(acc), do: acc + x

  defp sum_step({:py_list, xr, xlen}, {:py_list, ar, alen}),
    do: {:py_list, xr ++ ar, alen + xlen}

  defp sum_step({:py_list, xr, xlen}, list) when is_list(list),
    do: {:py_list, xr ++ Enum.reverse(list), length(list) + xlen}

  defp sum_step(x, acc) when is_list(x) and is_list(acc), do: acc ++ x
  defp sum_step({:tuple, x}, {:tuple, acc}), do: {:tuple, acc ++ x}
  defp sum_step(x, acc) when is_binary(x) and is_binary(acc), do: acc <> x

  # Decimal accumulation. The default `start=0` is an int, but Decimal
  # arithmetic with ints is supported (the int gets coerced), so once the
  # first Decimal arrives the accumulator becomes a Decimal and stays that
  # way. This preserves financial-grade precision through long sums.
  defp sum_step({:pyex_decimal, x}, acc) when is_integer(acc),
    do: {:pyex_decimal, Decimal.add(Decimal.new(acc), x)}

  defp sum_step({:pyex_decimal, x}, {:pyex_decimal, acc}),
    do: {:pyex_decimal, Decimal.add(acc, x)}

  defp sum_step(x, {:pyex_decimal, acc}) when is_integer(x),
    do: {:pyex_decimal, Decimal.add(acc, Decimal.new(x))}

  @spec builtin_sorted([Interpreter.pyvalue()], %{optional(String.t()) => Interpreter.pyvalue()}) ::
          {:sort_call, [Interpreter.pyvalue()], Interpreter.pyvalue() | nil, boolean()}
          | {:iter_sorted, Interpreter.pyvalue(), Interpreter.pyvalue() | nil, boolean()}
          | {:exception, String.t()}
  defp builtin_sorted(args, kwargs) do
    items =
      case args do
        [{:py_list, reversed, _}] ->
          Enum.reverse(reversed)

        [list] when is_list(list) ->
          list

        [{:set, s}] ->
          MapSet.to_list(s)

        [{:frozenset, s}] ->
          MapSet.to_list(s)

        [{:tuple, elems}] ->
          elems

        [{:generator, elems}] ->
          elems

        [{:range, _, _, _} = r] ->
          case range_to_list(r) do
            {:exception, _} = err -> err
            list -> list
          end

        [str] when is_binary(str) ->
          String.codepoints(str)

        [{:py_dict, _, _} = dict] ->
          PyDict.keys(visible_dict(dict))

        [map] when is_map(map) ->
          map |> visible_dict() |> Map.keys()

        # Anything else iterable (bytes, custom __iter__, ...) defers to
        # the one coercion; non-iterables surface its TypeError.
        [val] ->
          {:needs_iter, val}

        _ ->
          :error
      end

    case items do
      {:exception, _} = err ->
        err

      :error ->
        {:exception, "TypeError: sorted() expected iterable argument"}

      {:needs_iter, val} ->
        key_fn = Map.get(kwargs, "key")
        reverse = truthy?(Map.get(kwargs, "reverse", false))
        {:iter_sorted, val, key_fn, reverse}

      items ->
        key_fn = Map.get(kwargs, "key")
        reverse = truthy?(Map.get(kwargs, "reverse", false))
        {:sort_call, items, key_fn, reverse}
    end
  end

  @spec builtin_reversed([Interpreter.pyvalue()]) :: [Interpreter.pyvalue()]
  # Already reversed internally!
  defp builtin_reversed([{:py_list, reversed, _}]), do: reversed
  defp builtin_reversed([list]) when is_list(list), do: Enum.reverse(list)
  defp builtin_reversed([{:generator, items}]), do: Enum.reverse(items)

  defp builtin_reversed([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> Enum.reverse(list)
    end
  end

  defp builtin_reversed([{:tuple, items}]), do: Enum.reverse(items)

  defp builtin_reversed([str]) when is_binary(str),
    do: str |> String.codepoints() |> Enum.reverse()

  # CPython: reversed(obj) uses __reversed__ if defined, else falls back to the
  # __len__ + __getitem__ sequence protocol.
  defp builtin_reversed([{:instance, _, _} = inst]),
    do: {:dunder_call, inst, "__reversed__", []}

  @spec builtin_enumerate([Interpreter.pyvalue()], map()) ::
          [{:tuple, [Interpreter.pyvalue()]}] | {:exception, String.t()}
  defp builtin_enumerate(args, kwargs) do
    {iterable, start} =
      case args do
        [it] -> {it, Map.get(kwargs, "start", 0)}
        [it, s] when is_integer(s) -> {it, s}
        _ -> {nil, 0}
      end

    items =
      case iterable do
        {:py_list, reversed, _} ->
          Enum.reverse(reversed)

        list when is_list(list) ->
          list

        {:generator, elems} ->
          elems

        {:range, _, _, _} = r ->
          case range_to_list(r) do
            {:exception, _} = err -> err
            list -> list
          end

        {:tuple, elems} ->
          elems

        str when is_binary(str) ->
          String.codepoints(str)

        _ ->
          :defer
      end

    case items do
      {:exception, _} = err ->
        err

      :defer ->
        {:iter_enumerate, iterable, start}

      _ ->
        items
        |> Enum.with_index(start)
        |> Enum.map(fn {val, idx} -> {:tuple, [idx, val]} end)
    end
  end

  @spec builtin_zip([Interpreter.pyvalue()], map()) ::
          [{:tuple, [Interpreter.pyvalue()]}] | {:exception, String.t()}
  defp builtin_zip([], _kwargs), do: []

  defp builtin_zip(args, kwargs) when is_list(args) do
    strict = Map.get(kwargs, "strict", false)

    lists =
      Enum.reduce_while(args, {:ok, []}, fn arg, {:ok, acc} ->
        case to_list(arg) do
          {:ok, list} -> {:cont, {:ok, [list | acc]}}
          :error -> {:halt, :error}
        end
      end)

    case lists do
      {:ok, reversed_lists} ->
        ordered = Enum.reverse(reversed_lists)
        lengths = Enum.map(ordered, &length/1)

        cond do
          strict and length(Enum.uniq(lengths)) > 1 ->
            {:exception,
             "ValueError: zip() argument #{Enum.find_index(lengths, fn l -> l != List.first(lengths) end) + 1} is shorter than argument #{1}"}

          true ->
            ordered
            |> Enum.zip()
            |> Enum.map(fn t -> {:tuple, Tuple.to_list(t)} end)
        end

      :error ->
        {:exception, "TypeError: zip argument is not iterable"}
    end
  end

  @spec to_list(Interpreter.pyvalue()) :: {:ok, [Interpreter.pyvalue()]} | :error
  @doc false
  @spec to_list_safe(Interpreter.pyvalue()) :: [Interpreter.pyvalue()]
  def to_list_safe(val) do
    case to_list(val) do
      {:ok, items} -> items
      :error -> []
    end
  end

  @doc """
  Coerce any simple (non-instance) iterable to an Elixir list, or
  `:error`. The shared pure coercion stdlib consumers use so a function
  that takes an iterable accepts the same types `for`/`list()` do.
  """
  @spec iterable_to_list(Interpreter.pyvalue()) :: {:ok, [Interpreter.pyvalue()]} | :error
  def iterable_to_list(val), do: to_list(val)

  defp to_list({:deque, _, _, _, _} = d), do: {:ok, Pyex.Methods.deque_to_list(d)}
  defp to_list({:py_list, reversed, _}), do: {:ok, Enum.reverse(reversed)}
  defp to_list(list) when is_list(list), do: {:ok, list}

  defp to_list({:range, _, _, _} = r) do
    case range_to_list(r) do
      {:exception, _} -> :error
      list -> {:ok, list}
    end
  end

  defp to_list({:tuple, items}), do: {:ok, items}
  defp to_list({:generator, items}), do: {:ok, items}
  defp to_list({:set, s}), do: {:ok, MapSet.to_list(s)}
  defp to_list({:frozenset, s}), do: {:ok, MapSet.to_list(s)}
  defp to_list(str) when is_binary(str), do: {:ok, String.codepoints(str)}
  defp to_list({:py_dict, _, _} = dict), do: {:ok, PyDict.keys(visible_dict(dict))}
  defp to_list(map) when is_map(map), do: {:ok, map |> visible_dict() |> Map.keys()}
  defp to_list({:bytes, bin}), do: {:ok, :binary.bin_to_list(bin)}
  defp to_list({:bytearray, bin}), do: {:ok, :binary.bin_to_list(bin)}
  defp to_list(_), do: :error

  @spec builtin_bool([Interpreter.pyvalue()]) ::
          boolean() | {:dunder_call, Interpreter.pyvalue(), String.t(), []}
  defp builtin_bool([]), do: false
  defp builtin_bool([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__bool__", []}
  defp builtin_bool([val]), do: truthy?(val)

  @spec builtin_list([Interpreter.pyvalue()]) ::
          [Interpreter.pyvalue()]
          | {:iter_to_list, Interpreter.pyvalue()}
          | {:ctx_call, (Env.t(), Ctx.t() -> {Interpreter.pyvalue(), Env.t(), Ctx.t()})}
  defp builtin_list([]), do: alloc_list([])

  defp builtin_list([{:py_list, reversed, _}]), do: alloc_list(Enum.reverse(reversed))

  defp builtin_list([list]) when is_list(list), do: alloc_list(list)

  defp builtin_list([string]) when is_binary(string), do: alloc_list(String.codepoints(string))

  defp builtin_list([{:tuple, items}]), do: alloc_list(items)
  defp builtin_list([{:set, s}]), do: alloc_list(MapSet.to_list(s))
  defp builtin_list([{:frozenset, s}]), do: alloc_list(MapSet.to_list(s))
  defp builtin_list([{:deque, _, _, _, _} = d]), do: alloc_list(Pyex.Methods.deque_to_list(d))
  defp builtin_list([{:generator, items}]), do: alloc_list(items)

  # When a generator raises during accumulation, we receive a
  # {:generator_error, items, msg}.  Consuming the generator via list()
  # should surface that exception after yielding partial results, but
  # in practice CPython raises immediately from `list(gen())` — we do
  # the same and discard the partial items.
  defp builtin_list([{:generator_error, _items, msg}]), do: {:exception, msg}

  defp builtin_list([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> alloc_list(list)
    end
  end

  defp builtin_list([{:py_dict, _, _} = dict]), do: alloc_list(PyDict.keys(visible_dict(dict)))
  defp builtin_list([map]) when is_map(map), do: alloc_list(map |> visible_dict() |> Map.keys())
  # Any other single arg defers to the one iterable coercion
  # (Interpreter.to_iterable), which also yields the right TypeError for
  # non-iterables. New iterable types are then supported everywhere at once.
  defp builtin_list([val]), do: {:iter_to_list, val}

  @spec alloc_list([Interpreter.pyvalue()]) ::
          {:py_list, [Interpreter.pyvalue()], non_neg_integer()}
  # `list(...)` yields a concrete `{:py_list}` VALUE. Through the normal builtin path,
  # `handle_builtin_result` heap-allocs it into a fresh `{:ref}` — exactly like a `[...]` literal — so a
  # user's list has identity (mutation-through-alias persists, slice assignment works). Direct callers
  # (e.g. list/dict subclass construction via `builtin_type_base_class`) instead get the usable concrete
  # list. Elements pass through unchanged — already refs if mutable — so this is a correct shallow copy.
  defp alloc_list(items), do: {:py_list, Enum.reverse(items), length(items)}

  @doc false
  @spec builtin_dict([Interpreter.pyvalue()], %{optional(String.t()) => Interpreter.pyvalue()}) ::
          PyDict.t() | {:exception, String.t()}
  def builtin_dict(args, kwargs \\ %{}) do
    with {:ok, dict} <- builtin_dict_positional(args) do
      Enum.reduce(kwargs, dict, fn {k, v}, acc -> PyDict.put(acc, k, v) end)
    end
  end

  @spec builtin_dict_positional([Interpreter.pyvalue()]) ::
          {:ok, PyDict.t()} | {:exception, String.t()}
  defp builtin_dict_positional([]), do: {:ok, PyDict.new()}

  defp builtin_dict_positional([{:py_dict, _, _} = dict]) do
    {:ok, visible_dict(dict)}
  end

  defp builtin_dict_positional([map]) when is_map(map) do
    {:ok,
     map
     |> visible_dict()
     |> Map.reject(fn {_k, v} ->
       match?({:builtin, _}, v) or match?({:builtin_kw, _}, v) or
         match?({:function, _, _, _, _, _, _}, v) or match?({:bound_method, _, _}, v) or
         match?({:bound_method, _, _, _}, v)
     end)
     |> PyDict.from_map()}
  end

  # dict(iterable_of_pairs): every element must be a 2-element iterable.
  # Routes through the shared coercion so list/tuple/str/bytes/range/set/
  # generator all work, and raises CPython's ValueError/TypeError on
  # malformed elements rather than silently dropping them.
  defp builtin_dict_positional([iterable]) do
    case to_list(iterable) do
      {:ok, items} -> dict_from_pairs(items)
      :error -> {:exception, "TypeError: '#{pytype(iterable)}' object is not iterable"}
    end
  end

  defp builtin_dict_positional(args) do
    {:exception, "TypeError: dict expected at most 1 argument, got #{length(args)}"}
  end

  @spec dict_from_pairs([Interpreter.pyvalue()]) :: {:ok, PyDict.t()} | {:exception, String.t()}
  defp dict_from_pairs(items) do
    items
    |> Enum.with_index()
    |> Enum.reduce_while({:ok, PyDict.new()}, fn {item, i}, {:ok, acc} ->
      case dict_pair(item) do
        {:ok, k, v} ->
          {:cont, {:ok, PyDict.put(acc, k, v)}}

        {:error, :type} ->
          {:halt,
           {:exception,
            "TypeError: cannot convert dictionary update sequence element ##{i} to a sequence"}}

        {:error, :length, n} ->
          {:halt,
           {:exception,
            "ValueError: dictionary update sequence element ##{i} has length #{n}; 2 is required"}}
      end
    end)
  end

  @spec dict_pair(Interpreter.pyvalue()) ::
          {:ok, Interpreter.pyvalue(), Interpreter.pyvalue()}
          | {:error, :type}
          | {:error, :length, non_neg_integer()}
  defp dict_pair({:tuple, [k, v]}), do: {:ok, k, v}
  defp dict_pair([k, v]), do: {:ok, k, v}

  defp dict_pair(item) do
    case to_list(item) do
      {:ok, [k, v]} -> {:ok, k, v}
      {:ok, other} -> {:error, :length, length(other)}
      :error -> {:error, :type}
    end
  end

  @doc false
  @spec dict_fromkeys(Interpreter.pyvalue(), Interpreter.pyvalue()) ::
          PyDict.t() | {:exception, String.t()}
  def dict_fromkeys(keys, default) do
    keys_list =
      case keys do
        {:py_list, reversed, _} -> Enum.reverse(reversed)
        list when is_list(list) -> list
        {:tuple, items} -> items
        {:set, s} -> MapSet.to_list(s)
        {:frozenset, s} -> MapSet.to_list(s)
        {:range, _, _, _} = r -> range_to_list(r)
        str when is_binary(str) -> String.codepoints(str)
        _ -> {:error, "TypeError: dict.fromkeys() argument is not iterable"}
      end

    case keys_list do
      {:error, msg} ->
        {:exception, msg}

      list ->
        Enum.reduce(list, PyDict.new(), fn k, acc -> PyDict.put(acc, k, default) end)
    end
  end

  @spec builtin_isinstance([Interpreter.pyvalue()]) :: boolean()
  defp builtin_isinstance([val, type_name]) when is_binary(type_name) do
    pytype(val) == type_name
  end

  defp builtin_isinstance([val, {:builtin_type, type_name, _}]) do
    actual = pytype(val)

    cond do
      actual == type_name ->
        true

      # Every value is an instance of object.
      type_name == "object" ->
        true

      type_name == "int" and actual == "bool" ->
        true

      # `class MyList(list)` — instance's class has the built-in in its MRO.
      match?({:instance, {:class, _, _, _}, _}, val) ->
        {:instance, {:class, name, bases, _}, _} = val
        name == type_name or check_bases(bases, type_name)

      true ->
        false
    end
  end

  # `isinstance(x, type)` — true iff x is a class-like value.
  defp builtin_isinstance([val, {:class, "type", [], _}]) do
    class_like?(val)
  end

  defp builtin_isinstance([{:instance, {:class, name, bases, _}, _}, {:class, target_name, _, _}]) do
    name == target_name or check_bases(bases, target_name)
  end

  # Built-in exception classes as runtime values: handle both sides.
  defp builtin_isinstance([{:instance, {:class, name, bases, _}, _}, {:exception_class, target}]) do
    Pyex.ExceptionsHierarchy.subclass?(name, target) or check_bases(bases, target)
  end

  defp builtin_isinstance([val, {:tuple, types}]) do
    Enum.any?(types, fn t ->
      builtin_isinstance([val, t])
    end)
  end

  defp builtin_isinstance([val, {:exception_class, _}]) when not is_tuple(val), do: false
  defp builtin_isinstance([_, _]), do: false

  @spec class_like?(Interpreter.pyvalue()) :: boolean()
  defp class_like?({:class, _, _, _}), do: true
  defp class_like?({:builtin_type, _, _}), do: true
  defp class_like?({:exception_class, _}), do: true
  defp class_like?(_), do: false

  @spec builtin_issubclass([Interpreter.pyvalue()]) :: boolean() | {:exception, String.t()}
  defp builtin_issubclass([{:class, name, bases, _}, {:class, target_name, _, _}]) do
    name == target_name or check_bases(bases, target_name)
  end

  defp builtin_issubclass([{:class, name, bases, _}, {:builtin_type, type_name, _}]) do
    name == type_name or check_bases(bases, type_name)
  end

  defp builtin_issubclass([{:class, _, _, _} = cls, {:tuple, types}]) do
    Enum.any?(types, fn t ->
      builtin_issubclass([cls, t])
    end)
  end

  defp builtin_issubclass([{:class, name, bases, _}, {:exception_class, target}]) do
    Pyex.ExceptionsHierarchy.subclass?(name, target) or check_bases(bases, target)
  end

  defp builtin_issubclass([{:class, _, _, _}, _]), do: false

  # Built-in type hierarchy: bool is a subtype of int
  defp builtin_issubclass([{:builtin_type, sub, _}, {:builtin_type, sup, _}]) do
    sub == sup or builtin_subtype?(sub, sup)
  end

  defp builtin_issubclass([{:builtin_type, sub, _}, {:class, sup_name, _, _}]) do
    sub == sup_name
  end

  defp builtin_issubclass([{:builtin_type, _, _}, _]), do: false

  # Exception classes: subclass relationships both ways.
  defp builtin_issubclass([{:exception_class, sub}, {:exception_class, sup}]) do
    Pyex.ExceptionsHierarchy.subclass?(sub, sup)
  end

  defp builtin_issubclass([{:exception_class, _} = cls, {:tuple, types}]) do
    Enum.any?(types, fn t -> builtin_issubclass([cls, t]) end)
  end

  defp builtin_issubclass([{:exception_class, sub}, {:class, sup, bases, _}]) do
    Pyex.ExceptionsHierarchy.subclass?(sub, sup) or
      Enum.any?(bases, fn _ -> false end)
  end

  defp builtin_issubclass([{:exception_class, _}, _]), do: false

  defp builtin_issubclass([arg1, _arg2]) do
    {:exception, "TypeError: issubclass() arg 1 must be a class, not #{pytype(arg1)}"}
  end

  @builtin_hierarchy %{
    "bool" => ["int"],
    "int" => ["object"],
    "float" => ["object"],
    "str" => ["object"],
    "list" => ["object"],
    "dict" => ["object"],
    "tuple" => ["object"],
    "set" => ["object"],
    "object" => []
  }

  @spec builtin_subtype?(String.t(), String.t()) :: boolean()
  defp builtin_subtype?(sub, sup) do
    parents = Map.get(@builtin_hierarchy, sub, [])

    Enum.any?(parents, fn parent ->
      parent == sup or builtin_subtype?(parent, sup)
    end)
  end

  @spec check_bases([Interpreter.pyvalue()], String.t()) :: boolean()
  defp check_bases([], _target), do: false

  defp check_bases(bases, target) do
    Enum.any?(bases, fn
      {:class, name, sub_bases, _} ->
        name == target or check_bases(sub_bases, target)

      _ ->
        false
    end)
  end

  @spec builtin_tuple([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_tuple([]), do: {:tuple, []}
  defp builtin_tuple([{:py_list, reversed, _}]), do: {:tuple, Enum.reverse(reversed)}
  defp builtin_tuple([list]) when is_list(list), do: {:tuple, list}
  defp builtin_tuple([{:tuple, _} = t]), do: t

  defp builtin_tuple([str]) when is_binary(str),
    do: {:tuple, String.codepoints(str)}

  defp builtin_tuple([{:generator, items}]), do: {:tuple, items}

  defp builtin_tuple([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> {:tuple, list}
    end
  end

  defp builtin_tuple([val]), do: {:iter_to_tuple, val}

  @spec builtin_set([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_set([]), do: {:set, MapSet.new()}
  defp builtin_set([{:py_list, reversed, _}]), do: {:set, MapSet.new(Enum.reverse(reversed))}
  defp builtin_set([list]) when is_list(list), do: {:set, MapSet.new(list)}
  defp builtin_set([{:tuple, items}]), do: {:set, MapSet.new(items)}
  defp builtin_set([{:set, _} = s]), do: s
  defp builtin_set([{:frozenset, s}]), do: {:set, s}
  defp builtin_set([str]) when is_binary(str), do: {:set, MapSet.new(String.codepoints(str))}
  defp builtin_set([{:generator, items}]), do: {:set, MapSet.new(items)}

  defp builtin_set([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> {:set, MapSet.new(list)}
    end
  end

  defp builtin_set([val]), do: {:iter_to_set, val}

  @spec builtin_frozenset([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_frozenset([]), do: {:frozenset, MapSet.new()}

  defp builtin_frozenset([{:py_list, reversed, _}]),
    do: {:frozenset, MapSet.new(Enum.reverse(reversed))}

  defp builtin_frozenset([list]) when is_list(list), do: {:frozenset, MapSet.new(list)}
  defp builtin_frozenset([{:tuple, items}]), do: {:frozenset, MapSet.new(items)}
  defp builtin_frozenset([{:set, s}]), do: {:frozenset, s}
  defp builtin_frozenset([{:frozenset, _} = fs]), do: fs

  defp builtin_frozenset([str]) when is_binary(str),
    do: {:frozenset, MapSet.new(String.codepoints(str))}

  defp builtin_frozenset([{:generator, items}]), do: {:frozenset, MapSet.new(items)}

  defp builtin_frozenset([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> {:frozenset, MapSet.new(list)}
    end
  end

  defp builtin_frozenset([val]), do: {:iter_to_frozenset, val}

  @spec builtin_round([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_round([val]) when is_boolean(val), do: bool_to_int(val)
  defp builtin_round([val]) when is_integer(val), do: val
  defp builtin_round([val]) when is_float(val), do: bankers_round(val)

  defp builtin_round([val, ndigits]) when is_boolean(val) and is_integer(ndigits),
    do: bool_to_int(val)

  defp builtin_round([val, ndigits]) when is_number(val) and is_integer(ndigits) do
    float_str = :erlang.float_to_binary(val / 1, [])

    case Decimal.parse(float_str) do
      {d, ""} ->
        rounded = Decimal.round(d, ndigits, :half_even)
        result = Decimal.to_float(rounded)
        if ndigits <= 0 and is_integer(val), do: round(result), else: result

      _ ->
        factor = :math.pow(10, ndigits)
        scaled = val * factor
        bankers_round(scaled) / factor
    end
  end

  # round(Decimal[, ndigits]) returns another Decimal, rounding with banker's
  # to match Python's default. Without ndigits, returns the integer-valued
  # Decimal; CPython actually returns a Python int here.
  defp builtin_round([{:pyex_decimal, %Decimal{coef: :NaN}}]),
    do: {:exception, "ValueError: cannot convert NaN to integer"}

  defp builtin_round([{:pyex_decimal, %Decimal{coef: :inf}}]),
    do: {:exception, "OverflowError: cannot round Infinity"}

  defp builtin_round([{:pyex_decimal, d}]) do
    Decimal.round(d, 0, :half_even) |> Decimal.to_integer()
  end

  defp builtin_round([{:pyex_decimal, %Decimal{coef: :NaN}} = d, _]),
    do: d

  defp builtin_round([{:pyex_decimal, %Decimal{coef: :inf}} = d, _]),
    do: d

  defp builtin_round([{:pyex_decimal, d}, ndigits]) when is_integer(ndigits) do
    {:pyex_decimal, Decimal.round(d, ndigits, :half_even)}
  end

  defp builtin_round([_]),
    do: {:exception, "TypeError: type has no __round__ method"}

  defp builtin_round([_, _]),
    do: {:exception, "TypeError: type has no __round__ method"}

  # Python uses banker's rounding (round-half-to-even).
  @spec bankers_round(float()) :: integer()
  defp bankers_round(x) when x < 0, do: -bankers_round(-x)

  defp bankers_round(x) do
    fi = trunc(x)
    frac = x - fi

    cond do
      abs(frac - 0.5) < 1.0e-9 ->
        if rem(fi, 2) == 0, do: fi, else: fi + 1

      frac > 0.5 ->
        fi + 1

      true ->
        fi
    end
  end

  @spec builtin_input([Interpreter.pyvalue()]) :: {:exception, String.t()}
  defp builtin_input(_args) do
    {:exception, "RuntimeError: input() is not supported in the sandbox"}
  end

  @spec builtin_open(
          [Interpreter.pyvalue()],
          %{optional(String.t()) => Interpreter.pyvalue()}
        ) ::
          {:ctx_call, (Pyex.Env.t(), Pyex.Ctx.t() -> term())}
          | {:exception, String.t()}
  defp builtin_open(args, kwargs) do
    with :ok <- validate_open_kwargs(kwargs),
         {:ok, path} <- resolve_open_path(args, kwargs),
         {:ok, mode_str} <- resolve_open_mode(args, kwargs) do
      open_file_handle(path, mode_str)
    else
      {:exception, _} = error -> error
    end
  end

  @open_supported_kwargs ["file", "mode", "encoding", "newline", "errors"]

  @spec validate_open_kwargs(%{optional(String.t()) => Interpreter.pyvalue()}) ::
          :ok | {:exception, String.t()}
  defp validate_open_kwargs(kwargs) do
    case Map.keys(kwargs) -- @open_supported_kwargs do
      [] -> :ok
      [name | _] -> {:exception, "TypeError: open() got an unexpected keyword argument '#{name}'"}
    end
  end

  @spec resolve_open_path([Interpreter.pyvalue()], %{
          optional(String.t()) => Interpreter.pyvalue()
        }) ::
          {:ok, String.t()} | {:exception, String.t()}
  defp resolve_open_path(args, kwargs) do
    cond do
      length(args) > 2 ->
        {:exception,
         "TypeError: open() takes from 1 to 2 positional arguments but #{length(args)} were given"}

      length(args) >= 1 and Map.has_key?(kwargs, "file") ->
        {:exception, "TypeError: argument for open() given by name ('file') and position (1)"}

      length(args) >= 1 ->
        coerce_pathlike(hd(args), "open() argument 'file'")

      Map.has_key?(kwargs, "file") ->
        coerce_pathlike(Map.fetch!(kwargs, "file"), "open() argument 'file'")

      args == [] and not Map.has_key?(kwargs, "file") ->
        {:exception, "TypeError: open() missing required argument 'file' (pos 1)"}

      true ->
        {:exception, "TypeError: invalid arguments"}
    end
  end

  @spec coerce_pathlike(Interpreter.pyvalue(), String.t()) ::
          {:ok, String.t()} | {:exception, String.t()}
  defp coerce_pathlike(value, label) do
    case Pyex.Path.coerce(value) do
      {:ok, path} -> {:ok, path}
      :error -> {:exception, "TypeError: #{label} must be str or PathLike"}
    end
  end

  @spec resolve_open_mode([Interpreter.pyvalue()], %{
          optional(String.t()) => Interpreter.pyvalue()
        }) ::
          {:ok, String.t()} | {:exception, String.t()}
  defp resolve_open_mode(args, kwargs) do
    cond do
      length(args) >= 2 and Map.has_key?(kwargs, "mode") ->
        {:exception, "TypeError: argument for open() given by name ('mode') and position (2)"}

      match?([_, mode] when is_binary(mode), args) ->
        {:ok, Enum.at(args, 1)}

      match?(%{"mode" => mode} when is_binary(mode), kwargs) ->
        {:ok, Map.fetch!(kwargs, "mode")}

      length(args) <= 1 and not Map.has_key?(kwargs, "mode") ->
        {:ok, "r"}

      true ->
        {:exception, "TypeError: invalid arguments"}
    end
  end

  @spec open_file_handle(String.t(), String.t()) ::
          {:ctx_call, (Pyex.Env.t(), Pyex.Ctx.t() -> term())}
          | {:exception, String.t()}
  defp open_file_handle(path, mode_str) do
    normalized = String.replace(mode_str, "b", "")

    case normalized do
      m when m in ["r", "w", "a"] ->
        mode = %{"r" => :read, "w" => :write, "a" => :append} |> Map.fetch!(m)

        {:ctx_call,
         fn env, ctx ->
           # Platform capability span: the tamper-proof record of which file the
           # turn opened and with what intent (read/write/append). See Pyex.Turn.
           {ctx, span} =
             Pyex.Ctx.open_runtime_span(ctx, "file.open", %{
               "file.path" => path,
               "file.mode" => Atom.to_string(mode)
             })

           case Pyex.Ctx.open_handle(ctx, path, mode) do
             {:ok, id, ctx} ->
               {{:file_handle, id}, env, Pyex.Ctx.close_runtime_span(ctx, span)}

             {:error, msg} ->
               {{:exception, msg}, env,
                Pyex.Ctx.close_runtime_span(ctx, span, %{"error.type" => msg})}
           end
         end}

      _ ->
        {:exception, "ValueError: invalid mode: '#{mode_str}' (normalized: '#{normalized}')"}
    end
  end

  @spec builtin_any([Interpreter.pyvalue()]) :: boolean() | {:exception, String.t()}
  defp builtin_any([{:py_list, reversed, _}]), do: Enum.any?(Enum.reverse(reversed), &truthy?/1)
  defp builtin_any([list]) when is_list(list), do: Enum.any?(list, &truthy?/1)
  defp builtin_any([{:tuple, items}]), do: Enum.any?(items, &truthy?/1)
  defp builtin_any([{:generator, items}]), do: Enum.any?(items, &truthy?/1)

  defp builtin_any([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> Enum.any?(list, &truthy?/1)
    end
  end

  defp builtin_any([val]), do: {:iter_any, val}

  @spec builtin_all([Interpreter.pyvalue()]) :: boolean() | {:exception, String.t()}
  defp builtin_all([{:py_list, reversed, _}]), do: Enum.all?(Enum.reverse(reversed), &truthy?/1)
  defp builtin_all([list]) when is_list(list), do: Enum.all?(list, &truthy?/1)
  defp builtin_all([{:tuple, items}]), do: Enum.all?(items, &truthy?/1)
  defp builtin_all([{:generator, items}]), do: Enum.all?(items, &truthy?/1)

  defp builtin_all([{:range, _, _, _} = r]) do
    case range_to_list(r) do
      {:exception, _} = err -> err
      list -> Enum.all?(list, &truthy?/1)
    end
  end

  defp builtin_all([val]), do: {:iter_all, val}

  @spec builtin_map([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_map([{:builtin, func} | iterables]) when iterables != [] do
    case collect_iterables(iterables) do
      {:ok, lists} -> map_with_func(func, lists)
      {:exception, _} = e -> e
    end
  end

  defp builtin_map([{:builtin_type, _, func} | iterables]) when iterables != [] do
    case collect_iterables(iterables) do
      {:ok, lists} -> map_with_func(func, lists)
      {:exception, _} = e -> e
    end
  end

  defp builtin_map([{:function, _, _, _, _, _, _} = func | iterables]) when iterables != [] do
    case collect_iterables(iterables) do
      {:ok, lists} -> {:map_call, func, zip_truncate(lists)}
      {:exception, _} = e -> e
    end
  end

  defp builtin_map([{:builtin_kw, func} | iterables]) when iterables != [] do
    case collect_iterables(iterables) do
      {:ok, lists} ->
        map_with_func(fn args -> func.(args, %{}) end, lists)

      {:exception, _} = e ->
        e
    end
  end

  # `functools.partial`, lru-cached functions, classes, and instances
  # (callable via __call__) are all valid map()/filter() targets in
  # CPython.  Defer them to the interpreter via :map_call so the full
  # invocation pipeline applies.
  defp builtin_map([{:partial, _, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:lru_cached_function, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:class, _, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:exception_class, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:instance, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:bound_method, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([{:bound_method, _, _, _} = func | iterables]) when iterables != [] do
    map_call_defer(func, iterables)
  end

  defp builtin_map([_func | _]),
    do: {:exception, "TypeError: map() first arg must be callable"}

  defp builtin_map(_),
    do: {:exception, "TypeError: map() requires at least 2 arguments"}

  @spec map_call_defer(Interpreter.pyvalue(), [Interpreter.pyvalue()]) ::
          Interpreter.builtin_signal() | {:exception, String.t()}
  defp map_call_defer(func, iterables) do
    case collect_iterables(iterables) do
      {:ok, lists} -> {:map_call, func, zip_truncate(lists)}
      {:exception, _} = e -> e
    end
  end

  @spec collect_iterables([Interpreter.pyvalue()]) ::
          {:ok, [[Interpreter.pyvalue()]]} | {:exception, String.t()}
  defp collect_iterables(iterables) do
    Enum.reduce_while(iterables, {:ok, []}, fn iter, {:ok, acc} ->
      case to_list(iter) do
        {:ok, list} -> {:cont, {:ok, [list | acc]}}
        :error -> {:halt, {:exception, "TypeError: map() argument is not iterable"}}
      end
    end)
    |> case do
      {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
      err -> err
    end
  end

  @spec zip_truncate([[Interpreter.pyvalue()]]) :: [[Interpreter.pyvalue()]]
  defp zip_truncate([]), do: []
  defp zip_truncate([single]), do: Enum.map(single, &[&1])

  defp zip_truncate(lists) do
    lists
    |> Enum.zip()
    |> Enum.map(&Tuple.to_list/1)
  end

  @spec map_with_func(
          ([Interpreter.pyvalue()] -> Interpreter.pyvalue()),
          [[Interpreter.pyvalue()]]
        ) :: [Interpreter.pyvalue()] | {:exception, String.t()}
  defp map_with_func(func, lists) do
    grouped = zip_truncate(lists)

    result =
      Enum.reduce_while(grouped, [], fn args, acc ->
        case func.(args) do
          {:exception, _} = exc -> {:halt, exc}
          val -> {:cont, [val | acc]}
        end
      end)

    case result do
      {:exception, _} = exc -> exc
      acc -> Enum.reverse(acc)
    end
  end

  @spec builtin_filter([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_filter([nil, list]) when is_list(list) do
    Enum.filter(list, &truthy?/1)
  end

  defp builtin_filter([nil, {:py_list, reversed, _}]) do
    Enum.filter(Enum.reverse(reversed), &truthy?/1)
  end

  defp builtin_filter([nil, iterable]) do
    case to_list(iterable) do
      {:ok, items} -> Enum.filter(items, &truthy?/1)
      :error -> {:exception, "TypeError: filter() argument is not iterable"}
    end
  end

  defp builtin_filter([{:builtin, func}, list]) when is_list(list) do
    filter_with_func(func, list)
  end

  defp builtin_filter([{:builtin_type, _, func}, list]) when is_list(list) do
    filter_with_func(func, list)
  end

  defp builtin_filter([{:function, _, _, _, _, _, _} = func, list]) when is_list(list) do
    {:filter_call, func, list}
  end

  defp builtin_filter([func, iterable]) do
    case to_list(iterable) do
      {:ok, items} -> builtin_filter([func, items])
      :error -> {:exception, "TypeError: filter() argument is not iterable"}
    end
  end

  @spec filter_with_func(
          ([Interpreter.pyvalue()] -> Interpreter.pyvalue()),
          [Interpreter.pyvalue()]
        ) :: [Interpreter.pyvalue()] | {:exception, String.t()}
  defp filter_with_func(func, list) do
    result =
      Enum.reduce_while(list, [], fn item, acc ->
        case func.([item]) do
          {:exception, _} = exc -> {:halt, exc}
          val -> if truthy?(val), do: {:cont, [item | acc]}, else: {:cont, acc}
        end
      end)

    case result do
      {:exception, _} = exc -> exc
      acc -> Enum.reverse(acc)
    end
  end

  @spec builtin_chr([Interpreter.pyvalue()]) :: String.t() | {:exception, String.t()}
  defp builtin_chr([val]) when is_boolean(val), do: builtin_chr([bool_to_int(val)])

  defp builtin_chr([val]) when is_integer(val) and val >= 0 and val <= 0x10FFFF do
    <<val::utf8>>
  end

  defp builtin_chr([val]) when is_integer(val) do
    {:exception, "ValueError: chr() arg not in range(0x110000)"}
  end

  defp builtin_chr([_]), do: {:exception, "TypeError: an integer is required"}

  @spec builtin_ord([Interpreter.pyvalue()]) :: integer() | {:exception, String.t()}
  defp builtin_ord([val]) when is_binary(val) do
    case String.to_charlist(val) do
      [cp] ->
        cp

      _ ->
        {:exception,
         "TypeError: ord() expected a character, but string of length #{String.length(val)} found"}
    end
  end

  defp builtin_ord([_]), do: {:exception, "TypeError: ord() expected string of length 1"}

  @spec builtin_hex([Interpreter.pyvalue()]) :: String.t() | {:exception, String.t()}
  defp builtin_hex([val]) when is_boolean(val), do: builtin_hex([bool_to_int(val)])

  defp builtin_hex([val]) when is_integer(val) and val >= 0,
    do: ("0x" <> Integer.to_string(val, 16)) |> String.downcase()

  defp builtin_hex([val]) when is_integer(val),
    do: ("-0x" <> Integer.to_string(abs(val), 16)) |> String.downcase()

  defp builtin_hex([_]),
    do: {:exception, "TypeError: 'float' object cannot be interpreted as an integer"}

  @spec builtin_oct([Interpreter.pyvalue()]) :: String.t() | {:exception, String.t()}
  defp builtin_oct([val]) when is_boolean(val), do: builtin_oct([bool_to_int(val)])
  defp builtin_oct([val]) when is_integer(val) and val >= 0, do: "0o" <> Integer.to_string(val, 8)
  defp builtin_oct([val]) when is_integer(val), do: "-0o" <> Integer.to_string(abs(val), 8)

  defp builtin_oct([_]),
    do: {:exception, "TypeError: 'float' object cannot be interpreted as an integer"}

  @spec builtin_bin([Interpreter.pyvalue()]) :: String.t() | {:exception, String.t()}
  defp builtin_bin([val]) when is_boolean(val), do: builtin_bin([bool_to_int(val)])
  defp builtin_bin([val]) when is_integer(val) and val >= 0, do: "0b" <> Integer.to_string(val, 2)
  defp builtin_bin([val]) when is_integer(val), do: "-0b" <> Integer.to_string(abs(val), 2)

  defp builtin_bin([_]),
    do: {:exception, "TypeError: 'float' object cannot be interpreted as an integer"}

  @spec builtin_pow([Interpreter.pyvalue()]) :: number() | {:exception, String.t()}
  defp builtin_pow(args) when is_list(args) do
    coerced =
      Enum.map(args, fn
        v when is_boolean(v) -> bool_to_int(v)
        v -> v
      end)

    builtin_pow_dispatch(coerced)
  end

  defp builtin_pow_dispatch([base, exp]) when is_integer(base) and is_integer(exp) and exp >= 0,
    do: int_pow(base, exp)

  defp builtin_pow_dispatch([base, exp]) when is_number(base) and is_number(exp),
    do: :math.pow(base, exp)

  defp builtin_pow_dispatch([base, exp, mod])
       when is_integer(base) and is_integer(exp) and is_integer(mod) and mod != 0 do
    mod_pow(base, exp, mod)
  end

  defp builtin_pow_dispatch([_, _, 0]),
    do: {:exception, "ValueError: pow() 3rd argument cannot be 0"}

  defp builtin_pow_dispatch(_),
    do: {:exception, "TypeError: unsupported operand type(s) for pow()"}

  @spec int_pow(integer(), non_neg_integer()) :: integer()
  defp int_pow(_base, 0), do: 1
  defp int_pow(base, 1), do: base

  defp int_pow(base, exp) when rem(exp, 2) == 0 do
    half = int_pow(base, div(exp, 2))
    half * half
  end

  defp int_pow(base, exp) do
    base * int_pow(base, exp - 1)
  end

  @spec builtin_divmod([Interpreter.pyvalue()]) ::
          {:tuple, [integer()]} | {:exception, String.t()}
  defp builtin_divmod([a, b]) when is_boolean(a) or is_boolean(b),
    do: builtin_divmod([bool_to_int(a), bool_to_int(b)])

  defp builtin_divmod([_, b]) when b == 0 or b == 0.0,
    do: {:exception, "ZeroDivisionError: integer division or modulo by zero"}

  defp builtin_divmod([a, b]) when is_integer(a) and is_integer(b) do
    q = Integer.floor_div(a, b)
    r = a - q * b
    {:tuple, [q, r]}
  end

  defp builtin_divmod([a, b]) when is_number(a) and is_number(b) do
    q = Float.floor(a / b)
    r = a - q * b
    {:tuple, [q, r]}
  end

  defp builtin_divmod([{:pyex_decimal, _} = a, b]) when is_integer(b),
    do: builtin_divmod([a, {:pyex_decimal, Decimal.new(b)}])

  defp builtin_divmod([a, {:pyex_decimal, _} = b]) when is_integer(a),
    do: builtin_divmod([{:pyex_decimal, Decimal.new(a)}, b])

  defp builtin_divmod([{:pyex_decimal, da}, {:pyex_decimal, db}]) do
    cond do
      # CPython: `divmod(a, 0)` always raises InvalidOperation, never
      # DivisionByZero, because the remainder half of the pair signals
      # InvalidOperation on a zero divisor.
      Decimal.equal?(db, Decimal.new(0)) ->
        {:exception, "InvalidOperation: divmod undefined for zero divisor"}

      true ->
        try do
          # Decimal divmod uses truncation + sign-of-dividend remainder
          # (IEEE / IBM decimal-arithmetic spec), matching CPython.
          # CPython normalises the integer quotient to exp 0, and the
          # remainder to min(exp_a, exp_b). We mirror via rescale, and
          # compute the remainder under a tall context so `q * b` does
          # not drop trailing digits.
          q = Decimal.div_int(da, db) |> normalize_exp(0)

          digits_a = integer_digit_count(da.coef) + max(-da.exp, 0)
          digits_b = integer_digit_count(db.coef) + max(-db.exp, 0)
          needed = digits_a + digits_b + 10
          ctx = Decimal.Context.get()
          high_prec = %{ctx | precision: max(ctx.precision, needed)}
          r_raw = Decimal.Context.with(high_prec, fn -> Decimal.rem(da, db) end)

          target_exp = min(da.exp, db.exp)
          r = normalize_exp(r_raw, target_exp)

          {:tuple, [{:pyex_decimal, q}, {:pyex_decimal, r}]}
        rescue
          Decimal.Error ->
            {:exception, "InvalidOperation: invalid divmod operation"}
        end
    end
  end

  defp integer_digit_count(:NaN), do: 0
  defp integer_digit_count(:inf), do: 0
  defp integer_digit_count(0), do: 1
  defp integer_digit_count(n) when is_integer(n), do: length(Integer.digits(abs(n)))

  defp normalize_exp(%Decimal{coef: :NaN} = d, _), do: d
  defp normalize_exp(%Decimal{coef: :inf} = d, _), do: d
  defp normalize_exp(%Decimal{exp: e} = d, target) when e == target, do: d

  defp normalize_exp(%Decimal{sign: s, coef: 0}, target),
    do: %Decimal{sign: s, coef: 0, exp: target}

  defp normalize_exp(%Decimal{sign: s, coef: coef, exp: e}, target) when e > target do
    diff = e - target
    factor = :erlang.round(:math.pow(10, diff))
    %Decimal{sign: s, coef: coef * factor, exp: target}
  end

  defp normalize_exp(d, _), do: d

  @spec builtin_repr([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_repr([{:instance, _, _} = inst]), do: {:dunder_call, inst, "__repr__", []}

  defp builtin_repr([val]) when is_list(val) do
    {:ctx_call, fn env, ctx -> Pyex.Interpreter.Protocols.eval_py_repr(val, env, ctx) end}
  end

  defp builtin_repr([{:py_list, _, _} = val]) do
    {:ctx_call, fn env, ctx -> Pyex.Interpreter.Protocols.eval_py_repr(val, env, ctx) end}
  end

  defp builtin_repr([{:tuple, _} = val]) do
    {:ctx_call, fn env, ctx -> Pyex.Interpreter.Protocols.eval_py_repr(val, env, ctx) end}
  end

  defp builtin_repr([{:py_dict, _, _} = val]) do
    {:ctx_call, fn env, ctx -> Pyex.Interpreter.Protocols.eval_py_repr(val, env, ctx) end}
  end

  defp builtin_repr([val]), do: py_repr_quoted(val)

  # ascii(obj) == repr(obj) with all non-ASCII chars backslash-escaped.
  @spec builtin_ascii([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_ascii([{:instance, _, _} = inst]) do
    {:ctx_call,
     fn env, ctx ->
       {repr, env, ctx} = Pyex.Interpreter.Protocols.eval_py_repr(inst, env, ctx)
       {ascii_escape(repr), env, ctx}
     end}
  end

  defp builtin_ascii([val]) do
    case builtin_repr([val]) do
      {:ctx_call, fun} ->
        {:ctx_call,
         fn env, ctx ->
           {repr, env, ctx} = fun.(env, ctx)
           {ascii_escape(repr), env, ctx}
         end}

      repr when is_binary(repr) ->
        ascii_escape(repr)
    end
  end

  @spec ascii_escape(String.t()) :: String.t()
  defp ascii_escape(str) do
    str
    |> String.to_charlist()
    |> Enum.map_join(fn
      cp when cp < 128 -> <<cp::utf8>>
      cp when cp <= 0xFF -> "\\x" <> hex(cp, 2)
      cp when cp <= 0xFFFF -> "\\u" <> hex(cp, 4)
      cp -> "\\U" <> hex(cp, 8)
    end)
  end

  defp hex(cp, width),
    do: cp |> Integer.to_string(16) |> String.downcase() |> String.pad_leading(width, "0")

  @spec builtin_hasattr([Interpreter.pyvalue()]) :: boolean()
  defp builtin_hasattr([{:instance, {:class, _, _, _} = class, attrs}, attr])
       when is_binary(attr) do
    Map.has_key?(attrs, attr) or has_class_attr?(class, attr) or
      has_class_attr?(class, "__getattr__")
  end

  defp builtin_hasattr([{:class, _, _, _} = class, attr])
       when is_binary(attr) do
    attr in [
      "__mro__",
      "__bases__",
      "__name__",
      "__qualname__",
      "__module__",
      "__doc__",
      "__dict__",
      "__class__"
    ] or
      has_class_attr?(class, attr)
  end

  defp builtin_hasattr([{:module, _, attrs}, attr]) when is_binary(attr) do
    attr in ["__name__", "__class__", "__dict__", "__doc__"] or Map.has_key?(attrs, attr)
  end

  defp builtin_hasattr([{:builtin_type, _, _}, attr]) when is_binary(attr) do
    attr in [
      "__name__",
      "__qualname__",
      "__module__",
      "__class__",
      "__doc__",
      "__mro__",
      "__bases__"
    ]
  end

  defp builtin_hasattr([{:function, _, _, _, _, _, _}, attr]) when is_binary(attr) do
    attr in [
      "__name__",
      "__qualname__",
      "__module__",
      "__doc__",
      "__defaults__",
      "__kwdefaults__",
      "__annotations__",
      "__class__"
    ]
  end

  defp builtin_hasattr([{:func_with_attrs, _, attrs}, attr]) when is_binary(attr) do
    Map.has_key?(attrs, attr) or
      attr in [
        "__name__",
        "__qualname__",
        "__module__",
        "__doc__",
        "__defaults__",
        "__kwdefaults__",
        "__annotations__",
        "__class__"
      ]
  end

  defp builtin_hasattr([{:py_dict, _, _} = dict, attr]) when is_binary(attr) do
    PyDict.has_key?(dict, attr)
  end

  defp builtin_hasattr([obj, attr]) when is_map(obj) and is_binary(attr) do
    Map.has_key?(obj, attr)
  end

  defp builtin_hasattr([obj, attr]) when is_binary(attr) do
    Pyex.Methods.resolve(obj, attr) != :error
  end

  defp builtin_hasattr([_, _]), do: false

  @spec has_class_attr?(Interpreter.pyvalue(), String.t()) :: boolean()
  defp has_class_attr?({:class, _, bases, class_attrs}, attr) do
    Map.has_key?(class_attrs, attr) or Enum.any?(bases, &has_class_attr?(&1, attr))
  end

  defp has_class_attr?({:exception_class, _name}, attr) do
    # Standard dunders that every exception instance exposes.
    attr in [
      "args",
      "__init__",
      "__str__",
      "__repr__",
      "__cause__",
      "__context__",
      "__traceback__",
      "__suppress_context__",
      "__class__"
    ]
  end

  defp has_class_attr?(_, _), do: false

  @spec find_class_attr(Interpreter.pyvalue(), String.t()) ::
          {:ok, Interpreter.pyvalue()} | :error
  defp find_class_attr({:class, _, bases, class_attrs}, attr) do
    case Map.fetch(class_attrs, attr) do
      {:ok, _val} = found -> found
      :error -> Enum.find_value(bases, :error, &find_class_attr(&1, attr))
    end
  end

  defp find_class_attr(_, _), do: :error

  @spec class_getattr(Interpreter.pyvalue(), String.t()) ::
          {:ok, Interpreter.pyvalue()} | :error
  defp class_getattr({:class, _, _, _} = class, attr) do
    Pyex.Interpreter.ClassLookup.class_attribute(class, attr)
  end

  @spec module_getattr(Interpreter.pyvalue(), String.t()) ::
          {:ok, Interpreter.pyvalue()} | :error
  defp module_getattr({:module, name, attrs}, attr) do
    Pyex.Interpreter.module_attr(name, attrs, attr)
  end

  @spec builtin_getattr([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_getattr([{:instance, {:class, name, _, _} = class, attrs}, attr])
       when is_binary(attr) do
    case Map.get(attrs, attr) do
      nil ->
        case find_class_attr(class, attr) do
          {:ok, val} -> val
          :error -> {:exception, "AttributeError: '#{name}' object has no attribute '#{attr}'"}
        end

      val ->
        val
    end
  end

  defp builtin_getattr([{:instance, class, attrs}, attr, default]) when is_binary(attr) do
    case Map.get(attrs, attr) do
      nil ->
        case find_class_attr(class, attr) do
          {:ok, val} -> val
          :error -> default
        end

      val ->
        val
    end
  end

  defp builtin_getattr([{:class, _, _, _} = class, attr]) when is_binary(attr) do
    case class_getattr(class, attr) do
      {:ok, val} ->
        val

      :error ->
        {:class, class_name, _, _} = class
        {:exception, "AttributeError: type object '#{class_name}' has no attribute '#{attr}'"}
    end
  end

  defp builtin_getattr([{:class, _, _, _} = class, attr, default]) when is_binary(attr) do
    case class_getattr(class, attr) do
      {:ok, val} -> val
      :error -> default
    end
  end

  defp builtin_getattr([{:module, _, attrs} = mod, attr]) when is_binary(attr) do
    case module_getattr(mod, attr) do
      {:ok, val} ->
        val

      :error ->
        {:module, name, _} = mod
        _ = attrs
        {:exception, "AttributeError: module '#{name}' has no attribute '#{attr}'"}
    end
  end

  defp builtin_getattr([{:module, _, _} = mod, attr, default]) when is_binary(attr) do
    case module_getattr(mod, attr) do
      {:ok, val} -> val
      :error -> default
    end
  end

  defp builtin_getattr([{:builtin_type, name, _}, attr]) when is_binary(attr) do
    case Pyex.Interpreter.builtin_type_attr(name, attr) do
      {:ok, val} -> val
      :error -> {:exception, "AttributeError: type object '#{name}' has no attribute '#{attr}'"}
    end
  end

  defp builtin_getattr([{:builtin_type, name, _}, attr, default]) when is_binary(attr) do
    case Pyex.Interpreter.builtin_type_attr(name, attr) do
      {:ok, val} -> val
      :error -> default
    end
  end

  defp builtin_getattr([{:function, _, _, _, _, _, _} = f, attr]) when is_binary(attr) do
    case Pyex.Interpreter.Helpers.function_attr(f, attr) do
      {:ok, val} -> val
      :error -> {:exception, "AttributeError: function has no attribute '#{attr}'"}
    end
  end

  defp builtin_getattr([{:function, _, _, _, _, _, _} = f, attr, default]) when is_binary(attr) do
    case Pyex.Interpreter.Helpers.function_attr(f, attr) do
      {:ok, val} -> val
      :error -> default
    end
  end

  defp builtin_getattr([{:func_with_attrs, _, attrs} = f, attr]) when is_binary(attr) do
    case Map.fetch(attrs, attr) do
      {:ok, val} ->
        val

      :error ->
        case Pyex.Interpreter.Helpers.function_attr(f, attr) do
          {:ok, val} -> val
          :error -> {:exception, "AttributeError: function has no attribute '#{attr}'"}
        end
    end
  end

  defp builtin_getattr([{:func_with_attrs, _, attrs} = f, attr, default]) when is_binary(attr) do
    case Map.fetch(attrs, attr) do
      {:ok, val} ->
        val

      :error ->
        case Pyex.Interpreter.Helpers.function_attr(f, attr) do
          {:ok, val} -> val
          :error -> default
        end
    end
  end

  defp builtin_getattr([{:py_dict, _, _} = dict, attr]) when is_binary(attr) do
    case PyDict.fetch(dict, attr) do
      {:ok, val} -> val
      :error -> {:exception, "AttributeError: 'dict' object has no attribute '#{attr}'"}
    end
  end

  defp builtin_getattr([{:py_dict, _, _} = dict, attr, default]) when is_binary(attr) do
    PyDict.get(dict, attr, default)
  end

  defp builtin_getattr([obj, attr]) when is_map(obj) and is_binary(attr) do
    case Map.get(obj, attr) do
      nil -> {:exception, "AttributeError: 'dict' object has no attribute '#{attr}'"}
      val -> val
    end
  end

  defp builtin_getattr([obj, attr, default]) when is_map(obj) and is_binary(attr) do
    Map.get(obj, attr, default)
  end

  defp builtin_getattr([obj, attr]) when is_binary(attr) do
    {:exception, "AttributeError: '#{pytype(obj)}' object has no attribute '#{attr}'"}
  end

  @spec builtin_setattr([Interpreter.pyvalue()]) ::
          {:mutate, Interpreter.pyvalue(), nil} | {:exception, String.t()}
  defp builtin_setattr([{:instance, class, attrs}, attr, value]) when is_binary(attr) do
    {:mutate, {:instance, class, Map.put(attrs, attr, value)}, nil}
  end

  defp builtin_setattr([_, attr, _]) when is_binary(attr) do
    {:exception, "AttributeError: cannot set attribute '#{attr}'"}
  end

  @spec builtin_delattr([Interpreter.pyvalue()]) ::
          {:mutate, Interpreter.pyvalue(), nil} | {:exception, String.t()}
  defp builtin_delattr([{:instance, class, attrs}, attr]) when is_binary(attr) do
    if Map.has_key?(attrs, attr) do
      {:mutate, {:instance, class, Map.delete(attrs, attr)}, nil}
    else
      {:exception, "AttributeError: #{attr}"}
    end
  end

  defp builtin_delattr([_, attr]) when is_binary(attr),
    do: {:exception, "AttributeError: cannot delete attribute '#{attr}'"}

  @spec builtin_super([Interpreter.pyvalue()]) ::
          {:super_call} | {:super_call, Interpreter.pyvalue(), Interpreter.pyvalue()}
  defp builtin_super([]), do: {:super_call}
  defp builtin_super([type, obj]), do: {:super_call, type, obj}

  defp builtin_super([_type]),
    do: {:exception, "TypeError: super() with a single argument (unbound) is not supported"}

  @spec builtin_callable([Interpreter.pyvalue()]) :: boolean()
  defp builtin_callable([{:builtin, _}]), do: true
  defp builtin_callable([{:builtin_kw, _}]), do: true
  defp builtin_callable([{:builtin_type, _, _}]), do: true
  defp builtin_callable([{:function, _, _, _, _, _, _}]), do: true
  defp builtin_callable([{:class, _, _, _}]), do: true
  defp builtin_callable([{:bound_method, _, _}]), do: true
  defp builtin_callable([{:bound_method, _, _, _}]), do: true

  defp builtin_callable([{:instance, {:class, _, _, class_attrs}, _}]) do
    Map.has_key?(class_attrs, "__call__")
  end

  defp builtin_callable([_]), do: false

  @spec builtin_dir([Interpreter.pyvalue()]) :: [String.t()]
  defp builtin_dir([{:instance, {:class, _, bases, class_attrs}, instance_attrs}]) do
    inherited = collect_inherited_attrs(bases)
    visible_class = Pyex.Interpreter.ClassLookup.visible_attrs(class_attrs)
    visible_inherited = Pyex.Interpreter.ClassLookup.visible_attrs(inherited)

    all_keys =
      Map.keys(visible_class) ++ Map.keys(instance_attrs) ++ Map.keys(visible_inherited)

    all_keys |> Enum.uniq() |> Enum.sort()
  end

  defp builtin_dir([{:class, _, bases, class_attrs}]) do
    inherited = collect_inherited_attrs(bases)
    visible_class = Pyex.Interpreter.ClassLookup.visible_attrs(class_attrs)
    visible_inherited = Pyex.Interpreter.ClassLookup.visible_attrs(inherited)
    all_keys = Map.keys(visible_class) ++ Map.keys(visible_inherited)
    all_keys |> Enum.uniq() |> Enum.sort()
  end

  defp builtin_dir([{:module, _, attrs}]) do
    attrs |> Map.keys() |> Enum.sort()
  end

  defp builtin_dir([{:py_dict, _, _} = dict]) do
    own_keys = dict |> PyDict.keys() |> Enum.filter(&is_binary/1)
    methods = Pyex.Methods.method_names(dict)
    (own_keys ++ methods) |> Enum.uniq() |> Enum.sort()
  end

  defp builtin_dir([val]) when is_map(val) do
    own_keys = val |> Map.keys() |> Enum.filter(&is_binary/1)
    methods = Pyex.Methods.method_names(val)
    (own_keys ++ methods) |> Enum.uniq() |> Enum.sort()
  end

  defp builtin_dir([val]) do
    Pyex.Methods.method_names(val) |> Enum.sort()
  end

  # dir() with no argument lists the names in the *current* scope, sorted — like
  # CPython's locals()/globals() keys. Needs the env, so it defers. pyex seeds
  # builtins into the global scope, so they are subtracted to match CPython,
  # which keeps builtins in a separate namespace dir() does not show.
  defp builtin_dir([]) do
    {:ctx_call,
     fn env, ctx ->
       builtin_names = names()

       scope_names =
         env.scopes
         |> List.first(%{})
         |> Map.keys()
         |> Enum.reject(&(&1 in builtin_names or internal_scope_name?(&1)))
         |> Enum.sort()

       {scope_names, env, ctx}
     end}
  end

  # locals(): the current (innermost) scope as a dict. globals(): the module
  # scope (bottom of the stack). Builtins are seeded into the global scope, so
  # they're subtracted to match CPython's separate-namespace model.
  defp builtin_locals([]) do
    {:ctx_call, fn env, ctx -> {scope_to_dict(List.first(env.scopes, %{}), ctx), env, ctx} end}
  end

  defp builtin_globals([]) do
    {:ctx_call, fn env, ctx -> {scope_to_dict(List.last(env.scopes) || %{}, ctx), env, ctx} end}
  end

  @spec scope_to_dict(map(), Ctx.t()) :: Interpreter.pyvalue()
  defp scope_to_dict(scope, ctx) do
    builtin_names = names()

    scope
    |> Enum.reject(fn {k, _v} -> k in builtin_names or internal_scope_name?(k) end)
    |> Enum.map(fn {k, v} -> {k, Ctx.deref(ctx, v)} end)
    |> PyDict.from_pairs()
  end

  # Pyex injects a few sentinel bindings into scopes (e.g. the active exception);
  # they are interpreter internals, not user names, so dir() must not surface them.
  @spec internal_scope_name?(String.t()) :: boolean()
  defp internal_scope_name?(name), do: name == "__current_exception__"

  @spec collect_inherited_attrs([Interpreter.pyvalue()]) :: %{
          optional(String.t()) => Interpreter.pyvalue()
        }
  defp collect_inherited_attrs(bases) do
    Enum.reduce(bases, %{}, fn
      {:class, _, parent_bases, attrs}, acc ->
        parent = collect_inherited_attrs(parent_bases)
        Map.merge(parent, Map.merge(attrs, acc))

      _, acc ->
        acc
    end)
  end

  @spec builtin_vars([Interpreter.pyvalue()]) :: %{optional(String.t()) => Interpreter.pyvalue()}
  defp builtin_vars([{:instance, _class, attrs}]), do: attrs

  defp builtin_vars([{:class, _, _bases, attrs}]),
    do: Pyex.Interpreter.ClassLookup.visible_attrs(attrs)

  defp builtin_vars([{:module, _, attrs}]), do: attrs

  defp builtin_vars([{:py_dict, _, _} = dict]), do: dict
  defp builtin_vars([val]) when is_map(val), do: val

  defp builtin_vars([other]) do
    type_name = pytype(other)
    {:exception, "TypeError: vars() argument must have __dict__ attribute (got #{type_name})"}
  end

  defp builtin_vars([]) do
    {:exception, "TypeError: vars expected at most 1 argument, got 0"}
  end

  # `__import__(name)` resolves a module by name and returns it. As in
  # CPython, a dotted name without a fromlist yields the top-level package
  # (e.g. `__import__("os.path")` returns the `os` module).
  @spec builtin_import([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_import([name | _rest]) when is_binary(name) do
    {:ctx_call,
     fn env, ctx ->
       root = name |> String.split(".") |> hd()

       case Pyex.Interpreter.Import.resolve_module(root, env, ctx) do
         {:ok, module_value, ctx} ->
           {module_value, env, ctx}

         {:import_error, msg, ctx} ->
           {{:exception, msg}, env, ctx}

         {:unknown_module, ctx} ->
           {{:exception, "ModuleNotFoundError: No module named '#{root}'"}, env, ctx}
       end
     end}
  end

  defp builtin_import(_args) do
    {:exception, "TypeError: __import__() argument 1 must be str"}
  end

  # format(value[, spec]) delegates to the same spec engine f-strings use.
  @spec builtin_format([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_format([val]), do: builtin_format([val, ""])

  # Instances go through the full __format__ protocol (needs the interpreter
  # for method dispatch); everything else uses the pure format mini-language.
  defp builtin_format([{:instance, _, _} = val, spec]) when is_binary(spec),
    do: {:ctx_call, fn env, ctx -> Interpreter.format_with_protocol(val, spec, env, ctx) end}

  defp builtin_format([val, spec]) when is_binary(spec),
    do: FstringFormat.apply_format_spec(val, spec)

  defp builtin_format(_), do: {:exception, "TypeError: format() takes 1 or 2 arguments"}

  @spec builtin_object([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  defp builtin_object([]) do
    {:object, :erlang.unique_integer()}
  end

  defp builtin_object(_args) do
    {:exception, "TypeError: object() takes no arguments"}
  end

  @doc false
  @spec builtin_property([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  def builtin_property([fget]), do: {:property, fget, nil, nil}
  def builtin_property([fget, fset]), do: {:property, fget, fset, nil}
  def builtin_property([fget, fset, fdel]), do: {:property, fget, fset, fdel}
  def builtin_property([]), do: {:property, nil, nil, nil}

  def builtin_property(_),
    do: {:exception, "TypeError: property() takes at most 3 arguments"}

  @doc false
  @spec builtin_staticmethod([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  def builtin_staticmethod([func]), do: {:staticmethod, func}

  def builtin_staticmethod(_),
    do: {:exception, "TypeError: staticmethod() takes exactly 1 argument"}

  @doc false
  @spec builtin_classmethod([Interpreter.pyvalue()]) :: Interpreter.pyvalue()
  def builtin_classmethod([func]), do: {:classmethod, func}

  def builtin_classmethod(_),
    do: {:exception, "TypeError: classmethod() takes exactly 1 argument"}

  @spec builtin_id([Interpreter.pyvalue()]) :: integer()
  defp builtin_id([val]) do
    :erlang.phash2(val)
  end

  @spec builtin_hash([Interpreter.pyvalue()]) :: integer() | {:exception, String.t()}
  defp builtin_hash([val]) when is_integer(val), do: val
  defp builtin_hash([val]) when is_float(val), do: :erlang.phash2(val)
  defp builtin_hash([val]) when is_binary(val), do: :erlang.phash2(val)
  defp builtin_hash([true]), do: 1
  defp builtin_hash([false]), do: 0
  defp builtin_hash([nil]), do: 0
  defp builtin_hash([{:tuple, items}]), do: :erlang.phash2(items)
  defp builtin_hash([{:frozenset, s}]), do: :erlang.phash2(MapSet.to_list(s))
  defp builtin_hash([{:object, id}]), do: id

  defp builtin_hash([{:pyex_decimal, %Decimal{coef: :NaN}}]),
    do: {:exception, "TypeError: Cannot hash a NaN value"}

  defp builtin_hash([{:pyex_decimal, d}]) do
    # CPython requires hash(Decimal(n)) == hash(n) for any integer-valued
    # Decimal n, so dict/set lookups interoperate across the int / Decimal
    # / bool boundary. For non-integer Decimals we hash a normalized form
    # so that hash(Decimal('1.5')) == hash(Decimal('1.500')).
    if Decimal.integer?(d) do
      Decimal.to_integer(d)
    else
      n = Decimal.normalize(d)
      :erlang.phash2({n.sign, n.coef, n.exp})
    end
  end

  defp builtin_hash([{:py_list, _, _}]),
    do: {:exception, "TypeError: unhashable type: 'list'"}

  defp builtin_hash([{:py_dict, _, _}]),
    do: {:exception, "TypeError: unhashable type: 'dict'"}

  defp builtin_hash([{:set, _}]),
    do: {:exception, "TypeError: unhashable type: 'set'"}

  defp builtin_hash([val]) when is_map(val),
    do: {:exception, "TypeError: unhashable type: 'dict'"}

  defp builtin_hash([val]), do: :erlang.phash2(val)

  @spec mod_pow(integer(), integer(), integer()) :: integer() | {:exception, String.t()}
  defp mod_pow(_base, _exp, 1), do: 0

  defp mod_pow(base, exp, mod) when exp >= 0 do
    base = rem(base, mod)
    do_mod_pow(base, exp, mod, 1)
  end

  # Negative exponent (Python 3.8+): pow(b, -e, m) == pow(b**-1 mod m, e, m).
  defp mod_pow(base, exp, mod) do
    case mod_inverse(base, mod) do
      {:ok, inv} -> mod_pow(inv, -exp, mod)
      :error -> {:exception, "ValueError: base is not invertible for the given modulus"}
    end
  end

  @spec mod_inverse(integer(), integer()) :: {:ok, integer()} | :error
  defp mod_inverse(a, m) do
    a = Integer.mod(a, m)
    {g, x, _y} = ext_gcd(a, m)
    if g == 1, do: {:ok, Integer.mod(x, m)}, else: :error
  end

  @spec ext_gcd(integer(), integer()) :: {integer(), integer(), integer()}
  defp ext_gcd(0, b), do: {b, 0, 1}

  defp ext_gcd(a, b) do
    {g, x, y} = ext_gcd(Integer.mod(b, a), a)
    {g, y - div(b, a) * x, x}
  end

  @spec do_mod_pow(integer(), integer(), integer(), integer()) :: integer()
  defp do_mod_pow(_base, 0, _mod, result), do: result

  defp do_mod_pow(base, exp, mod, result) do
    result = if rem(exp, 2) == 1, do: rem(result * base, mod), else: result
    base = rem(base * base, mod)
    do_mod_pow(base, div(exp, 2), mod, result)
  end

  @spec builtin_exec([Interpreter.pyvalue()]) ::
          Interpreter.builtin_signal() | {:exception, String.t()}
  defp builtin_exec([code]) when is_binary(code) do
    {:ctx_call,
     fn env, ctx ->
       case do_eval_or_exec(code, env, ctx, :exec) do
         {:ok, _val, env, ctx} -> {nil, env, ctx}
         {:error, msg, env, ctx} -> {{:exception, msg}, env, ctx}
       end
     end}
  end

  defp builtin_exec(_),
    do: {:exception, "TypeError: exec() expects a string of source code"}

  @spec builtin_eval([Interpreter.pyvalue()]) ::
          Interpreter.builtin_signal() | {:exception, String.t()}
  defp builtin_eval([code]) when is_binary(code) do
    {:ctx_call,
     fn env, ctx ->
       case do_eval_or_exec(code, env, ctx, :eval) do
         {:ok, val, env, ctx} -> {val, env, ctx}
         {:error, msg, env, ctx} -> {{:exception, msg}, env, ctx}
       end
     end}
  end

  defp builtin_eval(_),
    do: {:exception, "TypeError: eval() expects a string expression"}

  # Executes `code` in the context of `env`/`ctx`.  For :eval, the code
  # must be a single expression and its value is returned.  For :exec,
  # the code may be any statements; we return `nil` as the result.
  @spec do_eval_or_exec(String.t(), Env.t(), Ctx.t(), :eval | :exec) ::
          {:ok, Interpreter.pyvalue(), Env.t(), Ctx.t()}
          | {:error, String.t(), Env.t(), Ctx.t()}
  defp do_eval_or_exec(code, env, ctx, mode) do
    with {:ok, tokens} <- Pyex.Lexer.tokenize(code),
         {:ok, {:module, _, statements}} <- Pyex.Parser.parse(tokens) do
      # For :eval, callers expect the expression's value to be returned.
      # For :exec, we discard the final value and return nil upstream.
      _ = mode

      case Interpreter.eval_statements(statements, env, ctx) do
        {{:exception, msg}, _env, ctx} -> {:error, msg, env, ctx}
        {val, new_env, ctx} -> {:ok, val, new_env, ctx}
      end
    else
      {:error, msg} -> {:error, "SyntaxError: #{msg}", env, ctx}
    end
  end

  @spec builtin_compile([Interpreter.pyvalue()]) :: {:exception, String.t()}
  defp builtin_compile(_args) do
    {:exception,
     "NotImplementedError: compile() is not supported. " <>
       "Write your logic directly instead of using dynamic code execution"}
  end

  @spec builtin_complex([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:exception, String.t()}
  defp builtin_complex([]), do: {:complex, 0.0, 0.0}
  defp builtin_complex([r]) when is_number(r), do: {:complex, r * 1.0, 0.0}
  defp builtin_complex([true]), do: {:complex, 1.0, 0.0}
  defp builtin_complex([false]), do: {:complex, 0.0, 0.0}
  defp builtin_complex([{:complex, _, _} = c]), do: c

  defp builtin_complex([r, i]) when is_number(r) and is_number(i),
    do: {:complex, r * 1.0, i * 1.0}

  defp builtin_complex([{:complex, r, i}, {:complex, r2, i2}]),
    do: {:complex, r - i2, i + r2}

  defp builtin_complex([s]) when is_binary(s) do
    # Very small complex-literal parser: accepts "1+2j", "2j", "-3.5j", "1".
    trimmed = s |> String.trim() |> String.replace(~r/\s+/, "") |> strip_digit_underscores()

    cond do
      String.ends_with?(trimmed, "j") or String.ends_with?(trimmed, "J") ->
        parse_complex_with_j(trimmed)

      true ->
        case Float.parse(trimmed) do
          {r, ""} -> {:complex, r, 0.0}
          _ -> {:exception, "ValueError: complex() arg is a malformed string"}
        end
    end
  end

  defp builtin_complex(_),
    do: {:exception, "TypeError: complex() takes at most 2 arguments"}

  @spec parse_complex_with_j(String.t()) :: Interpreter.pyvalue()
  defp parse_complex_with_j(s) do
    # Strip the trailing j/J and look for a split +/- in the middle.
    without_j = String.slice(s, 0, String.length(s) - 1)

    result =
      case split_complex(without_j) do
        {:just_imag, imag_str} ->
          case Float.parse(imag_str) do
            {i, ""} -> {:complex, 0.0, i}
            _ -> :parse_error
          end

        {:both, real_str, imag_str} ->
          with {r, ""} <- Float.parse(real_str),
               {i, ""} <- Float.parse(imag_str) do
            {:complex, r, i}
          else
            _ -> :parse_error
          end
      end

    case result do
      {:complex, _, _} = c -> c
      _ -> {:exception, "ValueError: complex() arg is a malformed string"}
    end
  end

  @spec split_complex(String.t()) ::
          {:just_imag, String.t()} | {:both, String.t(), String.t()}
  defp split_complex(s) do
    # Find a + or - that's not at position 0 and not after `e`/`E`.
    chars = String.to_charlist(s)

    idx =
      Enum.reduce_while(Enum.with_index(chars), :none, fn {c, i}, _ ->
        cond do
          i == 0 -> {:cont, :none}
          c in [?+, ?-] and Enum.at(chars, i - 1) not in [?e, ?E] -> {:halt, i}
          true -> {:cont, :none}
        end
      end)

    case idx do
      :none ->
        imag_part = if s == "" or s == "+" or s == "-", do: s <> "1", else: s
        {:just_imag, imag_part}

      i ->
        {real, imag_rest} = String.split_at(s, i)
        imag = if imag_rest in ["+", "-"], do: imag_rest <> "1", else: imag_rest
        {:both, real, imag}
    end
  end

  @spec builtin_bytes([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:exception, String.t()}
  defp builtin_bytes([]), do: {:bytes, ""}

  defp builtin_bytes([n]) when is_integer(n) and n > 100_000_000,
    do: {:exception, "OverflowError: cannot allocate #{n} bytes (max 100000000)"}

  defp builtin_bytes([n]) when is_integer(n) and n >= 0, do: {:bytes, :binary.copy(<<0>>, n)}
  defp builtin_bytes([n]) when is_integer(n), do: {:exception, "ValueError: negative count"}
  defp builtin_bytes([true]), do: {:bytes, <<0>>}
  defp builtin_bytes([false]), do: {:bytes, ""}

  defp builtin_bytes([{:bytes, b}]), do: {:bytes, b}
  defp builtin_bytes([{:bytearray, b}]), do: {:bytes, b}

  defp builtin_bytes([s]) when is_binary(s) do
    # bytes(str) in CPython requires an encoding; we mirror this by
    # accepting the default UTF-8 encoding when no second arg is given.
    {:exception, "TypeError: string argument without an encoding"}
  end

  # bytes(iterable_of_ints): any iterable of 0..255 ints, via the shared
  # coercion (list/tuple/range/set/dict-keys/generator/bytearray).
  defp builtin_bytes([iterable]) do
    case to_list(iterable) do
      {:ok, items} -> bytes_from_iterable(items)
      :error -> {:exception, "TypeError: cannot convert '#{pytype(iterable)}' object to bytes"}
    end
  end

  defp builtin_bytes([s, encoding]) when is_binary(s) and is_binary(encoding) do
    case String.downcase(encoding) do
      "utf-8" ->
        {:bytes, s}

      "utf8" ->
        {:bytes, s}

      "ascii" ->
        if String.printable?(s, :infinity),
          do: {:bytes, s},
          else: {:exception, "UnicodeEncodeError: 'ascii' codec can't encode character"}

      "latin-1" ->
        {:bytes, s}

      "latin1" ->
        {:bytes, s}

      _ ->
        {:exception, "LookupError: unknown encoding: #{encoding}"}
    end
  end

  defp builtin_bytes(_args) do
    {:exception, "TypeError: cannot convert argument to bytes"}
  end

  @spec bytes_from_iterable([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:exception, String.t()}
  # Decodes a byte string to its latin-1 text (each byte → its codepoint),
  # so int()/float() can parse bytes-like values the way CPython does.
  @spec bytes_to_latin1(binary()) :: String.t()
  defp bytes_to_latin1(bin), do: for(<<c <- bin>>, into: "", do: <<c::utf8>>)

  defp bytes_from_iterable(items) do
    Enum.reduce_while(items, <<>>, fn
      n, acc when is_integer(n) and n >= 0 and n <= 255 -> {:cont, <<acc::binary, n>>}
      true, acc -> {:cont, <<acc::binary, 1>>}
      false, acc -> {:cont, <<acc::binary, 0>>}
      # An out-of-range integer is a ValueError; a non-integer element is a
      # TypeError ("object cannot be interpreted as an integer") in CPython.
      n, _acc when is_integer(n) -> {:halt, {:error, :range}}
      bad, _acc -> {:halt, {:error, {:type, pytype(bad)}}}
    end)
    |> case do
      {:error, :range} ->
        {:exception, "ValueError: bytes must be in range(0, 256)"}

      {:error, {:type, type}} ->
        {:exception, "TypeError: '#{type}' object cannot be interpreted as an integer"}

      b ->
        {:bytes, b}
    end
  end

  @spec builtin_bytearray([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | {:exception, String.t()}
  defp builtin_bytearray(args) do
    case builtin_bytes(args) do
      {:bytes, b} -> {:bytearray, b}
      other -> other
    end
  end

  @spec builtin_memoryview([Interpreter.pyvalue()]) ::
          {:memoryview, binary()} | {:exception, String.t()}
  defp builtin_memoryview([{tag, b}]) when tag in [:bytes, :bytearray, :memoryview],
    do: {:memoryview, b}

  defp builtin_memoryview(_),
    do: {:exception, "TypeError: memoryview: a bytes-like object is required"}

  @doc """
  Returns whether a Python value is truthy.
  """
  @spec truthy?(Interpreter.pyvalue()) :: boolean()
  def truthy?(false), do: false
  def truthy?(nil), do: false
  def truthy?(0), do: false
  def truthy?(+0.0), do: false
  def truthy?(-0.0), do: false
  def truthy?(""), do: false
  def truthy?([]), do: false
  def truthy?({:py_list, _, 0}), do: false
  def truthy?({:py_dict, map, _}) when map == %{}, do: false
  def truthy?({:py_dict, _, _}), do: true
  def truthy?(map) when map == %{}, do: false
  def truthy?({:tuple, []}), do: false
  def truthy?({:deque, _, _, 0, _}), do: false
  def truthy?({:deque, _, _, _, _}), do: true
  def truthy?({:set, s}), do: MapSet.size(s) > 0
  def truthy?({:frozenset, s}), do: MapSet.size(s) > 0

  def truthy?({:range, start, stop, step}),
    do: range_length({:range, start, stop, step}) > 0

  def truthy?({:bytes, b}), do: byte_size(b) > 0
  def truthy?({:bytearray, b}), do: byte_size(b) > 0

  def truthy?({:pyex_decimal, d}), do: not Decimal.equal?(d, Decimal.new(0))
  def truthy?({:complex, r, i}), do: r != 0.0 or i != 0.0

  def truthy?(_), do: true

  @spec pytype(Interpreter.pyvalue()) :: String.t()
  defp pytype(val) when is_integer(val), do: "int"
  defp pytype(val) when is_float(val), do: "float"
  defp pytype(:ellipsis), do: "ellipsis"
  defp pytype(:not_implemented), do: "NotImplementedType"
  defp pytype({:struct_time, _}), do: "struct_time"
  defp pytype({:memoryview, _}), do: "memoryview"
  defp pytype(val) when is_binary(val), do: "str"
  defp pytype(val) when is_boolean(val), do: "bool"
  defp pytype(nil), do: "NoneType"
  defp pytype({:py_list, _, _}), do: "list"
  defp pytype(val) when is_list(val), do: "list"
  defp pytype({:py_dict, _, _}), do: "dict"
  defp pytype(val) when is_map(val), do: "dict"
  defp pytype({:tuple, _}), do: "tuple"
  defp pytype({:set, _}), do: "set"
  defp pytype({:frozenset, _}), do: "frozenset"
  defp pytype({:function, _, _, _, _, _, _}), do: "function"
  defp pytype({:coroutine, _, _}), do: "coroutine"
  defp pytype({:asyncio_task, _}), do: "Task"
  defp pytype({:asyncio_task_pending, _}), do: "Task"
  defp pytype({:builtin, _}), do: "builtin_function_or_method"
  defp pytype({:builtin_kw, _}), do: "builtin_function_or_method"
  defp pytype({:class, name, _, _}), do: name
  defp pytype({:instance, {:class, name, _, _}, _}), do: name
  defp pytype({:deque, _, _, _, _}), do: "deque"
  defp pytype({:range, _, _, _}), do: "range"
  defp pytype({:bound_method, _, _}), do: "method"
  defp pytype({:bound_method, _, _, _}), do: "method"
  defp pytype({:bytes, _}), do: "bytes"
  defp pytype({:bytearray, _}), do: "bytearray"
  defp pytype({:complex, _, _}), do: "complex"
  defp pytype({:module, _, _}), do: "module"
  defp pytype({:generator, _}), do: "generator"
  defp pytype({:generator_error, _, _}), do: "generator"
  defp pytype({:iterator, _}), do: "iterator"
  defp pytype({:pandas_series, _}), do: "Series"
  defp pytype({:pandas_rolling, _, _}), do: "Rolling"
  defp pytype({:pandas_dataframe, _}), do: "DataFrame"
  defp pytype({:pyex_decimal, _}), do: "Decimal"
  defp pytype({:fraction, _, _}), do: "Fraction"
  defp pytype({:property, _, _, _}), do: "property"
  defp pytype({:staticmethod, _}), do: "staticmethod"
  defp pytype({:classmethod, _}), do: "classmethod"
  defp pytype({:partial, _, _, _}), do: "partial"
  defp pytype({:lru_cached_function, _, _}), do: "function"
  defp pytype({:cached_property, _}), do: "cached_property"
  defp pytype({:exception_class, _}), do: "type"
  defp pytype({:super_proxy, _, _}), do: "super"
  defp pytype({:stringio, _}), do: "StringIO"
  defp pytype({:builtin_raw, _}), do: "builtin_function_or_method"
  defp pytype({:builtin_type, _, _}), do: "type"
  defp pytype({:func_with_attrs, _, _}), do: "function"
  defp pytype({:file_handle, _}), do: "file"
  defp pytype(_), do: "object"

  @doc """
  Converts a Python value to its `str()` representation.
  """
  @spec py_repr(Interpreter.pyvalue()) :: String.t()
  def py_repr(nil), do: "None"
  def py_repr(true), do: "True"
  def py_repr(false), do: "False"
  def py_repr(val) when is_binary(val), do: val
  def py_repr(val) when is_integer(val), do: Integer.to_string(val)
  def py_repr(:infinity), do: "inf"
  def py_repr(:neg_infinity), do: "-inf"
  def py_repr(:nan), do: "nan"
  def py_repr(:ellipsis), do: "Ellipsis"
  def py_repr(:not_implemented), do: "NotImplemented"

  def py_repr({:struct_time, fields}),
    do: Pyex.Interpreter.Helpers.struct_time_repr(fields)

  def py_repr({:memoryview, _}), do: "<memory>"

  def py_repr(val) when is_float(val), do: Pyex.Interpreter.Helpers.py_float_str(val)

  def py_repr({:py_list, reversed, _len}) do
    inner = reversed |> Enum.reverse() |> Enum.map(&py_repr_quoted/1) |> Enum.join(", ")
    "[#{inner}]"
  end

  def py_repr(val) when is_list(val) do
    inner = val |> Enum.map(&py_repr_quoted/1) |> Enum.join(", ")
    "[#{inner}]"
  end

  def py_repr({:py_dict, _, _} = dict) do
    visible = visible_dict(dict)

    inner =
      visible
      |> PyDict.items()
      |> Enum.map(fn {k, v} -> "#{py_repr_quoted(k)}: #{py_repr_quoted(v)}" end)
      |> Enum.join(", ")

    "{#{inner}}"
  end

  def py_repr(val) when is_map(val) do
    visible = visible_dict(val)

    inner =
      visible
      |> Enum.map(fn {k, v} -> "#{py_repr_quoted(k)}: #{py_repr_quoted(v)}" end)
      |> Enum.join(", ")

    "{#{inner}}"
  end

  def py_repr({:tuple, items}) do
    case items do
      [single] -> "(#{py_repr_quoted(single)},)"
      _ -> "(#{items |> Enum.map(&py_repr_quoted/1) |> Enum.join(", ")})"
    end
  end

  def py_repr({:set, s}) do
    if MapSet.size(s) == 0 do
      "set()"
    else
      "{#{s |> MapSet.to_list() |> Enum.map(&py_repr_quoted/1) |> Enum.join(", ")}}"
    end
  end

  def py_repr({:frozenset, s}) do
    if MapSet.size(s) == 0 do
      "frozenset()"
    else
      inner = s |> MapSet.to_list() |> Enum.map(&py_repr_quoted/1) |> Enum.join(", ")
      "frozenset({#{inner}})"
    end
  end

  def py_repr({:range, s, e, st}) do
    if st == 1, do: "range(#{s}, #{e})", else: "range(#{s}, #{e}, #{st})"
  end

  def py_repr({:instance, {:class, name, _, _}, _}), do: "<#{name} instance>"
  def py_repr({:class, name, _, _}), do: "<class '#{name}'>"
  def py_repr({:module, name, _}), do: "<module '#{name}'>"
  def py_repr({:exception_class, name}), do: "<class '#{name}'>"
  def py_repr({:builtin_type, name, _}), do: "<class '#{name}'>"
  def py_repr({:bytes, b}), do: Pyex.Interpreter.Helpers.bytes_repr(b, "b")

  def py_repr({:bytearray, b}),
    do: "bytearray(" <> Pyex.Interpreter.Helpers.bytes_repr(b, "b") <> ")"

  def py_repr({:complex, _, _} = c), do: Pyex.Interpreter.Helpers.py_str(c)

  def py_repr({:function, name, _, _, _, _, _}), do: "<function #{name}>"
  def py_repr({:coroutine, name, _}), do: "<coroutine object #{name}>"
  def py_repr({:asyncio_task, _}), do: "<Task done>"
  def py_repr({:asyncio_task_pending, _}), do: "<Task pending>"
  def py_repr({:builtin, _}), do: "<built-in function>"
  def py_repr({:builtin_kw, _}), do: "<built-in function>"
  def py_repr({:pyex_decimal, d}), do: Decimal.to_string(d)
  def py_repr({:fraction, _, _} = f), do: Pyex.Fraction.to_repr(f)
  def py_repr({:deque, _, _, _, _} = d), do: Pyex.Interpreter.Helpers.py_str(d)
  def py_repr(_), do: "<object>"

  @doc """
  Python `repr()` that quotes strings (unlike `py_repr/1` which leaves them bare).

  `py_repr("y")` returns `"y"` but `py_repr_quoted("y")` returns `"'y'"`.
  Use this for error messages that embed user-facing values (`KeyError`,
  `IndexError`, etc.) so string keys match CPython.
  """
  @spec py_repr_quoted(Interpreter.pyvalue()) :: String.t()
  def py_repr_quoted(val) when is_binary(val) do
    if String.contains?(val, "'") and not String.contains?(val, "\"") do
      "\"#{escape_string(val, "\"")}\""
    else
      "'#{escape_string(val, "'")}'"
    end
  end

  def py_repr_quoted({:pyex_decimal, d}), do: "Decimal('#{Decimal.to_string(d)}')"

  def py_repr_quoted(val), do: py_repr(val)

  @spec escape_string(String.t(), String.t()) :: String.t()
  defp escape_string(s, quote_char) do
    s =
      s
      |> String.replace("\\", "\\\\")
      |> String.replace("\n", "\\n")
      |> String.replace("\r", "\\r")
      |> String.replace("\t", "\\t")

    if quote_char == "'" do
      String.replace(s, "'", "\\'")
    else
      String.replace(s, "\"", "\\\"")
    end
    |> String.replace("\0", "\\x00")
  end

  @spec builtin_iter([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_iter([{:instance, _, _} = inst]) do
    {:iter_instance, inst}
  end

  defp builtin_iter([{:iterator, _} = it]) do
    it
  end

  defp builtin_iter([val]) do
    case materialize_iterable(val) do
      {:ok, items} -> {:make_iter, items}
      {:pass, iter} -> iter
      :error -> {:exception, "TypeError: '#{pytype(val)}' object is not iterable"}
    end
  end

  @spec builtin_next([Interpreter.pyvalue()]) ::
          Interpreter.pyvalue() | Interpreter.builtin_signal()
  defp builtin_next([{:generator, _items}]) do
    {:exception,
     "TypeError: cannot call next() directly on a generator. " <>
       "Use iter() first: g = iter(gen()); next(g)"}
  end

  defp builtin_next([{:generator, _items}, _default]) do
    {:exception,
     "TypeError: cannot call next() directly on a generator. " <>
       "Use iter() first: g = iter(gen()); next(g)"}
  end

  defp builtin_next([{:iterator, id}]) do
    {:iter_next, id}
  end

  defp builtin_next([{:iterator, id}, default]) do
    {:iter_next_default, id, default}
  end

  defp builtin_next([{:instance, _, _} = inst]) do
    {:dunder_call, inst, "__next__", []}
  end

  defp builtin_next([{:instance, _, _} = inst, default]) do
    {:next_with_default, inst, default}
  end

  defp builtin_next(_) do
    {:exception, "TypeError: next() argument must be an iterator"}
  end

  @spec bool_to_int(boolean() | term()) :: integer() | term()
  defp bool_to_int(true), do: 1
  defp bool_to_int(false), do: 0
  defp bool_to_int(other), do: other
end
