defmodule Phoenix.LiveView.HTMLAlgebra do
  @moduledoc false

  import Inspect.Algebra, except: [format: 2]

  @languages ~w(style script)

  # Reference for all inline elements so that we can tell the formatter to not
  # force a line break. This list has been taken from here:
  #
  # https://web.archive.org/web/20220405120608/https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements#list_of_inline_elements
  #
  # A notable omission is `<script>`, which is handled separately in `html_algebra.ex`.
  @inline_tags ~w(a abbr acronym audio b bdi bdo big br button canvas cite
  code data datalist del dfn em embed i iframe img input ins kbd label map
  mark meter noscript object output picture progress q ruby s samp select slot
  small span strong sub sup svg template textarea time u tt var video wbr)

  # The formatter has two modes:
  #
  # * :normal
  # * :preserve - for preserving text in <pre>, <script>, <style> and HTML Comment tags
  #
  def build(tree, opts) when is_list(tree) do
    {inline_matcher, opts} = Keyword.pop(opts, :inline_matcher, ["link", "button"])
    {migrate, opts} = Keyword.pop(opts, :migrate_eex_to_curly_interpolation, true)
    {attr_formatters, opts} = Keyword.pop!(opts, :attribute_formatters)

    tree
    |> block_to_algebra(%{
      mode: :normal,
      migrate: migrate,
      attr_formatters: attr_formatters,
      opts: opts,
      inline_matcher: inline_matcher
    })
    |> group()
  end

  defp block_to_algebra([], _context), do: empty()

  defp block_to_algebra(block, %{mode: :preserve} = context) do
    concat =
      Enum.reduce(block, empty(), fn node, doc ->
        {_type, next_doc} = to_algebra(node, context)
        concat(doc, next_doc)
      end)

    {force_unfit?, meta} =
      Enum.find_value(block, fn
        {:text, text, meta} ->
          {String.contains?(text, "\n"), meta}

        _ ->
          {false, %{}}
      end)

    doc =
      if force_unfit? do
        concat |> force_unfit() |> group()
      else
        concat |> group()
      end

    newline_or_empty_before_text =
      if meta[:newlines_before_text] && meta[:newlines_before_text] > 1 do
        line()
      else
        empty()
      end

    newline_or_empty_after_text =
      if meta[:newlines_after_text] && meta[:newlines_after_text] > 1 do
        line()
      else
        empty()
      end

    concat([newline_or_empty_before_text, doc, newline_or_empty_after_text])
  end

  defp block_to_algebra([head | tail], context) do
    {type, doc} =
      head
      |> to_algebra(context)
      |> maybe_force_unfit()

    Enum.reduce(tail, {head, type, doc}, fn next_node, {prev_node, prev_type, prev_doc} ->
      {next_type, next_doc} =
        next_node
        |> to_algebra(context)
        |> maybe_force_unfit()

      doc =
        cond do
          prev_type == :inline and next_type == :inline ->
            on_break =
              if next_doc == empty() do
                ""
              else
                inline_break(prev_node, next_node)
              end

            concat([prev_doc, on_break, next_doc])

          prev_type == :newline and next_type == :inline ->
            concat([prev_doc, line(), next_doc])

          next_type == :newline ->
            {:text, _text, %{newlines_before_text: newlines}} = next_node

            if newlines > 1 do
              concat([prev_doc, nest(line(), :reset), next_doc])
            else
              concat([prev_doc, next_doc])
            end

          true ->
            # For most cases, we do want to `break("")` here because they are
            # block tags (div, p, etc..). But, in case the previous or next token
            # is a text without whitespace, such as:
            #
            #   (<div label="application programming interface">API</div>).
            #
            # We don't want to break("") otherwise it would format it like this:
            #
            #   (
            #     <div label="application programming interface">API</div>
            #   ).
            #
            # Therefore, this check if the previous or next token is not a text
            # and, if it is a text, check if that contains whitespace.
            cond do
              text_ends_with_space?(prev_node) or text_starts_with_space?(next_node) ->
                concat([prev_doc, break(""), next_doc])

              text?(prev_node) or text?(next_node) or (tag?(prev_node) and tag?(next_node)) ->
                concat([prev_doc, next_doc])

              true ->
                concat([prev_doc, break(""), next_doc])
            end
        end

      {next_node, next_type, doc}
    end)
    |> elem(2)
    |> group()
  end

  defp inline_break(prev_node, next_node) do
    cond do
      # We do not insert breaks when preserving.
      # We may insert spaces though if both sides are text.
      block_preserve?(prev_node) or block_preserve?(next_node) ->
        if text_ends_with_space?(prev_node) or text_starts_with_space?(next_node) do
          " "
        else
          ""
        end

      # If we have a block followed by anything that is not a tag,
      # we force a break.
      tag_block?(prev_node) and not tag?(next_node) ->
        break(" ")

      (text_ends_with_space?(prev_node) or text_starts_with_space?(next_node)) and
          not text_preserve?(prev_node) ->
        flex_break(" ")

      true ->
        ""
    end
  end

  defp tag_block?({:tag_block, _, _, _, _}), do: true
  defp tag_block?(_node), do: false

  defp tag?({:tag_block, _, _, _, _}), do: true
  defp tag?({:tag_self_close, _, _}), do: true
  defp tag?(_node), do: false

  defp text?({:text, _, _}), do: true
  defp text?(_), do: false

  @codepoints ~c"\s\n\r\t"

  defp text_starts_with_space?({:text, text, _meta}) when text != "",
    do: :binary.first(text) in @codepoints

  defp text_starts_with_space?(_node), do: false

  defp text_ends_with_space?({:text, text, _meta}) when text != "",
    do: :binary.last(text) in @codepoints

  defp text_ends_with_space?(_node), do: false

  defp block_preserve?({:tag_block, _, _, _, %{mode: :preserve}}), do: true
  defp block_preserve?({:body_expr, _, _}), do: true
  defp block_preserve?({:eex, _, _}), do: true
  defp block_preserve?(_node), do: false

  defp text_preserve?({:text, _, %{mode: :preserve}}), do: true
  defp text_preserve?(_), do: false

  defp to_algebra({:html_comment, block}, context) do
    children = block_to_algebra(block, %{context | mode: :preserve})
    {:block, group(nest(children, :reset))}
  end

  defp to_algebra({:tag_block, name, attrs, block, meta}, context) when name in @languages do
    children = block_to_algebra(block, %{context | mode: :preserve})

    # Convert the whole block to text as there are no
    # tags inside script/style, only text and EEx blocks.
    lines =
      children
      |> Inspect.Algebra.format(:infinity)
      |> IO.iodata_to_binary()
      |> String.split(["\r\n", "\n"])
      |> Enum.drop_while(&(String.trim_leading(&1) == ""))

    indentation =
      lines
      |> Enum.map(&count_indentation(&1, 0))
      |> Enum.min(fn -> :infinity end)
      |> case do
        :infinity -> 0
        min -> min
      end

    doc =
      case lines do
        [] ->
          line()

        _ ->
          text =
            lines
            |> Enum.map(&remove_indentation(&1, indentation))
            |> text_to_algebra(0, [])

          case meta do
            %{mode: :preserve} -> text
            _ -> concat(nest(concat(line(), text), 2), line())
          end
      end

    group =
      concat([format_tag_open(name, attrs, context), doc, "</#{name}>"])
      |> group()

    {:block, group}
  end

  defp to_algebra({:tag_block, name, attrs, block, meta}, %{mode: :preserve} = context) do
    children = block_to_algebra(block, context)

    children =
      if inline?(name, context) and meta.mode != :preserve do
        children
      else
        nest(children, 2)
      end

    tag =
      concat([format_tag_open(name, attrs, context), children, "</#{name}>"])
      |> group()

    {:inline, tag}
  end

  defp to_algebra({:tag_block, _name, _attrs, _block, %{mode: :preserve}} = doc, context) do
    to_algebra(doc, %{context | mode: :preserve})
  end

  defp to_algebra({:tag_block, name, attrs, block, _meta}, context) do
    inline? = inline?(name, context)
    {block, force_newline?} = trim_block_newlines(block, inline?)
    inline? = inline? and not force_newline?

    children =
      case block do
        [] -> empty()
        _ when inline? -> block_to_algebra(block, context)
        _ -> nest(concat(break(""), block_to_algebra(block, context)), 2)
      end

    children = if force_newline?, do: force_unfit(children), else: children

    doc =
      concat([
        format_tag_open(name, attrs, context),
        children,
        if(inline?, do: empty(), else: break("")),
        "</#{name}>"
      ])
      |> group()

    if inline? do
      {:inline, doc}
    else
      {:block, doc}
    end
  end

  defp to_algebra({:tag_self_close, name, attrs}, context) do
    doc =
      concat([
        "<#{name}",
        build_attrs(attrs, " ", context.attr_formatters, context.opts),
        "/>"
      ])

    {:inline, group(doc)}
  end

  # Handle EEX blocks within preserve tags
  defp to_algebra({:eex_block, expr, block, meta}, %{mode: :preserve} = context) do
    doc =
      Enum.reduce(block, empty(), fn {block, expr}, doc ->
        children = block_to_algebra(block, context)
        expr = "<% #{expr} %>"
        concat([doc, children, expr])
      end)

    {:block, group(concat("<%#{meta.opt} #{expr} %>", doc))}
  end

  # Handle EEX blocks
  defp to_algebra({:eex_block, expr, block, meta}, context) do
    {doc, _stab} =
      Enum.reduce(block, {empty(), false}, fn {block, expr}, {doc, stab?} ->
        {block, _force_newline?} = trim_block_newlines(block, false)
        {next_doc, stab?} = eex_block_to_algebra(expr, block, stab?, context)
        {concat(doc, force_unfit(next_doc)), stab?}
      end)

    {:block, group(concat("<%#{meta.opt} #{expr} %>", doc))}
  end

  defp to_algebra({:eex_comment, text}, _context) do
    {:inline, concat(["<%!--", text, "--%>"])}
  end

  defp to_algebra({:eex, text, %{opt: opt} = meta}, context) do
    cond do
      context.mode == :preserve ->
        {:inline, concat(["<%#{opt} ", text, " %>"])}

      context.migrate and opt == ~c"=" and safe_to_migrate?(text, 0) ->
        to_algebra({:body_expr, text, meta}, context)

      true ->
        doc = expr_to_code_algebra(text, meta, context.opts)
        {:inline, concat(["<%#{opt} ", doc, " %>"])}
    end
  end

  defp to_algebra({:body_expr, text, meta}, context) do
    if context.mode == :preserve do
      {:inline, concat(["{", text, "}"])}
    else
      doc = expr_to_code_algebra(text, meta, context.opts)
      {:inline, concat(["{", doc, "}"])}
    end
  end

  # Handle text within <pre>/<script>/<style>/comment tags.
  defp to_algebra({:text, text, _meta}, %{mode: :preserve}) when is_binary(text) do
    {:inline, string(text)}
  end

  defp to_algebra({:text, text, %{mode: :preserve}}, _context) when is_binary(text) do
    {:inline, string(text)}
  end

  # Handle text within other tags.
  defp to_algebra({:text, text, _meta}, _context) when is_binary(text) do
    case classify_leading(text) do
      :spaces ->
        {:inline, empty()}

      :newline ->
        {:newline, empty()}

      :other ->
        {:inline,
         text
         |> String.split(["\r\n", "\n"])
         |> Enum.map(&String.trim/1)
         |> Enum.drop_while(&(&1 == ""))
         |> text_to_algebra(0, [])}
    end
  end

  defp inline?(name, context) do
    name in @inline_tags or Enum.any?(context.inline_matcher, &(name =~ &1))
  end

  # Empty newline
  defp text_to_algebra(["" | lines], newlines, acc),
    do: text_to_algebra(lines, newlines + 1, acc)

  # Text
  # Text
  defp text_to_algebra([line | lines], 0, acc),
    do: text_to_algebra(lines, 0, [string(line), line() | acc])

  # Text
  #
  # Text
  defp text_to_algebra([line | lines], _newlines, acc),
    do: text_to_algebra(lines, 0, [string(line), line(), nest(line(), :reset) | acc])

  # Final clause: single line
  defp text_to_algebra([], _, [doc, _line]),
    do: doc

  defp text_to_algebra([], _, []),
    do: empty()

  # Final clause: multiple lines
  defp text_to_algebra([], _, acc),
    do: acc |> Enum.reverse() |> tl() |> concat()

  defp build_attrs([], on_break, _formatters, _opts), do: on_break

  defp build_attrs([attr], on_break, formatters, opts) do
    concat([" ", render_attribute(attr, formatters, opts), on_break])
  end

  defp build_attrs(attrs, on_break, formatters, opts) do
    doc =
      attrs
      |> Enum.sort_by(&attrs_sorter/1)
      |> Enum.reduce(
        empty(),
        &concat([&2, break(" "), render_attribute(&1, formatters, opts)])
      )
      |> nest(2)
      |> concat(break(on_break))

    if distinct_lines?(attrs, -1) do
      doc |> force_unfit() |> group()
    else
      group(doc)
    end
  end

  defp distinct_lines?([{_, _, %{line: line}} | _], line), do: false
  defp distinct_lines?([{_, _, %{line: line}} | tail], _line), do: distinct_lines?(tail, line)
  defp distinct_lines?([], _line), do: true

  @attrs_order %{
    ":let" => 1,
    ":for" => 2,
    ":if" => 3
  }

  # Sort attrs by @attrs_order. This will set :let, :for and :if at the beginning
  # and ordinary HTML attributes at the end. HTML attributes will not change their
  # order.
  defp attrs_sorter({attr_name, _, _}) do
    case @attrs_order[attr_name] do
      nil -> 4
      attrs_order -> attrs_order
    end
  end

  defp format_tag_open(name, attrs, context),
    do: concat(["<#{name}", build_attrs(attrs, "", context.attr_formatters, context.opts), ">"])

  defp render_attribute({:root, {:expr, expr, _}, _}, _formatters, _opts), do: ~s({#{expr}})

  defp render_attribute({name, _, _} = attr, formatters, opts)
       when is_map_key(formatters, name) do
    attr
    |> formatters[name].render_attribute(opts)
    |> render_attribute(%{}, opts)
  end

  defp render_attribute({attr, {:string, value, %{delimiter: ?'}}, _}, _formatters, _opts) do
    if String.contains?(value, ["\"", "'"]) do
      ~s(#{attr}='#{value}')
    else
      ~s(#{attr}="#{value}")
    end
  end

  defp render_attribute({attr, {:string, value, _meta}, _}, _formatters, _opts),
    do: ~s(#{attr}="#{value}")

  defp render_attribute({attr, {:expr, value, meta}, _}, _formatters, opts) do
    case expr_to_quoted(value, meta, opts) do
      {{:__block__, meta, [string]} = block, []} when is_binary(string) ->
        has_quotes? = String.contains?(string, "\"")
        delimiter = Keyword.get(meta, :delimiter)

        if has_quotes? or delimiter == "\"\"\"" do
          # don't try to flatten heredocs or strings with quotes
          group(concat(["#{attr}={", quoted_to_code_algebra(block, [], opts), "}"]))
        else
          # attr={"foo"} -> attr="foo"
          ~s(#{attr}="#{string}")
        end

      {{atom, _, _}, []} when atom in [:<<>>, :<>] ->
        concat(["#{attr}={", string(value), "}"])

      {{:__block__, _, [[_ | _]]} = quoted, []} ->
        expr = quoted_to_code_algebra(quoted, [], opts)
        group(concat(["#{attr}={", expr, "}"]))

      {quoted, comments} ->
        expr =
          break("")
          |> concat(quoted_to_code_algebra(quoted, comments, opts))
          |> nest(2)

        group(concat(["#{attr}={", expr, concat(break(""), "}")]))
    end
  end

  defp render_attribute({attr, {_, value, _meta}, _}, _formatters, _opts),
    do: ~s(#{attr}=#{value})

  defp render_attribute({attr, nil, _}, _formatters, _opts), do: ~s(#{attr})

  # Handle EEx clauses
  #
  # {[], "something ->"}
  # {[{:tag_block, "p", [], [text: "do something"]}], "else"}
  defp eex_block_to_algebra(expr, block, stab?, context) when is_list(block) do
    indent = if stab?, do: 4, else: 2

    document =
      if block == [] do
        # The first clause in cond/case and general empty clauses.
        empty()
      else
        line()
        |> concat(block_to_algebra(block, context))
        |> nest(indent)
      end

    stab? = String.ends_with?(expr, "->")
    indent = if stab?, do: 2, else: 0

    next =
      line()
      |> concat("<% #{expr} %>")
      |> nest(indent)

    {concat(document, next), stab?}
  end

  defp expr_to_quoted(expr, meta, opts) do
    string_to_quoted_opts = [
      literal_encoder: &{:ok, {:__block__, &2, [&1]}},
      token_metadata: true,
      unescape: false,
      line: meta.line,
      column: meta.column,
      file: Keyword.get(opts, :file, "nofile")
    ]

    Code.string_to_quoted_with_comments!(expr, string_to_quoted_opts)
  end

  defp expr_to_code_algebra(expr, meta, opts) do
    {quoted, comments} = expr_to_quoted(expr, meta, opts)
    quoted_to_code_algebra(quoted, comments, opts)
  end

  defp quoted_to_code_algebra(quoted, comments, opts) do
    Code.quoted_to_algebra(quoted, Keyword.merge(opts, escape: false, comments: comments))
  end

  def classify_leading(text), do: classify_leading(text, :spaces)

  def classify_leading(<<char, rest::binary>>, mode) when char in [?\s, ?\t],
    do: classify_leading(rest, mode)

  def classify_leading(<<?\n, rest::binary>>, _), do: classify_leading(rest, :newline)
  def classify_leading(<<>>, mode), do: mode
  def classify_leading(_rest, _), do: :other

  defp maybe_force_unfit({:block, doc}), do: {:block, force_unfit(doc)}
  defp maybe_force_unfit(doc), do: doc

  defp trim_block_newlines(block, inline?) do
    {tail, first_force?} =
      pop_head_if_only_spaces_or_newlines(block, inline?, :newlines_before_text)

    {block, last_force?} =
      tail
      |> Enum.reverse()
      |> pop_head_if_only_spaces_or_newlines(inline?, :newlines_after_text)

    force? = if Enum.empty?(block), do: false, else: first_force? or last_force?

    {Enum.reverse(block), force?}
  end

  defp pop_head_if_only_spaces_or_newlines(
         [{:text, text, meta} | tail] = block,
         inline?,
         newlines_key
       ) do
    force? = Map.fetch!(meta, newlines_key) > 0

    cond do
      String.trim_leading(text) == "" ->
        {tail, force?}

      inline? and not force? and whitespace_around?(text, newlines_key) ->
        text = cleanup_extra_spaces(text, newlines_key)
        meta = Map.put(meta, :mode, :preserve)
        {[{:text, text, meta} | tail], false}

      true ->
        {block, force?}
    end
  end

  defp pop_head_if_only_spaces_or_newlines(block, _inline?, _where), do: {block, false}

  defp cleanup_extra_spaces(text, :newlines_before_text), do: " " <> String.trim_leading(text)
  defp cleanup_extra_spaces(text, :newlines_after_text), do: String.trim_trailing(text) <> " "

  defp whitespace_around?(text, :newlines_before_text), do: :binary.first(text) in ~c"\s\t"
  defp whitespace_around?(text, :newlines_after_text), do: :binary.last(text) in ~c"\s\t"

  defp count_indentation(<<?\t, rest::binary>>, indent), do: count_indentation(rest, indent + 2)
  defp count_indentation(<<?\s, rest::binary>>, indent), do: count_indentation(rest, indent + 1)
  defp count_indentation(<<>>, _indent), do: :infinity
  defp count_indentation(_, indent), do: indent

  defp remove_indentation(rest, 0), do: rest
  defp remove_indentation(<<?\t, rest::binary>>, indent), do: remove_indentation(rest, indent - 2)
  defp remove_indentation(<<?\s, rest::binary>>, indent), do: remove_indentation(rest, indent - 1)
  defp remove_indentation(rest, _indent), do: rest

  defp safe_to_migrate?(~S[\{] <> rest, acc), do: safe_to_migrate?(rest, acc)
  defp safe_to_migrate?(~S[\}] <> rest, acc), do: safe_to_migrate?(rest, acc)
  defp safe_to_migrate?("{" <> rest, acc), do: safe_to_migrate?(rest, acc + 1)
  defp safe_to_migrate?("}" <> rest, acc), do: safe_to_migrate?(rest, acc - 1)
  defp safe_to_migrate?(<<_::utf8, rest::binary>>, acc), do: safe_to_migrate?(rest, acc)
  defp safe_to_migrate?(<<>>, acc), do: acc == 0
end
