defmodule Pyex.Interpreter.ControlFlow do
  @moduledoc """
  Control-flow evaluation helpers for `Pyex.Interpreter`.

  Keeps branching, loops, and try/except handling together so the main
  interpreter can focus on dispatch while preserving `eval/3`.
  """

  alias Pyex.{Ctx, Env, Interpreter, Parser}
  alias Pyex.Interpreter.{Exceptions, Helpers}

  @typep eval_result :: {Interpreter.pyvalue() | tuple(), Env.t(), Ctx.t()}

  @doc """
  Evaluates an `if` statement.
  """
  @spec eval_if(
          [
            {Parser.ast_node(), [Parser.ast_node()]} | {:else, [Parser.ast_node()]}
          ],
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  def eval_if(clauses, env, ctx), do: eval_if_clauses(clauses, env, ctx)

  @doc """
  Evaluates a `while` loop.
  """
  @spec eval_while(
          Parser.ast_node(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) ::
          eval_result()
  def eval_while(condition, body, else_body, env, ctx) do
    case Ctx.check_step(ctx) do
      {:exceeded, msg} ->
        {{:exception, msg}, env, ctx}

      {:ok, ctx} ->
        eval_while_body(condition, body, else_body, env, ctx)
    end
  end

  @doc """
  Evaluates a `for` loop.
  """
  @spec eval_for(
          String.t() | [term()],
          Parser.ast_node(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) ::
          eval_result()
  def eval_for(var_name, iterable_expr, body, else_body, env, ctx) do
    source_var = iterable_source_var(iterable_expr)

    case Interpreter.eval(iterable_expr, env, ctx) do
      {{:exception, _} = signal, env, ctx} ->
        {signal, env, ctx}

      {{:generator_error, items, exception_msg}, env, ctx} ->
        case eval_for_items(var_name, items, body, else_body, env, ctx) do
          {{:exception, _} = signal, env, ctx} -> {signal, env, ctx}
          {{:returned, _} = signal, env, ctx} -> {signal, env, ctx}
          {{:break}, env, ctx} -> {{:exception, exception_msg}, env, ctx}
          {_, env, ctx} -> {{:exception, exception_msg}, env, ctx}
        end

      {{:iterator, id}, env, ctx} ->
        # Lazy path: when the iterable is a generator iterator, step
        # one yield at a time and execute the for body between yields.
        # This is the difference between observing CPython-conformant
        # interleaving (gen-before, body, gen-after, gen-before, ...)
        # and eager materialisation (which mis-orders side effects).
        case Pyex.Ctx.iter_entry(ctx, id) do
          {:gen_sync, _started?, _cont, _gen_env} ->
            eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

          {:gen_pending, _val, _cont, _gen_env} ->
            eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

          :gen_done ->
            eval_loop_else(else_body, env, ctx)

          _ ->
            iterable_to_for({:iterator, id}, var_name, body, else_body, env, ctx, source_var)
        end

      {iterable, env, ctx} ->
        iterable_to_for(iterable, var_name, body, else_body, env, ctx, source_var)
    end
  end

  @spec iterable_to_for(
          Interpreter.pyvalue(),
          String.t() | [term()],
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t(),
          String.t() | nil
        ) :: eval_result()
  defp iterable_to_for(iterable, var_name, body, else_body, env, ctx, source_var) do
    case live_list_source(iterable, source_var, env, ctx) do
      {:ok, source} ->
        eval_for_live_list(var_name, source, body, else_body, env, ctx, source_var, 0)

      :error ->
        case Interpreter.to_iterable(iterable, env, ctx) do
          {:ok, items, env, ctx} ->
            eval_for_items(var_name, items, body, else_body, env, ctx, source_var)

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

  @typep live_list_source :: {:ref, non_neg_integer()} | {:var, String.t()}

  @spec live_list_source(Interpreter.pyvalue(), String.t() | nil, Env.t(), Ctx.t()) ::
          {:ok, live_list_source()} | :error
  defp live_list_source({:ref, id} = ref, _source_var, _env, ctx) do
    case Ctx.deref(ctx, ref) do
      {:py_list, _, _} -> {:ok, {:ref, id}}
      _ -> :error
    end
  end

  defp live_list_source({:py_list, _, _}, source_var, _env, _ctx) when is_binary(source_var),
    do: {:ok, {:var, source_var}}

  defp live_list_source(_iterable, _source_var, _env, _ctx), do: :error

  @spec eval_for_generator_iter(
          String.t() | [term()],
          non_neg_integer(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp eval_for_generator_iter(var_name, id, body, else_body, env, ctx) do
    case Pyex.Ctx.iter_entry(ctx, id) do
      {:gen_sync, _started?, cont, gen_env} ->
        # Lazy step: advance the generator to its next yield, bind the loop
        # variable, then run the body. Side effects in the generator and the
        # body interleave exactly as CPython sequences them.
        case Pyex.Ctx.check_step(ctx) do
          {:exceeded, msg} ->
            {{:exception, msg}, env, ctx}

          {:ok, ctx} ->
            case Pyex.Interpreter.BuiltinResults.advance_gen_sync_raw(
                   id,
                   cont,
                   gen_env,
                   :next,
                   ctx
                 ) do
              {:yield, val, _new_cont, _new_gen_env, ctx} ->
                ctx = Pyex.Ctx.record(ctx, :loop, nil)

                case bind_loop_var(var_name, val, env) do
                  {:exception, _} = signal ->
                    {signal, env, ctx}

                  env ->
                    run_gen_sync_body(var_name, id, body, else_body, env, ctx)
                end

              {:return, _return_value, ctx} ->
                eval_loop_else(else_body, env, ctx)

              {:exhausted, ctx} ->
                eval_loop_else(else_body, env, ctx)

              {:raise, msg, ctx} ->
                {{:exception, msg}, env, ctx}
            end
        end

      {:gen_pending, val, cont, gen_env} ->
        case Pyex.Ctx.check_step(ctx) do
          {:exceeded, msg} ->
            {{:exception, msg}, env, ctx}

          {:ok, ctx} ->
            ctx = Pyex.Ctx.record(ctx, :loop, nil)
            env = bind_loop_var(var_name, val, env)

            case env do
              {:exception, _} = signal ->
                {signal, env, ctx}

              env ->
                step_generator_body(var_name, id, cont, gen_env, body, else_body, env, ctx)
            end
        end

      :gen_done ->
        eval_loop_else(else_body, env, ctx)

      _ ->
        eval_loop_else(else_body, env, ctx)
    end
  end

  # Run one for-body iteration over a lazy `:gen_sync` generator, then loop.
  # On a body `yield` (the for-loop is itself inside a generator), suspend with
  # a `:cont_for_gen_iter` frame; otherwise advance to the next item.
  @spec run_gen_sync_body(
          String.t() | [term()],
          non_neg_integer(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp run_gen_sync_body(var_name, id, body, else_body, env, ctx) do
    case Interpreter.eval_statements(body, env, ctx) do
      {{:returned, _} = signal, env, ctx} ->
        {signal, env, ctx}

      {{:break}, env, ctx} ->
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {nil, env, ctx}

      {{:exception, _} = signal, env, ctx} ->
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {signal, env, ctx}

      {{:yielded, val, inner_cont}, env, ctx} ->
        cont_frame = {:cont_for_gen_iter, var_name, id, body, else_body}
        {{:yielded, val, inner_cont ++ [cont_frame]}, env, ctx}

      {{:continue}, env, ctx} ->
        eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

      {_, env, ctx} ->
        eval_for_generator_iter(var_name, id, body, else_body, env, ctx)
    end
  end

  @spec step_generator_body(
          String.t() | [term()],
          non_neg_integer(),
          [term()],
          Env.t(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp step_generator_body(var_name, id, cont, gen_env, body, else_body, env, ctx) do
    # `cont` and `gen_env` are the *generator's* current continuation
    # state; we hold them so that on resume we keep stepping. They
    # don't change between yields of the *for-loop body* itself.
    _ = cont
    _ = gen_env

    case Interpreter.eval_statements(body, env, ctx) do
      {{:returned, _} = signal, env, ctx} ->
        {signal, env, ctx}

      {{:break}, env, ctx} ->
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {nil, env, ctx}

      {{:exception, _} = signal, env, ctx} ->
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {signal, env, ctx}

      {{:yielded, val, inner_cont}, env, ctx} ->
        # Body yielded — we are inside a generator whose body is itself
        # a `for x in gen():` loop. Defer the rest of *this* loop into
        # a continuation frame so the outer consumer can advance us
        # through `resume_generator`.
        cont_frame = {:cont_for_gen_iter, var_name, id, body, else_body}
        {{:yielded, val, inner_cont ++ [cont_frame]}, env, ctx}

      {{:continue}, env, ctx} ->
        advance_generator_iter(var_name, id, cont, gen_env, body, else_body, env, ctx)

      {_, env, ctx} ->
        advance_generator_iter(var_name, id, cont, gen_env, body, else_body, env, ctx)
    end
  end

  @doc """
  Resumes a `for x in gen_iter:` loop inside a generator after a
  body yield. Called by `Pyex.Interpreter.resume_generator/3` when
  it sees a `:cont_for_gen_iter` frame.
  """
  @spec resume_for_gen_iter(
          String.t() | [term()],
          non_neg_integer(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  def resume_for_gen_iter(var_name, id, body, else_body, env, ctx) do
    case Pyex.Ctx.iter_entry(ctx, id) do
      {:gen_sync, _started?, _cont, _gen_env} ->
        # The body's post-yield continuation has already run; advance the
        # generator to the next item and continue the loop.
        eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

      {:gen_pending, _, _, _} ->
        case Pyex.Ctx.iter_next(ctx, id) do
          {:gen_pending, val, cont, gen_env} ->
            advance_after_body_yield(var_name, id, val, cont, gen_env, body, else_body, env, ctx)
        end

      :gen_done ->
        eval_loop_else(else_body, env, ctx)

      _ ->
        eval_loop_else(else_body, env, ctx)
    end
  end

  # After the for-body yielded, we still hold the *current* iterator
  # value (already consumed by the previous body invocation). Resume by
  # advancing one step, then re-entering the loop body.
  @spec advance_after_body_yield(
          String.t() | [term()],
          non_neg_integer(),
          term(),
          [term()],
          Env.t(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp advance_after_body_yield(var_name, id, _val, cont, gen_env, body, else_body, env, ctx) do
    saved_mode = ctx.generator_mode
    inner_ctx = %{ctx | generator_mode: :defer_inner}

    case Interpreter.resume_generator(cont, gen_env, inner_ctx) do
      {{:yielded, next_val, next_cont}, next_gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.set_gen_pending(ctx, id, next_val, next_cont, next_gen_env)
        eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

      {{:done_with_value, return_value}, _gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.mark_iter_done_with_value(ctx, id, return_value)
        eval_loop_else(else_body, env, ctx)

      {:done, _gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        eval_loop_else(else_body, env, ctx)

      {{:exception, _} = signal, _gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {signal, env, ctx}
    end
  end

  @spec advance_generator_iter(
          String.t() | [term()],
          non_neg_integer(),
          [term()],
          Env.t(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp advance_generator_iter(var_name, id, cont, gen_env, body, else_body, env, ctx) do
    # `resume_generator` re-enters the generator's body, so `yield`
    # must reach its `:defer_inner` handler — not the outer caller's
    # `:lazy_iter` mode (which would treat yield as a syntax error).
    saved_mode = ctx.generator_mode
    inner_ctx = %{ctx | generator_mode: :defer_inner}

    case Interpreter.resume_generator(cont, gen_env, inner_ctx) do
      {{:yielded, val, next_cont}, next_gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.set_gen_pending(ctx, id, val, next_cont, next_gen_env)
        eval_for_generator_iter(var_name, id, body, else_body, env, ctx)

      {:done, _gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        eval_loop_else(else_body, env, ctx)

      {{:exception, _} = signal, _gen_env, ctx} ->
        ctx = %{ctx | generator_mode: saved_mode}
        ctx = Pyex.Ctx.mark_iter_exhausted(ctx, id)
        {signal, env, ctx}
    end
  end

  @spec iterable_source_var(Parser.ast_node()) :: String.t() | nil
  defp iterable_source_var({:var, _, [name]}), do: name
  defp iterable_source_var(_), do: nil

  @doc false
  @spec eval_for_items(
          String.t() | [term()],
          [Interpreter.pyvalue()],
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t(),
          String.t() | nil
        ) :: eval_result()
  def eval_for_items(var_name, items, body, else_body, env, ctx, source_var \\ nil) do
    do_eval_for_items(var_name, items, body, else_body, env, ctx, source_var, 0)
  end

  @spec eval_for_live_list(
          String.t() | [term()],
          live_list_source(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t(),
          String.t() | nil,
          non_neg_integer()
        ) :: eval_result()
  defp eval_for_live_list(var_name, source, body, else_body, env, ctx, source_var, idx) do
    case live_list_at(source, idx, env, ctx) do
      :done ->
        eval_loop_else(else_body, env, ctx)

      {:ok, item} ->
        eval_for_live_item(var_name, item, body, else_body, env, ctx, source_var, idx, source)
    end
  end

  @spec eval_for_live_item(
          String.t() | [term()],
          Interpreter.pyvalue(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t(),
          String.t() | nil,
          non_neg_integer(),
          live_list_source()
        ) :: eval_result()
  defp eval_for_live_item(var_names, item, body, else_body, env, ctx, source_var, idx, source)
       when is_list(var_names) do
    case Ctx.check_step(ctx) do
      {:exceeded, msg} ->
        {{:exception, msg}, env, ctx}

      {:ok, ctx} ->
        ctx = Ctx.record(ctx, :loop, nil)

        case bind_loop_var(var_names, Ctx.deref(ctx, item), env) do
          {:exception, msg} ->
            {{:exception, msg}, env, ctx}

          env ->
            case Interpreter.eval_statements(body, env, ctx) do
              {{:returned, _} = signal, env, ctx} ->
                {signal, env, ctx}

              {{:break}, env, ctx} ->
                {nil, env, ctx}

              {{:exception, _} = signal, env, ctx} ->
                {signal, env, ctx}

              {{:yielded, val, cont}, env, ctx} ->
                rest = live_list_rest(source, idx + 1, env, ctx)

                {{:yielded, val, cont ++ [{:cont_for, var_names, rest, body, else_body}]}, env,
                 ctx}

              {{:continue}, env, ctx} ->
                eval_for_live_list(
                  var_names,
                  source,
                  body,
                  else_body,
                  env,
                  ctx,
                  source_var,
                  idx + 1
                )

              {_, env, ctx} ->
                eval_for_live_list(
                  var_names,
                  source,
                  body,
                  else_body,
                  env,
                  ctx,
                  source_var,
                  idx + 1
                )
            end
        end
    end
  end

  defp eval_for_live_item(var_name, item, body, else_body, env, ctx, source_var, idx, source) do
    case Ctx.check_step(ctx) do
      {:exceeded, msg} ->
        {{:exception, msg}, env, ctx}

      {:ok, ctx} ->
        ctx = Ctx.record(ctx, :loop, nil)
        env = Env.smart_put(env, var_name, item)

        case Interpreter.eval_statements(body, env, ctx) do
          {{:returned, _} = signal, env, ctx} ->
            {signal, env, ctx}

          {{:break}, env, ctx} ->
            {nil, env, ctx}

          {{:exception, _} = signal, env, ctx} ->
            {signal, env, ctx}

          {{:yielded, val, cont}, env, ctx} ->
            rest = live_list_rest(source, idx + 1, env, ctx)
            {{:yielded, val, cont ++ [{:cont_for, var_name, rest, body, else_body}]}, env, ctx}

          {{:continue}, env, ctx} ->
            {env, ctx} = writeback_loop_var(var_name, source_var, idx, item, env, ctx)
            eval_for_live_list(var_name, source, body, else_body, env, ctx, source_var, idx + 1)

          {_, env, ctx} ->
            {env, ctx} = writeback_loop_var(var_name, source_var, idx, item, env, ctx)
            eval_for_live_list(var_name, source, body, else_body, env, ctx, source_var, idx + 1)
        end
    end
  end

  @spec live_list_at(live_list_source(), non_neg_integer(), Env.t(), Ctx.t()) ::
          {:ok, Interpreter.pyvalue()} | :done
  defp live_list_at(source, idx, env, ctx) do
    case live_list_value(source, env, ctx) do
      {:py_list, reversed, len} when idx < len ->
        {:ok, Enum.at(reversed, len - 1 - idx)}

      _ ->
        :done
    end
  end

  @spec live_list_rest(live_list_source(), non_neg_integer(), Env.t(), Ctx.t()) ::
          [Interpreter.pyvalue()]
  defp live_list_rest(source, idx, env, ctx) do
    case live_list_value(source, env, ctx) do
      {:py_list, reversed, len} when idx < len ->
        reversed
        |> Enum.reverse()
        |> Enum.drop(idx)

      _ ->
        []
    end
  end

  @spec live_list_value(live_list_source(), Env.t(), Ctx.t()) :: Interpreter.pyvalue() | nil
  defp live_list_value({:ref, id}, _env, ctx), do: Ctx.deref(ctx, {:ref, id})

  defp live_list_value({:var, name}, env, ctx) do
    case Env.get(env, name) do
      {:ok, value} -> Ctx.deref(ctx, value)
      :undefined -> nil
    end
  end

  @doc false
  @spec bind_loop_var(String.t() | [term()], Interpreter.pyvalue(), Env.t()) ::
          Env.t() | {:exception, String.t()}
  def bind_loop_var(var_name, item, env) when is_binary(var_name) do
    Env.smart_put(env, var_name, item)
  end

  def bind_loop_var(var_names, item, env) when is_list(var_names) do
    case unpack_for_item(var_names, item) do
      {:ok, bindings} ->
        Enum.reduce_while(bindings, env, fn
          {name, val}, acc when is_binary(name) ->
            {:cont, Env.smart_put(acc, name, val)}

          {{:starred, name}, val}, acc ->
            {:cont, Env.smart_put(acc, name, val)}

          {nested_names, val}, acc when is_list(nested_names) ->
            case bind_loop_var(nested_names, val, acc) do
              {:exception, _} = err -> {:halt, err}
              updated_env -> {:cont, updated_env}
            end
        end)

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

  @doc """
  Evaluates a `try` statement.
  """
  @spec eval_try(
          [Parser.ast_node()],
          [{String.t() | nil, String.t() | nil, [Parser.ast_node()]}],
          [Parser.ast_node()] | nil,
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  def eval_try(body, handlers, else_body, finally_body, env, ctx) do
    case Interpreter.eval_statements(body, env, ctx) do
      {{:yielded, val, body_cont}, env, ctx} ->
        # A `yield` inside the try suspends the WHOLE try. The except/else/finally
        # are preserved in a :cont_try frame and re-applied when the generator
        # resumes — so finally runs at try-exit (not at the yield), close() runs
        # it lazily, and throw() lands in the except. (Was: eager, mis-ordered.)
        {{:yielded, val, [{:cont_try, body_cont, handlers, else_body, finally_body}]}, env, ctx}

      {{:exception, msg}, body_env, ctx} ->
        run_finally(match_handler(handlers, msg, body_env, ctx), finally_body)

      {val, env, ctx} ->
        result =
          if else_body,
            do: Interpreter.eval_statements(else_body, env, ctx),
            else: {val, env, ctx}

        run_finally(result, finally_body)
    end
  end

  @doc "Applies a try's except handlers + finally to a resumed body result; used by the generator engine's :cont_try resume."
  @spec finish_try(
          eval_result(),
          [{String.t() | nil, String.t() | nil, [Parser.ast_node()]}],
          [Parser.ast_node()] | nil,
          [Parser.ast_node()] | nil
        ) :: eval_result()
  def finish_try({{:exception, msg}, env, ctx}, handlers, _else_body, finally_body) do
    run_finally(match_handler(handlers, msg, env, ctx), finally_body)
  end

  def finish_try({{:returned, _} = sig, env, ctx}, _handlers, _else_body, finally_body) do
    run_finally({sig, env, ctx}, finally_body)
  end

  def finish_try({{:done_with_value, _} = sig, env, ctx}, _handlers, _else_body, finally_body) do
    run_finally({sig, env, ctx}, finally_body)
  end

  def finish_try({val, env, ctx}, _handlers, else_body, finally_body) do
    result =
      if else_body, do: Interpreter.eval_statements(else_body, env, ctx), else: {val, env, ctx}

    run_finally(result, finally_body)
  end

  @spec eval_if_clauses(
          [
            {Parser.ast_node(), [Parser.ast_node()]} | {:else, [Parser.ast_node()]}
          ],
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp eval_if_clauses([], env, ctx), do: {nil, env, ctx}

  defp eval_if_clauses([{:else, body} | _], env, ctx) do
    Interpreter.eval_statements(body, env, ctx)
  end

  defp eval_if_clauses([{condition, body} | rest], env, ctx) do
    case Interpreter.eval(condition, env, ctx) do
      {{:exception, _} = signal, env, ctx} ->
        {signal, env, ctx}

      {value, env, ctx} ->
        {taken, env, ctx} = Interpreter.eval_truthy(value, env, ctx)

        if taken do
          Interpreter.eval_statements(body, env, ctx)
        else
          eval_if_clauses(rest, env, ctx)
        end
    end
  end

  @spec eval_while_body(
          Parser.ast_node(),
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t()
        ) ::
          eval_result()
  defp eval_while_body(condition, body, else_body, env, ctx) do
    case Interpreter.eval(condition, env, ctx) do
      {{:exception, _} = signal, env, ctx} ->
        {signal, env, ctx}

      {value, env, ctx} ->
        {taken, env, ctx} = Interpreter.eval_truthy(value, env, ctx)

        if taken do
          case Interpreter.eval_statements(body, env, ctx) do
            {{:returned, _} = signal, env, ctx} ->
              {signal, env, ctx}

            {{:break}, env, ctx} ->
              {nil, env, ctx}

            {{:exception, _} = signal, env, ctx} ->
              {signal, env, ctx}

            {{:yielded, val, cont}, env, ctx} ->
              {{:yielded, val, cont ++ [{:cont_while, condition, body, else_body}]}, env, ctx}

            {{:continue}, env, ctx} ->
              eval_while(condition, body, else_body, env, ctx)

            {_, env, ctx} ->
              eval_while(condition, body, else_body, env, ctx)
          end
        else
          eval_loop_else(else_body, env, ctx)
        end
    end
  end

  @spec do_eval_for_items(
          String.t() | [term()],
          [Interpreter.pyvalue()],
          [Parser.ast_node()],
          [Parser.ast_node()] | nil,
          Env.t(),
          Ctx.t(),
          String.t() | nil,
          non_neg_integer()
        ) ::
          eval_result()
  defp do_eval_for_items(_var_name, [], _body, else_body, env, ctx, _source_var, _idx) do
    eval_loop_else(else_body, env, ctx)
  end

  defp do_eval_for_items(var_names, [item | rest], body, else_body, env, ctx, source_var, idx)
       when is_list(var_names) do
    case Ctx.check_step(ctx) do
      {:exceeded, msg} ->
        {{:exception, msg}, env, ctx}

      {:ok, ctx} ->
        ctx = Ctx.record(ctx, :loop, nil)

        case bind_loop_var(var_names, Ctx.deref(ctx, item), env) do
          {:exception, msg} ->
            {{:exception, msg}, env, ctx}

          env ->
            case Interpreter.eval_statements(body, env, ctx) do
              {{:returned, _} = signal, env, ctx} ->
                {signal, env, ctx}

              {{:break}, env, ctx} ->
                {nil, env, ctx}

              {{:exception, _} = signal, env, ctx} ->
                {signal, env, ctx}

              {{:yielded, val, cont}, env, ctx} ->
                {{:yielded, val, cont ++ [{:cont_for, var_names, rest, body, else_body}]}, env,
                 ctx}

              {{:continue}, env, ctx} ->
                do_eval_for_items(var_names, rest, body, else_body, env, ctx, source_var, idx + 1)

              {_, env, ctx} ->
                do_eval_for_items(var_names, rest, body, else_body, env, ctx, source_var, idx + 1)
            end
        end
    end
  end

  defp do_eval_for_items(var_name, [item | rest], body, else_body, env, ctx, source_var, idx) do
    case Ctx.check_step(ctx) do
      {:exceeded, msg} ->
        {{:exception, msg}, env, ctx}

      {:ok, ctx} ->
        ctx = Ctx.record(ctx, :loop, nil)
        env = Env.smart_put(env, var_name, item)

        case Interpreter.eval_statements(body, env, ctx) do
          {{:returned, _} = signal, env, ctx} ->
            {signal, env, ctx}

          {{:break}, env, ctx} ->
            {nil, env, ctx}

          {{:exception, _} = signal, env, ctx} ->
            {signal, env, ctx}

          {{:yielded, val, cont}, env, ctx} ->
            {{:yielded, val, cont ++ [{:cont_for, var_name, rest, body, else_body}]}, env, ctx}

          {{:continue}, env, ctx} ->
            {env, ctx} = writeback_loop_var(var_name, source_var, idx, item, env, ctx)
            do_eval_for_items(var_name, rest, body, else_body, env, ctx, source_var, idx + 1)

          {_, env, ctx} ->
            {env, ctx} = writeback_loop_var(var_name, source_var, idx, item, env, ctx)
            do_eval_for_items(var_name, rest, body, else_body, env, ctx, source_var, idx + 1)
        end
    end
  end

  @spec writeback_loop_var(
          String.t(),
          String.t() | nil,
          non_neg_integer(),
          Interpreter.pyvalue(),
          Env.t(),
          Ctx.t()
        ) :: {Env.t(), Ctx.t()}
  defp writeback_loop_var(_var_name, _source_var, _idx, _original, env, ctx), do: {env, ctx}

  @spec eval_loop_else([Parser.ast_node()] | nil, Env.t(), Ctx.t()) :: eval_result()
  defp eval_loop_else(nil, env, ctx), do: {nil, env, ctx}
  defp eval_loop_else(else_body, env, ctx), do: Interpreter.eval_statements(else_body, env, ctx)

  @spec unpack_for_item([term()], Interpreter.pyvalue()) ::
          {:ok, [{term(), Interpreter.pyvalue()}]} | {:exception, String.t()}
  defp unpack_for_item(names, {:tuple, items}), do: unpack_for_list(names, items)

  defp unpack_for_item(names, {:py_list, reversed, _}),
    do: unpack_for_list(names, Enum.reverse(reversed))

  defp unpack_for_item(names, items) when is_list(items), do: unpack_for_list(names, items)

  defp unpack_for_item(_names, val) do
    {:exception, "TypeError: cannot unpack non-iterable #{Helpers.py_type(val)} object"}
  end

  @spec unpack_for_list([term()], [Interpreter.pyvalue()]) ::
          {:ok, [{term(), Interpreter.pyvalue()}]} | {:exception, String.t()}
  defp unpack_for_list(names, items) do
    case Enum.find_index(names, &match?({:starred, _}, &1)) do
      nil ->
        if length(names) == length(items) do
          {:ok, Enum.zip(names, items)}
        else
          {:exception,
           "ValueError: not enough values to unpack (expected #{length(names)}, got #{length(items)})"}
        end

      star_idx ->
        unpack_for_list_starred(names, items, star_idx)
    end
  end

  @spec unpack_for_list_starred([term()], [Interpreter.pyvalue()], non_neg_integer()) ::
          {:ok, [{term(), Interpreter.pyvalue()}]} | {:exception, String.t()}
  defp unpack_for_list_starred(names, items, star_idx) do
    fixed_count = length(names) - 1
    item_count = length(items)

    if item_count < fixed_count do
      {:exception,
       "ValueError: not enough values to unpack (expected at least #{fixed_count}, got #{item_count})"}
    else
      after_count = length(names) - star_idx - 1
      before_names = Enum.take(names, star_idx)
      star_target = Enum.at(names, star_idx)
      after_names = Enum.drop(names, star_idx + 1)

      before_items = Enum.take(items, star_idx)
      after_items = if after_count > 0, do: Enum.take(items, -after_count), else: []
      star_items = items |> Enum.drop(star_idx) |> Enum.take(item_count - star_idx - after_count)

      {:ok,
       Enum.zip(before_names, before_items) ++
         [{star_target, star_items}] ++
         Enum.zip(after_names, after_items)}
    end
  end

  @spec run_finally(eval_result(), [Parser.ast_node()] | nil) :: eval_result()
  defp run_finally(result, nil), do: result

  defp run_finally({original_signal, env, ctx}, finally_body) do
    # If an exception is in flight as `finally` runs, record it so a `raise`
    # inside the body chains it as the new exception's __context__.
    env =
      case original_signal do
        {:exception, msg} ->
          handling =
            case ctx.exception_instance do
              {:instance, _, _} = inst -> Exceptions.with_chaining_defaults(inst)
              _ -> Exceptions.synthesize_exception_instance(msg)
            end

          Env.put(env, "__handling_exception__", handling)

        _ ->
          env
      end

    case Interpreter.eval_statements(finally_body, env, ctx) do
      {{:exception, _} = new_signal, env, ctx} ->
        {new_signal, env, ctx}

      {{:returned, _} = new_signal, env, ctx} ->
        {new_signal, env, ctx}

      {_val, env, ctx} ->
        {original_signal, env, ctx}
    end
  end

  @spec match_handler(
          [{String.t() | [term()] | nil, String.t() | nil, [Parser.ast_node()]}],
          String.t(),
          Env.t(),
          Ctx.t()
        ) :: eval_result()
  defp match_handler([], message, env, ctx) do
    {{:exception, message}, env, ctx}
  end

  defp match_handler([{nil, nil, handler_body} | _], message, env, ctx) do
    env = Env.put(env, "__current_exception__", message)

    handling =
      case ctx.exception_instance do
        {:instance, _, _} = inst -> Exceptions.with_chaining_defaults(inst)
        _ -> Exceptions.synthesize_exception_instance(message)
      end

    env = Env.put(env, "__handling_exception__", handling)
    ctx = %{ctx | exception_instance: nil}
    Interpreter.eval_statements(handler_body, env, ctx)
  end

  defp match_handler([{exc_names, var_name, handler_body} | rest], message, env, ctx) do
    matches =
      case exc_names do
        names when is_list(names) ->
          Enum.any?(names, &exception_matches?(&1, message, env, ctx))

        name ->
          exception_matches?(name, message, env, ctx)
      end

    if matches do
      env = Env.put(env, "__current_exception__", message)

      exc_value =
        case ctx.exception_instance do
          {:instance, _, _} = inst -> inst
          _ -> Exceptions.synthesize_exception_instance(message)
        end
        |> Exceptions.with_chaining_defaults()

      # Record the exception being handled so a `raise` inside the body chains
      # it as the new exception's implicit __context__ (PEP 3134).
      env = Env.put(env, "__handling_exception__", exc_value)
      env = if var_name, do: Env.put(env, var_name, exc_value), else: env

      ctx = %{ctx | exception_instance: nil}

      # PEP 3110: `except E as e:` implicitly `del e` at the end of the block —
      # the name does not survive (even if it shadowed an outer binding).
      case Interpreter.eval_statements(handler_body, env, ctx) do
        {signal, env, ctx} when var_name != nil ->
          {signal, Env.delete(env, var_name), ctx}

        result ->
          result
      end
    else
      match_handler(rest, message, env, ctx)
    end
  end

  @spec exception_matches?(String.t(), String.t(), Env.t(), Ctx.t()) :: boolean()
  defp exception_matches?("Exception", _message, _env, _ctx), do: true
  defp exception_matches?("BaseException", _message, _env, _ctx), do: true

  defp exception_matches?(exc_name, message, env, ctx) do
    cond do
      message == exc_name ->
        true

      String.starts_with?(message, exc_name <> ":") ->
        true

      true ->
        raised_name = extract_raised_name(message)

        cond do
          raised_name != nil and class_is_subclass?(raised_name, exc_name, env, ctx) ->
            true

          # `except some_module.SomeExc as e:` also matches when the raised
          # class is the final segment — module-qualified handlers resolve
          # against the unqualified exception class name in the hierarchy.
          String.contains?(exc_name, ".") ->
            short = exc_name |> String.split(".") |> List.last()

            (raised_name != nil and class_is_subclass?(raised_name, short, env, ctx)) or
              String.starts_with?(message, short <> ":") or message == short

          true ->
            false
        end
    end
  end

  @spec extract_raised_name(String.t()) :: String.t() | nil
  defp extract_raised_name(message) do
    case Regex.run(~r/^([A-Za-z_]\w*)(?::|$)/, message) do
      [_, name] -> name
      _ -> nil
    end
  end

  @spec class_is_subclass?(String.t(), String.t(), Env.t(), Ctx.t()) :: boolean()
  defp class_is_subclass?(class_name, target_name, _env, _ctx) when class_name == target_name,
    do: true

  defp class_is_subclass?(class_name, target_name, env, ctx) do
    cond do
      Pyex.ExceptionsHierarchy.subclass?(class_name, target_name) ->
        true

      true ->
        case Env.get(env, class_name) do
          {:ok, {:class, ^class_name, bases, _}} ->
            Enum.any?(bases, fn base ->
              case base do
                {:var, _, [base_name]} ->
                  class_is_subclass?(base_name, target_name, env, ctx)

                {:class, base_name, _, _} ->
                  class_is_subclass?(base_name, target_name, env, ctx)

                _ ->
                  false
              end
            end)

          {:ok, {:instance, {:class, name, bases, _}, _}} ->
            class_is_subclass?(name, target_name, env, ctx) or
              Enum.any?(bases, fn base ->
                case base do
                  {:var, _, [base_name]} -> class_is_subclass?(base_name, target_name, env, ctx)
                  _ -> false
                end
              end)

          _ ->
            false
        end
    end
  end
end
