defmodule Expo.PO.Tokenizer do
  @moduledoc false

  # This module is responsible for turning a chunk of text (a string) into a
  # list of tokens. For what "token" means, see the docs for `tokenize/1`.

  @type line :: pos_integer

  @type token ::
          {:str_lines, line, [binary]}
          | {:plural_form, line, non_neg_integer}
          | {:msgid, line}
          | {:msgid_plural, line}
          | {:msgstr, line}
          | {:msgctxt, line}
          | {:obsolete_msgid, line}
          | {:obsolete_msgid_plural, line}
          | {:obsolete_msgstr, line}
          | {:obsolete_msgctxt, line}
          | {:comment, line, binary}
          | {:previous, line}
          | {:eof, line}

  # In this list of keywords *the order matters* because a function clause is
  # generated for each keyword, and keywords have to be followed by whitespace.
  # `msgid_plural` would cause an error if it didn't come before `msgid`.
  # Also note that the `msgstr` keyword is missing here since it can be also
  # followed by a plural form (for example, `[1]`).
  @string_keywords ~w(
    msgid_plural
    msgid
    msgctxt
  )

  @whitespace [?\n, ?\t, ?\r, ?\s]
  @whitespace_no_nl [?\t, ?\r, ?\s]
  @escapable_chars [?", ?n, ?t, ?r, ?\\]

  @doc """
  Converts a string into a list of tokens.

  A "token" is a tuple formed by:

    * the `:str` tag or a keyword tag (like `:msgid`)
    * the line the token is at
    * the value of the token if the token has a value (for example, a `:str`
      token will have the contents of the string as a value)

  Some examples of tokens are:

    * `{:msgid, 33}`
    * `{:str, 6, "foo"}`

  """
  @spec tokenize(binary) :: {:ok, [token]} | {:error, pos_integer, binary}
  def tokenize(str) do
    tokenize_line(str, _line = 1, _tokens_acc = [])
  end

  # Reverse str_lines strings.
  defp postprocess_tokens([{:str_lines, line, strings} | rest]) do
    [{:str_lines, line, Enum.reverse(strings)} | postprocess_tokens(rest)]
  end

  # These make parsing easier.
  for kw <- [:msgid, :msgid_plural, :msgctxt, :msgstr] do
    defp postprocess_tokens([{:obsolete, _line}, {unquote(kw), line} | rest]) do
      # credo:disable-for-next-line Credo.Check.Warning.UnsafeToAtom
      [{:"obsolete_#{unquote(kw)}", line} | postprocess_tokens(rest)]
    end
  end

  # There's no point in having the "obsolete" token before some tokens, we can just collapse
  # it away.
  defp postprocess_tokens([{:obsolete, line}, {token_name, line, _value} = token | rest])
       when token_name in [:comment, :str_lines] do
    [token | postprocess_tokens(rest)]
  end

  defp postprocess_tokens([token | rest]) do
    [token | postprocess_tokens(rest)]
  end

  defp postprocess_tokens([]) do
    []
  end

  # End of file.
  defp tokenize_line(<<>>, line, acc) do
    {:ok, [{:"$end", line} | acc] |> Enum.reverse() |> postprocess_tokens()}
  end

  # Go to the next line.
  defp tokenize_line(<<?\n, rest::binary>>, line, acc) do
    tokenize_line(rest, line + 1, acc)
  end

  # Skip other whitespace.
  defp tokenize_line(<<char, rest::binary>>, line, acc)
       when char in @whitespace_no_nl do
    tokenize_line(rest, line, acc)
  end

  # Obsolete comment.
  defp tokenize_line(<<"#~", rest::binary>>, line, acc) do
    tokenize_line(rest, line, [{:obsolete, line} | acc])
  end

  # Previous comment.
  defp tokenize_line(<<"#|", rest::binary>>, line, acc) do
    tokenize_line(rest, line, [{:previous, line} | acc])
  end

  # Normal comment.
  defp tokenize_line(<<?#, _rest::binary>> = rest, line, acc) do
    {contents, rest} = to_eol_or_eof(rest, "")
    tokenize_line(rest, line, [{:comment, line, contents} | acc])
  end

  # Keywords.
  for kw <- @string_keywords do
    defp tokenize_line(unquote(kw) <> <<char, rest::binary>>, line, acc)
         when char in @whitespace do
      acc = [{unquote(String.to_existing_atom(kw)), line} | acc]
      tokenize_line(rest, line, acc)
    end

    defp tokenize_line(unquote(kw) <> _rest, line, _acc) do
      {:error, line, "no space after '#{unquote(kw)}'"}
    end
  end

  # `msgstr`.
  defp tokenize_line("msgstr[" <> <<rest::binary>>, line, acc) do
    case tokenize_plural_form(rest, "") do
      {:ok, plural_form, rest} ->
        # The order of the :plural_form and :msgstr tokens is inverted since
        # the `acc` array of tokens will be reversed at the end.
        acc = [{:plural_form, line, plural_form}, {:msgstr, line} | acc]
        tokenize_line(rest, line, acc)

      {:error, reason} ->
        {:error, line, reason}
    end
  end

  defp tokenize_line("msgstr" <> <<char, rest::binary>>, line, acc)
       when char in @whitespace do
    acc = [{:msgstr, line} | acc]
    tokenize_line(rest, line, acc)
  end

  defp tokenize_line("msgstr" <> _rest, line, _acc) do
    {:error, line, "no space after 'msgstr'"}
  end

  # String.
  defp tokenize_line(<<?", rest::binary>>, line, acc) do
    case tokenize_string(rest, "") do
      {:ok, string, rest} ->
        tokenize_line(rest, line, add_str_lines(line, string, acc))

      {:error, reason} ->
        {:error, line, reason}
    end
  end

  # Unknown keyword.
  # At this point, there has to be a syntax error. Here, since the first byte is
  # a letter (we don't take care of unicode or fancy stuff, just ASCII letters),
  # we assume there's an unknown keyword. We parse it with a regex
  # so that the error message is informative.
  defp tokenize_line(<<letter, _rest::binary>> = binary, line, _acc)
       when letter in ?a..?z or letter in ?A..?Z do
    next_word = List.first(Regex.run(~r/\w+/u, binary))
    {:error, line, "unknown keyword '#{next_word}'"}
  end

  # Unknown token.
  # Last resort: this is just a plain unexpected token. We take the first
  # Unicode char of the given binary and build an informative error message
  # (with the codepoint of the char).
  defp tokenize_line(binary, line, _acc) when is_binary(binary) do
    # To get the first Unicode char, we convert to char list first.
    [char | _] = String.to_charlist(binary)
    msg = :io_lib.format(~c"unexpected token: \"~ts\" (codepoint U+~4.16.0B)", [[char], char])
    {:error, line, :unicode.characters_to_binary(msg)}
  end

  @obsolete_keywords ~w(msgid msgid_plural msgctxt msgstr)a

  # Collapse the string into the previous str_lines token if there is one *on the same line*.
  defp add_str_lines(line, string, [{:str_lines, line, strings} | acc]) do
    [{:str_lines, line, [string | strings]} | acc]
  end

  defp add_str_lines(_line, string, [
         {:str_lines, line, strings},
         {keyword, _keyword_line} = keyword_token | acc
       ])
       when keyword in @obsolete_keywords do
    [{:str_lines, line, [string | strings]}, keyword_token | acc]
  end

  # Same as the previous clause, but here we collapse for plural forms.
  defp add_str_lines(_line, string, [
         {:str_lines, line, strings},
         {:plural_form, _plural_form_line, _plural_form} = plural_form_token,
         {:msgstr, _keyword_line} = keyword_token | acc
       ]) do
    [
      {:str_lines, line, [string | strings]},
      plural_form_token,
      keyword_token | acc
    ]
  end

  # If this str_lines token comes after a "modifier" comment (#| or #~), then
  # we collapse into the previous str_lines token to avoid tokens like:
  # obsolete str_lines obsolete str_lines obsolete str_lines ...
  defp add_str_lines(_line, string, [
         {modifier, _new_modifier_line},
         {:str_lines, line, strings},
         {keyword, _keyword_line} = keyword_token,
         {modifier, _old_modifier_line} = modifier_token | acc
       ])
       when modifier in [:obsolete, :previous] and keyword in @obsolete_keywords do
    [{:str_lines, line, [string | strings]}, keyword_token, modifier_token | acc]
  end

  # Same as the previous clause, but here we collapse for plural forms.
  defp add_str_lines(_line, string, [
         {modifier, _new_modifier_line},
         {:str_lines, line, strings},
         {:plural_form, _plural_form_line, _plural_form} = plural_form_token,
         {:msgstr, _keyword_line} = keyword_token,
         {modifier, _old_modifier_line} = modifier_token | acc
       ])
       when modifier in [:obsolete, :previous] do
    [
      {:str_lines, line, [string | strings]},
      plural_form_token,
      keyword_token,
      modifier_token | acc
    ]
  end

  # There was no str_lines token, so we add a new one.
  defp add_str_lines(line, string, acc) do
    [{:str_lines, line, [string]} | acc]
  end

  # Parses the double-quotes-delimited string `str` into a single string. Note
  # that `str` doesn't start with a double quote (since that was needed to
  # identify the start of a string). Note that the rest of the original string
  # doesn't include the closing double quote.
  defp tokenize_string(str, acc)

  defp tokenize_string(<<?", rest::binary>>, acc), do: {:ok, acc, rest}

  defp tokenize_string(<<?\\, char, rest::binary>>, acc)
       when char in @escapable_chars,
       do: tokenize_string(rest, <<acc::binary, escape_char(char)>>)

  defp tokenize_string(<<?\\, _char, _rest::binary>>, _acc),
    do: {:error, "unsupported escape code"}

  defp tokenize_string(<<?\n, _rest::binary>>, _acc), do: {:error, "newline in string"}

  defp tokenize_string(<<char, rest::binary>>, acc),
    do: tokenize_string(rest, <<acc::binary, char>>)

  defp tokenize_string(<<>>, _acc), do: {:error, "missing token \""}

  defp tokenize_plural_form(<<digit, rest::binary>>, acc) when digit in ~c"0123456789" do
    tokenize_plural_form(rest, <<acc::binary, digit>>)
  end

  defp tokenize_plural_form(<<?], char, rest::binary>>, acc)
       when char in @whitespace and acc != <<>> do
    {:ok, String.to_integer(acc), rest}
  end

  defp tokenize_plural_form(<<?], _rest::binary>>, acc) when acc != <<>> do
    {:error, "missing space after 'msgstr[#{acc}]'"}
  end

  defp tokenize_plural_form(_binary, _acc) do
    {:error, "invalid plural form"}
  end

  defp escape_char(?n), do: ?\n
  defp escape_char(?t), do: ?\t
  defp escape_char(?r), do: ?\r
  defp escape_char(?"), do: ?"
  defp escape_char(?\\), do: ?\\

  defp to_eol_or_eof(<<?\n, _rest::binary>> = rest, acc), do: {acc, rest}
  defp to_eol_or_eof(<<>>, acc), do: {acc, ""}
  defp to_eol_or_eof(<<char, rest::binary>>, acc), do: to_eol_or_eof(rest, <<acc::binary, char>>)
end
