# Copyright 2024 Adobe. All rights reserved.
# Copyright 2025 SmartRent. All rights reserved.
# This file is licensed to you under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. You may obtain a copy
# of the License at http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software distributed under
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
# OF ANY KIND, either express or implied. See the License for the specific language
# governing permissions and limitations under the License.

defmodule Quokka.Style.Blocks do
  @moduledoc """
  Simple 1-1 rewrites all crammed into one module to make for more efficient traversals

  Credo Rules addressed:

  * Credo.Check.Readability.LargeNumbers
  * Credo.Check.Readability.ParenthesesOnZeroArityDefs
  * Credo.Check.Readability.WithSingleClause
  * Credo.Check.Refactor.CondStatements
  * Credo.Check.Refactor.RedundantWithClauseResult
  * Credo.Check.Refactor.WithClauses
  """

  @behaviour Quokka.Style

  alias Quokka.Config
  alias Quokka.Style
  alias Quokka.Zipper

  defguardp is_negator(n) when elem(n, 0) in [:!, :not, :!=, :!==]
  defguardp is_empty_body(n) when elem(n, 0) == :__block__ and elem(n, 2) in [[nil], []]

  # Pipe into case: case foo |> bar() do ... end => foo |> bar() |> case do ... end
  def run({{:case, case_meta, [{:|>, _, _} = pipe_chain, clauses]}, _} = zipper, ctx) do
    if Quokka.Config.pipe_into_case?() do
      new_node = {:|>, [line: case_meta[:line]], [pipe_chain, {:case, case_meta, [clauses]}]}
      {:cont, Zipper.replace(zipper, new_node), ctx}
    else
      {:cont, zipper, ctx}
    end
  end

  # Credo.Check.Refactor.CondStatements
  def run({{:cond, _, [[{_, [{:->, _, [[head], a]}, {:->, _, [[{:__block__, _, [truthy]}], b]}]}]]}, _} = zipper, ctx)
      when is_atom(truthy) and truthy not in [nil, false], do: if_ast(zipper, head, a, b, ctx)

  # Credo.Check.Readability.WithSingleClause
  # rewrite `with success <- single_statement do body else ...elses end`
  # to `case single_statement do success -> body; ...elses end`
  def run({{:with, m, [{:<-, am, [success, single_statement]}, [body, elses]]}, zm}, ctx) do
    {{:__block__, do_meta, [:do]}, body} = body
    {{:__block__, _, [:else]}, elses} = elses

    elses =
      case elses do
        # unwrap a stab ala `, else: (_ -> :ok)`. these became literals in 1.17
        {:__block__, _, [[{:->, _, _}] = stab]} -> stab
        elses -> elses
      end

    # drops keyword formatting etc
    do_meta = [line: do_meta[:line]]
    clauses = [{{:__block__, am, [:do]}, [{:->, do_meta, [[success], body]} | elses]}]
    end_line = Style.max_line(elses) + 1

    # fun fact: i added the detailed meta just because i noticed it was missing while debugging something ...
    # ... and it fixed the bug 🤷
    case_meta = [
      end_of_expression: [newlines: 1, line: end_line],
      do: do_meta,
      end: [line: end_line],
      line: m[:line]
    ]

    # recurse in case this new case should be rewritten to a `if`, etc
    run({{:case, case_meta, [single_statement, clauses]}, zm}, ctx)
  end

  # `with true <- x, do: bar` =>`if x, do: bar`
  def run({{:with, m, [{:<-, _, [{_, _, [true]}, rhs]}, [do_kwl]]}, _} = zipper, ctx) do
    children =
      case rhs do
        # `true <- foo || {:error, :shouldve_used_an_if_statement}``
        # turn the rhs of an `||` into an else body
        {:||, _, [head, else_body]} ->
          [
            head,
            [
              do_kwl,
              {{:__block__, [line: m[:line] + 2], [:else]}, Style.shift_line(else_body, 3)}
            ]
          ]

        _ ->
          [rhs, [do_kwl]]
      end

    {:cont, Zipper.replace(zipper, {:if, m, children}), ctx}
  end

  # Credo.Check.Refactor.WithClauses
  def run({{:with, _, children}, _} = zipper, ctx) when is_list(children) do
    do_block? = Enum.any?(children, &Style.do_block?/1)
    arrow_or_match? = Enum.any?(children, &(left_arrow?(&1) || match?({:=, _, _}, &1)))

    cond do
      # we can style this!
      do_block? and arrow_or_match? ->
        style_with_statement(zipper, ctx)

      # `with (head_statements) do: x (else ...)`
      do_block? ->
        # head statements can be the empty list, if it matters
        {head_statements, [[{{:__block__, _, [:do]}, body} | _]]} =
          Enum.split_while(children, &(not Style.do_block?(&1)))

        [first | rest] = head_statements ++ [body]
        # replace this `with` statement with its headers + body
        zipper = zipper |> Zipper.replace(first) |> Zipper.insert_siblings(rest)
        {:cont, zipper, ctx}

      # maybe this isn't a with statement - could be a function named `with` or something.
      true ->
        {:cont, zipper, ctx}
    end
  end

  def run({{:unless, m, [head, do_else]}, _} = zipper, ctx) do
    zipper
    |> Zipper.replace({:if, m, [invert(head), do_else]})
    |> run(ctx)
  end

  def run({{:if, m, children}, _} = zipper, ctx) do
    case children do
      # double negator
      # if !!x, do: y[, else: ...] => if x, do: y[, else: ...]
      [{_, _, [nb]} = na, do_else] when is_negator(na) and is_negator(nb) ->
        zipper |> Zipper.replace({:if, m, [invert(nb), do_else]}) |> run(ctx)

      # Credo.Check.Refactor.NegatedConditionsWithElse
      # if !x, do: y, else: z => if x, do: z, else: y
      [negator, [{do_, do_body}, {else_, else_body}]] when is_negator(negator) ->
        if Config.negated_conditions_with_else?() do
          zipper
          |> Zipper.replace({:if, m, [invert(negator), [{do_, else_body}, {else_, do_body}]]})
          |> run(ctx)
        else
          {:cont, zipper, ctx}
        end

      # drop `else end` and `else: nil`
      [head, [do_block, {_, else_body}]] when is_empty_body(else_body) ->
        {:cont, Zipper.replace(zipper, {:if, m, [head, [do_block]]}), ctx}

      # invert and drop `do: nil`
      [head, [{do_, do_body}, {_, else_body}]] when is_empty_body(do_body) ->
        {:cont, Zipper.replace(zipper, {:if, m, [invert(head), [{do_, else_body}]]}), ctx}

      [head, [do_, else_]] ->
        if Style.max_line(do_) > Style.max_line(else_) do
          # we inverted the if/else blocks of this `if` statement in a previous pass (due to negators or unless)
          # shift comments etc to make it happy now
          if_ast(zipper, head, do_, else_, ctx)
        else
          {:cont, zipper, ctx}
        end

      _ ->
        {:cont, zipper, ctx}
    end
  end

  def run(zipper, ctx), do: {:cont, zipper, ctx}

  # with statements can do _a lot_, so this beast of a function likewise does a lot.
  defp style_with_statement({{:with, with_meta, children}, _} = zipper, ctx) do
    {preroll, children} =
      children
      |> Enum.map(fn
        # `_ <- rhs` => `rhs`
        {:<-, _, [{:_, _, _}, rhs]} -> rhs
        # `lhs <- rhs` => `lhs = rhs`
        {:<-, m, [{atom, _, nil} = lhs, rhs]} when is_atom(atom) -> {:=, m, [lhs, rhs]}
        child -> child
      end)
      |> Enum.split_while(&(not left_arrow?(&1)))

    # after rewriting `x <- y()` to `x = y()` there are no more arrows.
    # this never should've been a with statement at all! we can just replace it with assignments
    if Enum.empty?(children) do
      {:cont, replace_with_statement(zipper, preroll), ctx}
    else
      [[{{_, do_meta, _} = do_block, do_body} | elses] | reversed_clauses] = Enum.reverse(children)
      {postroll, reversed_clauses} = Enum.split_while(reversed_clauses, &(not left_arrow?(&1)))
      [{:<-, final_clause_meta, [lhs, rhs]} = _final_clause | rest] = reversed_clauses

      # drop singleton identity else clauses like `else foo -> foo end`
      elses =
        with [{{_, _, [:else]}, [{:->, _, [[left], right]}]}] <- elses,
             true <- nodes_equivalent?(left, right),
             do: [],
             else: (_ -> elses)

      # Remove Redundant body
      {postroll, reversed_clauses, do_body} =
        if Enum.empty?(postroll) and Enum.empty?(elses) and nodes_equivalent?(lhs, do_body) do
          # removing redundant RHS can expose more non-arrows behind it, so repeat our postroll process
          {postroll, reversed_clauses} = Enum.split_while(rest, &(not left_arrow?(&1)))
          {postroll, reversed_clauses, rhs}
        else
          {postroll, reversed_clauses, do_body}
        end

      # Put the postroll into the body
      {reversed_clauses, do_body} =
        if Enum.any?(postroll) do
          {node, do_body_meta, do_children} = do_body
          do_children = if node == :__block__, do: do_children, else: [do_body]
          do_body = {:__block__, Keyword.take(do_body_meta, [:line]), Enum.reverse(postroll, do_children)}
          {reversed_clauses, do_body}
        else
          {reversed_clauses, do_body}
        end

      final_clause_line = final_clause_meta[:line]

      do_line =
        cond do
          do_meta[:format] == :keyword && final_clause_line + 1 >= do_meta[:line] -> do_meta[:line]
          do_meta[:format] == :keyword -> final_clause_line + 1
          true -> final_clause_line
        end

      do_block = Macro.update_meta(do_block, &Keyword.put(&1, :line, do_line))
      # disable keyword `, do:` since there will be multiple statements in the body
      with_meta =
        if Enum.any?(postroll),
          do: Keyword.merge(with_meta, do: [line: with_meta[:line]], end: [line: Style.max_line(children) + 1]),
          else: with_meta

      with_children = Enum.reverse(reversed_clauses, [[{do_block, do_body} | elses]])
      zipper = Zipper.replace(zipper, {:with, with_meta, with_children})

      cond do
        # oops! RedundantWithClauseResult removed the final arrow in this. no more need for a with statement!
        Enum.empty?(reversed_clauses) ->
          {:cont, replace_with_statement(zipper, preroll ++ with_children), ctx}

        # recurse if the # of `<-` have changed (this `with` could now be eligible for a `case` rewrite)
        Enum.any?(preroll) ->
          # put the preroll before the with statement in either a block we create or the existing parent block
          zipper
          |> Style.find_nearest_block()
          |> Zipper.prepend_siblings(preroll)
          |> run(ctx)

        # the # of `<-` changed, so we should have another look at this with statement
        Enum.any?(postroll) ->
          run(zipper, ctx)

        true ->
          # of clauses didn't change, so don't reecurse or we'll loop FOREEEVEERR
          {:cont, zipper, ctx}
      end
    end
  end

  # `with a <- b(), c <- d(), do: :ok, else: (_ -> :error)`
  # =>
  # `a = b(); c = d(); :ok`
  defp replace_with_statement(zipper, preroll) do
    [[{_do, do_body} | _elses] | preroll] = Enum.reverse(preroll)

    block =
      case do_body do
        {:__block__, _, [{_, _, _} | _] = children} ->
          Enum.reverse(preroll, children)

        _ ->
          # RedundantWithClauseResult except we rewrote the `<-` to an `=`
          # `with a, b, x <- y(), do: x` => `a; b; y`
          case preroll do
            [{:=, _, [lhs, rhs]} | rest] ->
              if nodes_equivalent?(lhs, do_body),
                do: Enum.reverse(rest, [rhs]),
                else: Enum.reverse(preroll, [do_body])

            _ ->
              Enum.reverse(preroll, [do_body])
          end
      end

    case Style.ensure_block_parent(zipper) do
      {:ok, zipper} ->
        zipper
        |> Zipper.prepend_siblings(block)
        |> Zipper.remove()

      :error ->
        # this is a very sad case, where the `with` is an arg to a function or the rhs of an assignment.
        # for now, just hacking a block with parens where the with use to be
        # x = with a, b, c, do: d
        # =>
        # x =
        #   (
        #     a
        #     b
        #     c
        #     d
        #   )
        # @TODO would be nice to change to
        # a
        # b
        # c
        # x = d
        Zipper.update(zipper, fn {:with, meta, _} ->
          {:__block__, Keyword.take(meta, [:line]), block}
        end)
    end
  end

  defp left_arrow?({:<-, _, _}), do: true
  defp left_arrow?(_), do: false

  defp nodes_equivalent?(a, b), do: Style.without_meta(a) == Style.without_meta(b)

  defp if_ast(zipper, head, {_, _, _} = do_body, {_, _, _} = else_body, ctx) do
    do_ = {{:__block__, [line: nil], [:do]}, do_body}
    else_ = {{:__block__, [line: nil], [:else]}, else_body}
    if_ast(zipper, head, do_, else_, ctx)
  end

  defp if_ast(zipper, {_, meta, _} = head, {do_kw, do_body}, {else_kw, else_body}, ctx) do
    line = meta[:line]
    # ... why am i doing this again? hmm.
    do_body = Macro.update_meta(do_body, &Keyword.delete(&1, :end_of_expression))
    else_body = Macro.update_meta(else_body, &Keyword.delete(&1, :end_of_expression))

    max_do_line = Style.max_line(do_body)
    max_else_line = Style.max_line(else_body)
    end_line = max(max_do_line, max_else_line)

    # Change ast meta and comment lines to fit the `if` ast
    {do_, else_, comments} =
      if max_do_line >= max_else_line do
        # we're swapping the ordering of two blocks of code
        # and so must swap the lines of the ast & comments to keep comments where they belong!
        # the math is: move B up by the length of A, and move A down by the length of B plus one (for the else keyword)
        else_size = max_else_line - line
        do_size = max_do_line - max_else_line

        shifts = [
          # move comments in the `else_body` down by the size of the `do_body`
          {line..max_else_line, do_size},
          # move comments in `do_body` up by the size of the `else_body`
          {(max_else_line + 1)..max_do_line, -else_size}
        ]

        do_ = {Style.set_line(do_kw, line), Style.shift_line(do_body, -else_size)}
        else_ = {Style.set_line(else_kw, max_else_line), Style.shift_line(else_body, do_size)}
        {do_, else_, Style.shift_comments(ctx.comments, shifts)}
      else
        # much simpler case -- just scootch things in the else down by 1 for the `else` keyword.
        do_ = {{:__block__, [line: line], [:do]}, do_body}
        else_ = Style.shift_line({{:__block__, [line: max_do_line], [:else]}, else_body}, 1)
        {do_, else_, Style.shift_comments(ctx.comments, max_do_line..max_else_line, 1)}
      end

    zipper
    |> Zipper.replace({:if, [do: [line: line], end: [line: end_line], line: line], [head, [do_, else_]]})
    |> run(%{ctx | comments: comments})
  end

  defp invert({:!=, m, [a, b]}), do: {:==, m, [a, b]}
  defp invert({:!==, m, [a, b]}), do: {:===, m, [a, b]}
  defp invert({:==, m, [a, b]}), do: {:!=, m, [a, b]}
  defp invert({:===, m, [a, b]}), do: {:!==, m, [a, b]}
  defp invert({:!, _, [condition]}), do: condition
  defp invert({:not, _, [condition]}), do: condition
  defp invert({:in, m, [_, _]} = ast), do: {:not, m, [ast]}
  defp invert({_, m, _} = ast), do: {:!, [line: m[:line]], [ast]}
end
