defmodule Eflatbuffers.Writer do
  alias Eflatbuffers.Utils

  def write({ _, %{ default: same } }, same, _, _) do
    []
  end

  def write({ _, _ }, nil, _, _) do
    []
  end

  def write({ :bool, _options }, true, _, _) do
    << 1 >>
  end

  def write({ :bool, _options }, false, _, _) do
    << 0 >>
  end
  def write({ :byte, _options }, byte, _, _) when is_integer(byte) and byte >= -128 and byte <= 127 do
    << byte :: signed-size(8) >>
  end

  def write({ :ubyte, _options }, byte, _, _) when is_integer(byte) and byte >= 0 and byte <= 255 do
    << byte :: unsigned-size(8) >>
  end

  def write({ :short, _options }, integer, _, _) when is_integer(integer) and integer <= 32_767 and integer >= -32_768 do
    << integer :: signed-little-size(16) >>
  end

  def write({ :ushort, _options }, integer, _, _) when is_integer(integer) and integer >= 0 and integer <= 65536 do
    << integer :: unsigned-little-size(16) >>
  end

  def write({ :int, _options }, integer, _, _) when is_integer(integer) and integer >= -2_147_483_648 and integer <= 2_147_483_647 do
    << integer :: signed-little-size(32) >>
  end

  def write({ :uint, _options }, integer, _, _) when is_integer(integer) and integer >= 0 and integer <= 4_294_967_295 do
    << integer :: unsigned-little-size(32) >>
  end

  def write({ :float, _options }, float, _, _) when (is_float(float) or is_integer(float)) and float >= -3.4E+38 and float <= +3.4E+38 do
    << float :: float-little-size(32) >>
  end

  def write({ :long, _options }, integer, _, _) when is_integer(integer) and integer >= -9_223_372_036_854_775_808 and integer <= 9_223_372_036_854_775_807 do
    << integer :: signed-little-size(64) >>
  end

  def write({ :ulong, _options }, integer, _, _) when is_integer(integer) and integer >= 0 and integer <= 18_446_744_073_709_551_615 do
    << integer :: unsigned-little-size(64) >>
  end

  def write({ :double, _options }, float, _, _) when (is_float(float) or is_integer(float)) and float >= -1.7E+308 and float <= +1.7E+308 do
    << float :: float-little-size(64) >>
  end

  # complex types

  def write({ :string, _options }, string, _, _) when is_binary(string) do
    << byte_size(string) :: unsigned-little-size(32) >> <> string
  end

  def write({:vector, options}, values, path, schema) when is_list(values) do
    {type, type_options} = options.type
    vector_length        = length(values)
    # we are putting the indices as [i] as a type
    # so if something goes wrong it's easy to see
    # that it was a vector index
    type_options_without_default = Map.put(type_options, :default, nil)
    index_types = for i <- :lists.seq(0, (vector_length - 1)), do: {[i], {type, type_options_without_default}}
    [ << vector_length :: little-size(32) >>, data_buffer_and_data(index_types, values, path, schema) ]
  end

  def write({:enum, options = %{ name: enum_name }}, value, path, {tables, _} = schema) when is_binary(value) do
    {:enum, enum_options} =  Map.get(tables, enum_name)
    members = enum_options.members
    {type, type_options}    = enum_options.type
    # if we got handed some defaults from outside,
    # we put them in here
    type_options = Map.merge(type_options, options)
    value_atom = :erlang.binary_to_existing_atom(value, :utf8)
    index = Map.get(members, value_atom)
    case index do
      nil -> throw({:error, {:not_in_enum, value_atom, members}})
      _   -> write({type, type_options}, index, path, schema)
    end
  end

  # write a complete table
  def write({:table, %{ name: table_name }}, map, path, {tables, _options} = schema) when is_map(map) and is_atom(table_name) do
    {:table, options} = Map.get(tables, table_name)
    fields            = options.fields
    {names_types, values} =
      Enum.reduce(
        Enum.reverse(fields),
        {[], []},
        fn({name, {:union, %{ name: union_name }}}, {type_acc, value_acc}) ->
            {:union, options} = Map.get(tables, union_name)
            members           = options.members
            case Map.get(map, String.to_atom(Atom.to_string(name) <> "_type")) do
              nil ->
                type_acc_new  = [{{name}, { :byte, %{ default: 0 }}} | type_acc]
                value_acc_new = [ 0 | value_acc]
                {type_acc_new, value_acc_new}
              union_type ->
                union_type   = String.to_atom(union_type)
                union_index  = Map.get(members, union_type)
                type_acc_new  = [{{name}, { :byte, %{ default: 0 }}} | [{name, {:table, %{ name: union_type }}} | type_acc]]
                value_acc_new = [union_index + 1 | [Map.get(map, name) | value_acc]]
                {type_acc_new, value_acc_new}
            end
          ({name, type}, {type_acc, value_acc}) ->
            {[{{name}, type} | type_acc], [Map.get(map, name) | value_acc]}
        end
      )
    # we are putting the keys as {key} as a type
    # so if something goes wrong it's easy to see
    # that it was a map key
    [data_buffer, data] = data_buffer_and_data(names_types, values, path, schema)
    vtable              = vtable(data_buffer)
    springboard         = << (:erlang.iolist_size(vtable) + 4) :: little-size(32) >>
    data_buffer_length  = << :erlang.iolist_size([springboard, data_buffer]) :: little-size(16) >>
    vtable_length       = << :erlang.iolist_size([vtable, springboard])      :: little-size(16) >>
    [vtable_length, data_buffer_length, vtable, springboard, data_buffer, data]
  end

  # fail if nothing matches
  def write({ type, _options }, data, path, _) do
    throw({:error, {:wrong_type, type, data, Enum.reverse(path)}})
  end

  # build up [data_buffer, data]
  # as part of a table or vector
  def data_buffer_and_data(types, values, path, schema) do
    data_buffer_and_data(types, values, path, schema, {[], [], 0})
  end
  def data_buffer_and_data([], [], _path, _schema, {data_buffer, data, _}) do
    [adjust_for_length(data_buffer), Enum.reverse(data)]
  end

  # value is nil so we put a null pointer
  def data_buffer_and_data([_type | types], [nil | values], path, schema, {scalar_and_pointers, data, data_offset}) do
    data_buffer_and_data(types, values, path, schema, {[[] | scalar_and_pointers], data, data_offset})
  end
  def data_buffer_and_data([{name, type} | types], [value | values], path, schema, {scalar_and_pointers, data, data_offset}) do
    # for clean error reporting we
    # need to accumulate the names of tables (depth)
    # but not the indices for vectors (width)
    case Utils.scalar?(type) do
      true ->
        scalar_data = write(type, value, [name|path], schema)
        data_buffer_and_data(types, values, path, schema, {[scalar_data | scalar_and_pointers], data, data_offset})
      false ->
        complex_data = write(type, value, [name|path], schema)
        complex_data_length = :erlang.iolist_size(complex_data)
        # for a table we do not point to the start but to the springboard
        data_pointer =
          case type do
            {:table, _} ->
              [vtable_length, data_buffer_length, vtable | _] = complex_data
              table_header_offset = :erlang.iolist_size([vtable_length, data_buffer_length, vtable])
              data_offset + table_header_offset
            _ ->
              data_offset
          end
        data_buffer_and_data(types, values, path, schema, {[data_pointer | scalar_and_pointers], [complex_data | data], complex_data_length + data_offset})
    end
  end

  # so this is a mix of scalars (binary)
  # and unadjusted pointers (integers)
  # we adjust the pointers to account
  # for their poisition in the buffer
  def adjust_for_length(data_buffer) do
    adjust_for_length(data_buffer, {[], 0})
  end

  def adjust_for_length([], {acc, _}) do
    acc
  end

  # this is null pointers, we pass
  def adjust_for_length([[] | data_buffer], {acc, offset}) do
    adjust_for_length(data_buffer, {[[]|acc], offset})
  end
  # this is a scalar, we just pass the data
  def adjust_for_length([scalar | data_buffer], {acc, offset}) when is_binary(scalar) do
    adjust_for_length(data_buffer, {[scalar | acc], offset + byte_size(scalar)})
  end

  # referenced data, we get it and recurse
  def adjust_for_length([pointer | data_buffer], {acc, offset}) when is_integer(pointer) do
    offset_new = offset + 4
    pointer_bin = << (pointer + offset_new) :: little-size(32) >>
    adjust_for_length(data_buffer, {[pointer_bin | acc], offset_new})
  end

  # we get a nested structure so we pass it untouched
  def adjust_for_length([iolist | data_buffer], {acc, offset}) when is_list(iolist) do
    adjust_for_length(data_buffer, {[iolist | acc], offset + 4})
  end


  def vtable(data_buffer) do
    Enum.reverse(vtable(data_buffer, {[], 4}))
  end

  def vtable([], {acc, _offset}) do
    acc
  end
  def vtable([data | data_buffer], {acc, offset}) do
    case data do
      [] ->
        # this is an undefined value, we put a null pointer
        # and leave the offset untouched
        vtable(data_buffer, {[<< 0 :: little-size(16) >> | acc ], offset })
      scalar_or_pointer ->
        vtable(data_buffer, {[<< offset :: little-size(16) >> | acc ], offset + :erlang.iolist_size(scalar_or_pointer) })
    end
  end

  def scalar?(:string),      do: false
  def scalar?({:vector, _}), do: false
  def scalar?({:table,  _}), do: false
  def scalar?({:enum,  _}),  do: true
  def scalar?(_),            do: true

end
