defmodule BDS.Sidecar do
  @moduledoc false

  alias BDS.Persistence

  @list_item_prefix "  - "
  @document_marker "---"
  @always_quoted_keys MapSet.new(["originalName", "title", "alt", "caption", "author"])

  def serialize_document(fields) when is_list(fields) do
    serialized_fields =
      fields
      |> Enum.flat_map(&serialize_field/1)
      |> Enum.join("\n")

    [@document_marker, serialized_fields, @document_marker]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n")
  end

  def parse_document(contents) when is_binary(contents) do
    {:ok,
     contents
     |> String.split("\n", trim: true)
     |> parse_lines(%{})}
  end

  defp serialize_field({_key, nil}), do: []
  defp serialize_field({_key, ""}), do: []

  defp serialize_field({key, values}) when is_list(values) do
    serialized_values = values |> Enum.map(&serialize_inline_list_scalar/1) |> Enum.join(", ")
    ["#{key}: [#{serialized_values}]"]
  end

  defp serialize_field({key, value}) when is_boolean(value) do
    ["#{key}: #{if(value, do: "true", else: "false")}"]
  end

  defp serialize_field({key, value}) when is_integer(value) do
    rendered =
      if timestamp_key?(key) do
        Persistence.timestamp_to_iso8601(value)
      else
        Integer.to_string(value)
      end

    ["#{key}: #{rendered}"]
  end

  defp serialize_field({key, value}) do
    ["#{key}: #{serialize_scalar(key, value)}"]
  end

  defp parse_lines([], acc), do: acc

  defp parse_lines([line | rest], acc) do
    cond do
      String.starts_with?(line, @list_item_prefix) ->
        parse_lines(rest, acc)

      String.ends_with?(line, ":") ->
        key = String.trim_trailing(line, ":")
        {items, remaining} = take_list_items(rest, [])
        parse_lines(remaining, Map.put(acc, key, Enum.reverse(items)))

      String.contains?(line, ": ") ->
        [key, raw_value] = String.split(line, ": ", parts: 2)
        parse_lines(rest, Map.put(acc, key, parse_scalar(key, raw_value)))

      true ->
        parse_lines(rest, acc)
    end
  end

  defp take_list_items([line | rest], items) do
    if String.starts_with?(line, @list_item_prefix) do
      value = line |> String.replace_prefix(@list_item_prefix, "") |> then(&parse_scalar(nil, &1))
      take_list_items(rest, [value | items])
    else
      {items, [line | rest]}
    end
  end

  defp take_list_items([], items), do: {items, []}

  defp parse_scalar(key, value) when is_binary(key) and is_binary(value) do
    trimmed = String.trim(value)
    parsed = parse_generic_scalar(trimmed)

    cond do
      timestamp_key?(key) ->
        if is_binary(parsed) do
          Persistence.parse_timestamp(parsed) || parsed
        else
          parsed
        end

      true ->
        parsed
    end
  end

  defp parse_scalar(nil, value) when is_binary(value) do
    value
    |> String.trim()
    |> parse_generic_scalar()
  end

  defp parse_generic_scalar("true"), do: true
  defp parse_generic_scalar("false"), do: false
  defp parse_generic_scalar("[]"), do: []

  defp parse_generic_scalar("[" <> _rest = value) do
    parse_inline_list(value)
  end

  defp parse_generic_scalar(value) do
    if Regex.match?(~r/^-?\d+$/, value) do
      String.to_integer(value)
    else
      parse_string(value)
    end
  end

  defp parse_string("\"" <> rest) do
    rest
    |> String.trim_trailing("\"")
    |> String.replace("\\n", "\n")
    |> String.replace("\\\"", "\"")
    |> String.replace("\\\\", "\\")
  end

  defp parse_string("'" <> rest) do
    rest
    |> String.trim_trailing("'")
    |> String.replace("\\n", "\n")
    |> String.replace("\\'", "'")
    |> String.replace("\\\\", "\\")
  end

  defp parse_string(value), do: value

  defp serialize_scalar(_key, value) when is_boolean(value) do
    if(value, do: "true", else: "false")
  end

  defp serialize_scalar(key, value) when is_integer(value) do
    if is_binary(key) and timestamp_key?(key) do
      Persistence.timestamp_to_iso8601(value)
    else
      Integer.to_string(value)
    end
  end

  defp serialize_scalar(key, value) do
    string_value = to_string(value)

    if is_binary(key) and MapSet.member?(@always_quoted_keys, key) do
      quote_string(string_value)
    else
      maybe_quote_string(string_value)
    end
  end

  defp serialize_inline_list_scalar(value) when is_binary(value), do: quote_string(value)
  defp serialize_inline_list_scalar(value), do: serialize_scalar(nil, value)

  defp parse_inline_list(value) do
    inner =
      value
      |> String.trim()
      |> String.trim_leading("[")
      |> String.trim_trailing("]")
      |> String.trim()

    if inner == "" do
      []
    else
      parse_inline_list_items(inner)
    end
  end

  defp parse_inline_list_items(inner) do
    if String.contains?(inner, "\"") or String.contains?(inner, "'") do
      Regex.scan(~r/"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'/, inner, capture: :all_but_first)
      |> Enum.map(fn captures ->
        captures
        |> Enum.find(&(&1 != ""))
        |> parse_inline_string_item()
      end)
    else
      inner
      |> String.split(",", trim: true)
      |> Enum.map(&parse_scalar(nil, String.trim(&1)))
    end
  end

  defp parse_inline_string_item(nil), do: ""

  defp parse_inline_string_item(value) do
    value
    |> String.replace("\\n", "\n")
    |> String.replace("\\\"", "\"")
    |> String.replace("\\'", "'")
    |> String.replace("\\\\", "\\")
  end

  defp maybe_quote_string(value) do
    if Regex.match?(~r/^[\p{L}\p{N} ._\/-]+$/u, value) do
      value
    else
      quote_string(value)
    end
  end

  defp quote_string(value) do
    escaped =
      value
      |> String.replace("\\", "\\\\")
      |> String.replace("\"", "\\\"")
      |> String.replace("\n", "\\n")

    "\"#{escaped}\""
  end

  defp timestamp_key?(key) do
    rendered = to_string(key)
    String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
  end
end
