defmodule Instream.Encoder.Line do
  @moduledoc """
  Encoder for the InfluxDB line protocol.
  """

  alias Instream.Decoder.RFC3339

  @type point ::
          %{
            required(:fields) => map,
            required(:measurement) => binary,
            optional(:tags) => map,
            optional(:timestamp) => non_neg_integer | binary | nil
          }
          | %{
              __struct__: module,
              fields: map,
              tags: map,
              timestamp: non_neg_integer | binary | nil
            }

  @doc """
  Creates protocol contents for a list of data points.
  """
  @spec encode([point()]) :: binary
  def encode(points), do: encode(points, [])

  defp encode([point | points], lines) do
    line = encode_point(point)

    encode(points, ["\n", line | lines])
  end

  defp encode([], ["\n" | lines]) do
    lines
    |> Enum.reverse()
    |> IO.iodata_to_binary()
  end

  defp encode([], []), do: ""

  defp append_fields(line, %{fields: fields}) do
    content =
      fields
      |> Enum.sort()
      |> Enum.reduce([], fn
        {_, nil}, acc -> acc
        {field, value}, acc -> [[encode_property(field), "=", encode_value(value)], "," | acc]
      end)
      |> Enum.reverse()

    case content do
      [] -> line
      ["," | encoded_fields] -> [line, " " | encoded_fields]
    end
  end

  defp append_tags(line, %{tags: tags}) do
    content =
      tags
      |> Enum.sort()
      |> Enum.reduce([], fn
        {_, nil}, acc -> acc
        {tag, value}, acc -> [[encode_property(tag), "=", encode_property(value)], "," | acc]
      end)
      |> Enum.reverse()

    case content do
      [] -> line
      encoded_tags -> [line | encoded_tags]
    end
  end

  defp append_tags(line, _), do: line

  defp append_timestamp(line, %{timestamp: nil}), do: line

  defp append_timestamp(line, %{timestamp: ts}) when is_integer(ts),
    do: [line, " ", Integer.to_string(ts)]

  defp append_timestamp(line, %{timestamp: ts}) when is_binary(ts),
    do: [line, " ", ts |> RFC3339.to_nanosecond() |> Integer.to_string()]

  defp append_timestamp(line, _), do: line

  defp encode_point(%{__struct__: series, fields: fields, tags: tags, timestamp: timestamp}) do
    encode_point(%{
      measurement: series.__meta__(:measurement),
      fields: Map.from_struct(fields),
      tags: Map.from_struct(tags),
      timestamp: timestamp
    })
  end

  defp encode_point(%{measurement: measurement} = point) do
    [encode_property(measurement)]
    |> append_tags(point)
    |> append_fields(point)
    |> append_timestamp(point)
  end

  defp encode_property(s) when is_binary(s) do
    s
    |> :binary.replace(",", "\\,", [:global])
    |> :binary.replace(" ", "\\ ", [:global])
    |> :binary.replace("=", "\\=", [:global])
  end

  defp encode_property(s), do: Kernel.to_string(s)

  defp encode_value(i) when is_integer(i), do: [Integer.to_string(i), "i"]

  defp encode_value(s) when is_binary(s),
    do: ["\"", :binary.replace(s, "\"", "\\\"", [:global]), "\""]

  defp encode_value(true), do: "true"
  defp encode_value(false), do: "false"
  defp encode_value(other), do: inspect(other)
end
