defmodule Protox.Parse do
  @moduledoc false
  # Internal.
  # Creates definitions from a protobuf encoded description (Protox.Google.Protobuf.FileDescriptorSet)
  # of a set of .proto files. This description is produced by `protoc`.

  import Protox.Guards

  alias Protox.Google.Protobuf.{
    DescriptorProto,
    FieldDescriptorProto,
    FieldOptions,
    FileDescriptorProto,
    FileDescriptorSet,
    MessageOptions
  }

  alias Protox.{Definition, Field, MessageSchema, OneOf, Scalar}

  @spec parse(binary(), Keyword.t()) :: {:ok, Definition.t()}
  def parse(file_descriptor_set, opts \\ []) do
    {:ok, descriptor} = FileDescriptorSet.decode(file_descriptor_set)

    definition =
      %Definition{}
      |> parse_files(descriptor.file)
      |> post_process(opts)
      |> add_file_options()
      |> remove_google_types()

    {:ok, definition}
  end

  # -- Private

  defp parse_files(definition, descriptors) do
    Enum.reduce(
      descriptors,
      definition,
      fn %FileDescriptorProto{} = descriptor, definition -> parse_file(definition, descriptor) end
    )
  end

  defp parse_file(definition, %FileDescriptorProto{} = descriptor) do
    syntax =
      case descriptor.syntax do
        "proto3" -> :proto3
        "proto2" -> :proto2
        "" -> :proto2
      end

    prefix =
      case descriptor.package do
        "" -> []
        p -> p |> String.split(".") |> camelize()
      end

    definition
    |> make_enums(prefix, descriptor.enum_type)
    |> make_messages(syntax, prefix, descriptor.message_type, descriptor.options)
    |> add_extensions(syntax, descriptor.extension)
  end

  # Prepend with namespace, resolve pending types and set default values
  defp post_process(definition, opts) do
    namespace_or_nil = Keyword.get(opts, :namespace, nil)

    processed_messages =
      for {msg_name, %MessageSchema{} = msg} <- definition.messages_schemas, into: %{} do
        name = Module.concat([namespace_or_nil | msg_name])

        fields =
          for {field_name, field} <- msg.fields, into: %{} do
            field =
              field
              |> resolve_types(definition.enums_schemas)
              |> set_default_value(definition.enums_schemas)
              |> concat_names(namespace_or_nil)
              |> concat_extender(namespace_or_nil)

            {field_name, field}
          end

        {name, %{msg | name: name, fields: fields}}
      end

    processsed_enums =
      for {ename, constants} <- definition.enums_schemas, into: %{} do
        {Module.concat([namespace_or_nil | ename]), constants}
      end

    %Definition{enums_schemas: processsed_enums, messages_schemas: processed_messages}
  end

  defp concat_extender(%Field{extender: nil} = field, _namespace_or_nil), do: field

  defp concat_extender(%Field{extender: extender} = field, namespace_or_nil) do
    %{field | extender: Module.concat([namespace_or_nil | extender])}
  end

  # We remove all Google types as:
  # - they're either well-known types (Any, Timestamp, etc.) which are provided with protox.
  # - or they come from descriptor.proto, which are only relevant (as far as I know) protox.
  # Removing these types permit to generate smaller code.
  defp remove_google_types(definition) do
    filtered_messages =
      Map.reject(definition.messages_schemas, fn {msg_name, _message} ->
        match?(["Google", "Protobuf" | _], Module.split(msg_name))
      end)

    filtered_enums =
      Map.reject(definition.enums_schemas, fn {enum_name, _constants} ->
        match?(["Google", "Protobuf" | _], Module.split(enum_name))
      end)

    %Definition{enums_schemas: filtered_enums, messages_schemas: filtered_messages}
  end

  defp add_file_options(definition) do
    # Custom file options are defined by users, so they can't be described in
    # the FileOptions of descriptor.ex. They are parsed as unknown fields.
    # So, to decode these custom fields, we have to compile the FileOptions
    # which comes with the current FileDescriptorSet.

    # Look for FileOptions and related messages. If found, we will compile them
    # to parse file options.
    {file_options_messages, other_messages} =
      Map.split(definition.messages_schemas, [
        Google.Protobuf.FeatureSet,
        Google.Protobuf.FileOptions,
        Google.Protobuf.UninterpretedOption,
        Google.Protobuf.UninterpretedOption.NamePart
      ])

    case file_options_messages do
      file_options_message when map_size(file_options_message) == 0 ->
        definition

      _ ->
        # If FileOptions and other related message have been found, we also need to compile their associated enums.
        {file_options_enums, other_enums} =
          Map.split(definition.enums_schemas, [
            Google.Protobuf.FeatureSet.EnumType,
            Google.Protobuf.FeatureSet.FieldPresence,
            Google.Protobuf.FeatureSet.JsonFormat,
            Google.Protobuf.FeatureSet.MessageEncoding,
            Google.Protobuf.FeatureSet.RepeatedFieldEncoding,
            Google.Protobuf.FeatureSet.Utf8Validation,
            Google.Protobuf.FileOptions.OptimizeMode
          ])

        # FileOptions can also contain nested messages and enums, which we need to compile as well.
        nested_in_file_options = find_enums_and_messages(definition.messages_schemas, Google.Protobuf.FileOptions)
        messages_used_in_file_options = Map.take(definition.messages_schemas, nested_in_file_options.messages)
        enums_used_in_file_options = Map.take(definition.enums_schemas, nested_in_file_options.enums)

        # Remove those messages and enums from the returned definition to avoid compiling them twice.
        other_messages = Map.drop(other_messages, nested_in_file_options.messages)
        other_enums = Map.drop(other_enums, nested_in_file_options.enums)

        # Compile the needed modules.
        %Definition{
          messages_schemas: Map.merge(file_options_messages, messages_used_in_file_options),
          enums_schemas: Map.merge(file_options_enums, enums_used_in_file_options)
        }
        |> Protox.Define.define()
        |> Code.eval_quoted()

        # We can now parse the unknown fields with the modules compiled above.
        other_messages =
          for {msg_name, msg} when msg.file_options != nil <- other_messages, into: %{} do
            file_options =
              msg.file_options
              |> Protox.Google.Protobuf.FileOptions.encode!()
              |> elem(_bytes_position_in_tuple = 0)
              |> IO.iodata_to_binary()
              |> then(&apply(Google.Protobuf.FileOptions, :decode!, [&1]))

            {msg_name, %{msg | file_options: file_options}}
          end

        # It's no longer necessary to keep the compiled modules in memory as they are of
        # no use for end user.
        remove_module(Google.Protobuf.FeatureSet)
        remove_module(Google.Protobuf.FileOptions)
        remove_module(Google.Protobuf.UninterpretedOption)
        remove_module(Google.Protobuf.UninterpretedOption.NamePart)
        remove_module(Google.Protobuf.FeatureSet.EnumType)
        remove_module(Google.Protobuf.FeatureSet.FieldPresence)
        remove_module(Google.Protobuf.FeatureSet.JsonFormat)
        remove_module(Google.Protobuf.FeatureSet.MessageEncoding)
        remove_module(Google.Protobuf.FeatureSet.RepeatedFieldEncoding)
        remove_module(Google.Protobuf.FeatureSet.Utf8Validation)
        remove_module(Google.Protobuf.FileOptions.OptimizeMode)

        # Finally, construct a new definition with the messages and enums that were not used
        # to parse FileOptions.
        definition
        |> put_in([Access.key!(:messages_schemas)], other_messages)
        |> put_in([Access.key!(:enums_schemas)], other_enums)
    end
  end

  defp find_enums_and_messages(messages_schemas, msg_name, acc \\ %{enums: [], messages: []}) do
    Enum.reduce(messages_schemas[msg_name].fields, acc, fn
      {_field_name, %Protox.Field{type: {:message, sub_msg_name}}}, acc ->
        find_enums_and_messages(
          messages_schemas,
          sub_msg_name,
          _new_acc = Map.update!(acc, :messages, &[sub_msg_name | &1])
        )

      {_field_name, %Protox.Field{type: {:enum, sub_enum_name}}}, acc ->
        Map.update!(acc, :enums, &[sub_enum_name | &1])

      _field, acc ->
        acc
    end)
  end

  defp resolve_types(%Field{type: {:type_to_resolve, tname}} = field, enums) do
    if Map.has_key?(enums, tname) do
      %{field | type: {:enum, tname}}
    else
      %{field | type: {:message, tname}}
    end
  end

  defp resolve_types(%Field{kind: :map, type: {key_type, {:type_to_resolve, tname}}} = field, enums) do
    if Map.has_key?(enums, tname) do
      %{field | type: {key_type, {:enum, tname}}}
    else
      %{field | type: {key_type, {:message, tname}}}
    end
  end

  defp resolve_types(%Field{} = field, _enums), do: field

  defp set_default_value(
         %Field{kind: %Scalar{default_value: :enum_default_to_resolve}, type: {:enum, ename}} = field,
         enums
       ) do
    # proto2: the first entry is always the default value
    # proto3: the entry with value 0 is the default value, and protoc mandates the first entry
    # to have the value 0
    [{_, first_is_default} | _] = Map.fetch!(enums, ename)

    %{field | kind: %Scalar{default_value: first_is_default}, type: {:enum, ename}}
  end

  defp set_default_value(%Field{} = field, _enums), do: field

  defp concat_names(%Field{type: {:enum, ename}} = field, namespace_or_nil) do
    %{field | type: {:enum, Module.concat([namespace_or_nil | ename])}}
  end

  defp concat_names(%Field{type: {:message, mname}} = field, namespace_or_nil) do
    %{field | type: {:message, Module.concat([namespace_or_nil | mname])}}
  end

  defp concat_names(%Field{kind: :map, type: {key_type, {:message, mname}}} = field, namespace_or_nil) do
    %{field | type: {key_type, {:message, Module.concat([namespace_or_nil | mname])}}}
  end

  defp concat_names(%Field{type: {key_type, {:enum, ename}}} = field, namespace_or_nil) do
    %{field | type: {key_type, {:enum, Module.concat([namespace_or_nil | ename])}}}
  end

  defp concat_names(%Field{} = field, _), do: field

  defp make_enums(definition, prefix, descriptors) do
    Enum.reduce(descriptors, definition, fn descriptor, definition ->
      make_enum(definition, prefix, descriptor)
    end)
  end

  defp make_enum(definition, prefix, descriptor) do
    enum_name = prefix ++ camelize([descriptor.name])
    enum_constants = Enum.map(descriptor.value, &{&1.number, String.to_atom(&1.name)})

    put_in(definition, [Access.key!(:enums_schemas), enum_name], enum_constants)
  end

  defp make_messages(definition, syntax, prefix, descriptors, file_options) do
    Enum.reduce(descriptors, definition, fn descriptor, definition ->
      make_message(definition, syntax, prefix, descriptor, file_options)
    end)
  end

  defp make_message(
         definition,
         _syntax,
         _prefix,
         %DescriptorProto{options: %MessageOptions{map_entry: map_entry}},
         _file_options
       )
       when map_entry do
    # This case has already been handled in the upper message with add_maps.
    definition
  end

  defp make_message(definition, syntax, prefix, descriptor, file_options) do
    msg_name = prefix ++ camelize([descriptor.name])

    definition
    |> add_message(syntax, msg_name, file_options)
    |> make_messages(syntax, msg_name, descriptor.nested_type, file_options)
    |> make_enums(msg_name, descriptor.enum_type)
    |> add_fields(descriptor, msg_name, syntax, descriptor.field)
    |> add_fields(descriptor, msg_name, syntax, descriptor.extension)
  end

  defp add_message(definition, syntax, name, file_options) do
    put_in(definition, [Access.key!(:messages_schemas), name], %MessageSchema{
      name: name,
      syntax: syntax,
      fields: %{},
      file_options: file_options
    })
  end

  defp add_extensions(definition, syntax, fields) do
    Enum.reduce(fields, definition, fn field, definition ->
      add_field(definition, syntax, _upper = nil, fully_qualified_name(field.extendee), field)
    end)
  end

  defp add_fields(definition, upper, msg_name, syntax, fields) do
    Enum.reduce(fields, definition, fn %FieldDescriptorProto{} = descriptor, definition ->
      case descriptor.extendee do
        nil ->
          add_field(definition, syntax, upper, msg_name, descriptor)

        # When extendee is not nil, it means this field is not part of `msg_name`, but of `extendee`,
        # even though it was declared in `msg_name` -> it's a nested extension.
        extendee ->
          add_field(
            definition,
            syntax,
            _upper = nil,
            fully_qualified_name(extendee),
            descriptor,
            _extender = msg_name
          )
      end
    end)
  end

  defp add_field(definition, syntax, upper, msg_name, descriptor, extender \\ nil) do
    field_name =
      if extender do
        extender
        |> Enum.concat([descriptor.name])
        |> Enum.map_join("_", &Macro.underscore/1)
        |> String.to_atom()
      else
        String.to_atom(descriptor.name)
      end

    {label, kind, type} =
      case map_entry(upper, msg_name, descriptor) do
        nil ->
          type = get_type(descriptor)
          kind = get_kind(syntax, upper, descriptor)
          {field_label(descriptor), kind, type}

        map_type ->
          {nil, :map, map_type}
      end

    field =
      Field.new!(
        tag: descriptor.number,
        label: label,
        name: field_name,
        kind: kind,
        type: type,
        extender: extender
      )

    put_in(
      definition,
      [Access.key!(:messages_schemas), msg_name, Access.key!(:fields), field.name],
      field
    )
  end

  defp field_label(%{proto3_optional: true}), do: :proto3_optional
  defp field_label(%{label: label}), do: label

  defp map_entry(nil, _prefix, _descriptor), do: nil

  defp map_entry(upper, prefix, %FieldDescriptorProto{label: :repeated, type: :message} = descriptor) do
    # Might be a map. Now find a nested type of upper that is the corresponding entry.
    search_nested_type =
      Enum.find(upper.nested_type, fn m ->
        if m.options != nil and m.options.map_entry do
          m_name = prefix ++ [m.name]
          t_name = fully_qualified_name(descriptor.type_name)

          # Test if the generated name of the MapEntry message is the same as the one
          # referenced by the actual map field.
          m_name == t_name
        else
          false
        end
      end)

    case search_nested_type do
      nil ->
        nil

      m ->
        key_type = Enum.find(m.field, &(&1.name == "key")).type
        value_type_field = Enum.find(m.field, &(&1.name == "value"))
        value_type = get_type(value_type_field)

        {key_type, value_type}
    end
  end

  defp map_entry(_upper, _prefix, _descriptor), do: nil

  defp fully_qualified_name(name) do
    # Make sure first element is always ".".
    true = String.starts_with?(name, ".")

    name
    |> String.split(".")
    |> tl()
    |> camelize()
  end

  defp get_kind(_syntax, upper, %FieldDescriptorProto{oneof_index: index}) when index != nil do
    %OneOf{parent: String.to_atom(Enum.at(upper.oneof_decl, index).name)}
  end

  defp get_kind(_syntax, _upper, %FieldDescriptorProto{label: :repeated, options: %FieldOptions{packed: true}}) do
    :packed
  end

  defp get_kind(_syntax, _upper, %FieldDescriptorProto{label: :repeated, options: %FieldOptions{packed: false}}) do
    :unpacked
  end

  defp get_kind(:proto3, _upper, %FieldDescriptorProto{label: :repeated, type: :enum}) do
    :packed
  end

  defp get_kind(:proto3, _upper, %FieldDescriptorProto{label: :repeated, type: ty}) when is_primitive(ty) do
    :packed
  end

  defp get_kind(_syntax, _upper, %FieldDescriptorProto{label: :repeated}), do: :unpacked

  defp get_kind(_syntax, _upper, %FieldDescriptorProto{label: label} = descriptor)
       when label == :optional or label == :required do
    %Scalar{default_value: get_default_value(descriptor)}
  end

  defp get_type(%FieldDescriptorProto{type_name: tyname}) when tyname != nil do
    # Documentation in descriptor.proto says that it's possible that `type_name` is set, but not
    # `type`. The type will be resolved in a post-process pass.
    {:type_to_resolve, fully_qualified_name(tyname)}
  end

  defp get_type(descriptor), do: descriptor.type

  defp get_default_value(%FieldDescriptorProto{type: :enum, default_value: nil}) do
    :enum_default_to_resolve
  end

  defp get_default_value(%FieldDescriptorProto{type: :enum} = f) do
    String.to_atom(f.default_value)
  end

  defp get_default_value(%FieldDescriptorProto{type: :message}), do: nil

  defp get_default_value(%FieldDescriptorProto{type: ty, default_value: nil}) do
    Protox.Default.default(ty)
  end

  defp get_default_value(%FieldDescriptorProto{type: :bool, default_value: "true"}), do: true
  defp get_default_value(%FieldDescriptorProto{type: :bool, default_value: "false"}), do: false

  defp get_default_value(%FieldDescriptorProto{type: :string} = f), do: f.default_value
  defp get_default_value(%FieldDescriptorProto{type: :bytes} = f), do: f.default_value

  defp get_default_value(%FieldDescriptorProto{type: :double} = f) do
    f.default_value |> Float.parse() |> elem(0)
  end

  defp get_default_value(%FieldDescriptorProto{type: :float} = f) do
    f.default_value |> Float.parse() |> elem(0)
  end

  defp get_default_value(f) do
    f.default_value |> Integer.parse() |> elem(0)
  end

  defp camelize(name) when is_list(name) do
    Enum.map(name, &Macro.camelize/1)
  end

  defp remove_module(module) when is_atom(module) do
    :code.delete(module)
    :code.purge(module)
  end
end
