defmodule Record do
  @moduledoc """
  Module to work with, define, and import records.

  Records are simply tuples where the first element is an atom:

      iex> Record.is_record {User, "john", 27}
      true

  This module provides conveniences for working with records at
  compilation time, where compile-time field names are used to
  manipulate the tuples, providing fast operations on top of
  the tuples' compact structure.

  In Elixir, records are used mostly in two situations:

    1. to work with short, internal data
    2. to interface with Erlang records

  The macros `defrecord/3` and `defrecordp/3` can be used to create records
  while `extract/2` and `extract_all/1` can be used to extract records from
  Erlang files.

  ## Types

  Types can be defined for tuples with the `record/2` macro (only available in
  typespecs). This macro will expand to a tuple as seen in the example below:

      defmodule MyModule do
        require Record
        Record.defrecord :user, name: "john", age: 25

        @type user :: record(:user, name: String.t, age: integer)
        # expands to: "@type user :: {:user, String.t, integer}"
      end

  """

  @doc """
  Extracts record information from an Erlang file.

  Returns a quoted expression containing the fields as a list
  of tuples.

  `name`, which is the name of the extracted record, is expected to be an atom
  *at compile time*.

  ## Options

  This function accepts the following options, which are exclusive to each other
  (i.e., only one of them can be used in the same call):

    * `:from` - (binary representing a path to a file) path to the Erlang file
      that contains the record definition to extract; with this option, this
      function uses the same path lookup used by the `-include` attribute used in
      Erlang modules.
    * `:from_lib` - (binary representing a path to a file) path to the Erlang
      file that contains the record definition to extract; with this option,
      this function uses the same path lookup used by the `-include_lib`
      attribute used in Erlang modules.

  These options are expected to be literals (including the binary values) at
  compile time.

  ## Examples

      iex> Record.extract(:file_info, from_lib: "kernel/include/file.hrl")
      [size: :undefined, type: :undefined, access: :undefined, atime: :undefined,
       mtime: :undefined, ctime: :undefined, mode: :undefined, links: :undefined,
       major_device: :undefined, minor_device: :undefined, inode: :undefined,
       uid: :undefined, gid: :undefined]

  """
  def extract(name, opts) when is_atom(name) and is_list(opts) do
    Record.Extractor.extract(name, opts)
  end

  @doc """
  Extracts all records information from an Erlang file.

  Returns a keyword list of `{record_name, fields}` tuples where `record_name`
  is the name of an extracted record and `fields` is a list of `{field, value}`
  tuples representing the fields for that record.

  ## Options

  This function accepts the following options, which are exclusive to each other
  (i.e., only one of them can be used in the same call):

    * `:from` - (binary representing a path to a file) path to the Erlang file
      that contains the record definitions to extract; with this option, this
      function uses the same path lookup used by the `-include` attribute used in
      Erlang modules.
    * `:from_lib` - (binary representing a path to a file) path to the Erlang
      file that contains the record definitions to extract; with this option,
      this function uses the same path lookup used by the `-include_lib`
      attribute used in Erlang modules.

  These options are expected to be literals (including the binary values) at
  compile time.
  """
  def extract_all(opts) when is_list(opts) do
    Record.Extractor.extract_all(opts)
  end

  @doc """
  Checks if the given `data` is a record of kind `kind`.

  This is implemented as a macro so it can be used in guard clauses.

  ## Examples

      iex> record = {User, "john", 27}
      iex> Record.is_record(record, User)
      true

  """
  defmacro is_record(data, kind) do
    case Macro.Env.in_guard?(__CALLER__) do
      true ->
        quote do
          is_atom(unquote(kind)) and is_tuple(unquote(data)) and tuple_size(unquote(data)) > 0 and
            elem(unquote(data), 0) == unquote(kind)
        end
      false ->
        quote do
          result = unquote(data)
          kind = unquote(kind)
          is_atom(kind) and is_tuple(result) and tuple_size(result) > 0 and elem(result, 0) == kind
        end
    end
  end

  @doc """
  Checks if the given `data` is a record.

  This is implemented as a macro so it can be used in guard clauses.

  ## Examples

      iex> record = {User, "john", 27}
      iex> Record.is_record(record)
      true
      iex> tuple = {}
      iex> Record.is_record(tuple)
      false

  """
  defmacro is_record(data) do
    case Macro.Env.in_guard?(__CALLER__) do
      true ->
        quote do
          is_tuple(unquote(data)) and tuple_size(unquote(data)) > 0 and
            is_atom(elem(unquote(data), 0))
        end
      false ->
        quote do
          result = unquote(data)
          is_tuple(result) and tuple_size(result) > 0 and is_atom(elem(result, 0))
        end
    end
  end

  @doc """
  Defines a set of macros to create, access, and pattern match
  on a record.

  The name of the generated macros will be `name` (which has to be an
  atom). `tag` is also an atom and is used as the "tag" for the record (i.e.,
  the first element of the record tuple); by default (if `nil`), it's the same
  as `name`. `kv` is a keyword list of `name: default_value` fields for the
  new record.

  The following macros are generated:

    * `name/0` to create a new record with default values for all fields
    * `name/1` to create a new record with the given fields and values,
      to get the zero-based index of the given field in a record or to
      convert the given record to a keyword list
    * `name/2` to update an existing record with the given fields and values
      or to access a given field in a given record

  All these macros are public macros (as defined by `defmacro`).

  See the "Examples" section for examples on how to use these macros.

  ## Examples

      defmodule User do
        require Record
        Record.defrecord :user, [name: "meg", age: "25"]
      end

  In the example above, a set of macros named `user` but with different
  arities will be defined to manipulate the underlying record.

      # Import the module to make the user macros locally available
      import User

      # To create records
      record = user()        #=> {:user, "meg", 25}
      record = user(age: 26) #=> {:user, "meg", 26}

      # To get a field from the record
      user(record, :name) #=> "meg"

      # To update the record
      user(record, age: 26) #=> {:user, "meg", 26}

      # To get the zero-based index of the field in record tuple
      # (index 0 is occupied by the record "tag")
      user(:name) #=> 1

      # Convert a record to a keyword list
      user(record) #=> [name: "meg", age: 26]

  The generated macros can also be used in order to pattern match on records and
  to bind variables during the match:

      record = user() #=> {:user, "meg", 25}

      user(name: name) = record
      name #=> "meg"

  By default, Elixir uses the record name as the first element of the tuple (the
  "tag"). However, a different tag can be specified when defining a record:

      defmodule User do
        require Record
        Record.defrecord :user, User, name: nil
      end

      require User
      User.user() #=> {User, nil}

  ## Defining extracted records with anonymous functions in the values

  If a record defines an anonymous function in the default values, an
  `ArgumentError` will be raised. This can happen unintentionally when defining
  a record after extracting it from an Erlang library that uses anonymous
  functions for defaults.

      Record.defrecord :my_rec, Record.extract(...)
      #=> ** (ArgumentError) invalid value for record field fun_field,
      cannot escape #Function<12.90072148/2 in :erl_eval.expr/5>.

  To work around this error, redefine the field with your own &M.f/a function,
  like so:

      defmodule MyRec do
        require Record
        Record.defrecord :my_rec, Record.extract(...) |> Keyword.merge(fun_field: &__MODULE__.foo/2)
        def foo(bar, baz), do: IO.inspect({bar, baz})
      end

  """
  defmacro defrecord(name, tag \\ nil, kv) do
    quote bind_quoted: [name: name, tag: tag, kv: kv] do
      tag = tag || name
      fields = Record.__fields__(:defrecord, kv)

      defmacro(unquote(name)(args \\ [])) do
        Record.__access__(unquote(tag), unquote(fields), args, __CALLER__)
      end

      defmacro(unquote(name)(record, args)) do
        Record.__access__(unquote(tag), unquote(fields), record, args, __CALLER__)
      end
    end
  end

  @doc """
  Same as `defrecord/3` but generates private macros.
  """
  defmacro defrecordp(name, tag \\ nil, kv) do
    quote bind_quoted: [name: name, tag: tag, kv: kv] do
      tag = tag || name
      fields = Record.__fields__(:defrecordp, kv)

      defmacrop(unquote(name)(args \\ [])) do
        Record.__access__(unquote(tag), unquote(fields), args, __CALLER__)
      end

      defmacrop(unquote(name)(record, args)) do
        Record.__access__(unquote(tag), unquote(fields), record, args, __CALLER__)
      end
    end
  end

  # Normalizes of record fields to have default values.
  @doc false
  def __fields__(type, fields) do
    :lists.map(fn
      {key, val} when is_atom(key) ->
        try do
          Macro.escape(val)
        rescue
          e in [ArgumentError] ->
            raise ArgumentError, "invalid value for record field #{key}, " <> Exception.message(e)
        else
          val -> {key, val}
        end
      key when is_atom(key) ->
        {key, nil}
      other ->
        raise ArgumentError, "#{type} fields must be atoms, got: #{inspect other}"
    end, fields)
  end

  # Callback invoked from record/0 and record/1 macros.
  @doc false
  def __access__(atom, fields, args, caller) do
    cond do
      is_atom(args) ->
        index(atom, fields, args)
      Keyword.keyword?(args) ->
        create(atom, fields, args, caller)
      true ->
        case Macro.expand(args, caller) do
          {:{}, _, [^atom | list]} when length(list) == length(fields) ->
            record = List.to_tuple([atom | list])
            Record.__keyword__(atom, fields, record)
          {^atom, arg} when length(fields) == 1 ->
            Record.__keyword__(atom, fields, {atom, arg})
          _ ->
            quote do: Record.__keyword__(unquote(atom), unquote(fields), unquote(args))
        end
    end
  end

  # Callback invoked from the record/2 macro.
  @doc false
  def __access__(atom, fields, record, args, caller) do
    cond do
      is_atom(args) ->
        get(atom, fields, record, args)
      Keyword.keyword?(args) ->
        update(atom, fields, record, args, caller)
      true ->
        msg = "expected arguments to be a compile time atom or keywords, got: #{Macro.to_string args}"
        raise ArgumentError, msg
    end
  end

  # Gets the index of field.
  defp index(atom, fields, field) do
    if index = find_index(fields, field, 0) do
      index - 1 # Convert to Elixir index
    else
      raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect field}"
    end
  end

  # Creates a new record with the given default fields and keyword values.
  defp create(atom, fields, keyword, caller) do
    in_match = Macro.Env.in_match?(caller)
    keyword = apply_underscore(fields, keyword)

    {match, remaining} =
      Enum.map_reduce(fields, keyword, fn({field, default}, each_keyword) ->
        new_fields =
          case Keyword.fetch(each_keyword, field) do
            {:ok, value} -> value
            :error when in_match -> {:_, [], nil}
            :error -> Macro.escape(default)
          end

        {new_fields, Keyword.delete(each_keyword, field)}
      end)

    case remaining do
      [] ->
        {:{}, [], [atom | match]}
      _  ->
        keys = for {key, _} <- remaining, do: key
        raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect hd(keys)}"
    end
  end

  # Updates a record given by var with the given keyword.
  defp update(atom, fields, var, keyword, caller) do
    if Macro.Env.in_match?(caller) do
      raise ArgumentError, "cannot invoke update style macro inside match"
    end

    keyword = apply_underscore(fields, keyword)

    Enum.reduce keyword, var, fn({key, value}, acc) ->
      index = find_index(fields, key, 0)
      if index do
        quote do
          :erlang.setelement(unquote(index), unquote(acc), unquote(value))
        end
      else
        raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect key}"
      end
    end
  end

  # Gets a record key from the given var.
  defp get(atom, fields, var, key) do
    index = find_index(fields, key, 0)
    if index do
      quote do
        :erlang.element(unquote(index), unquote(var))
      end
    else
      raise ArgumentError, "record #{inspect atom} does not have the key: #{inspect key}"
    end
  end

  defp find_index([{k, _} | _], k, i), do: i + 2
  defp find_index([{_, _} | t], k, i), do: find_index(t, k, i + 1)
  defp find_index([], _k, _i), do: nil

  # Returns a keyword list of the record
  @doc false
  def __keyword__(atom, fields, record) do
    if is_record(record, atom) do
      [_tag | values] = Tuple.to_list(record)
      case join_keyword(fields, values, []) do
        kv when is_list(kv) ->
          kv
        expected_fields ->
          msg = "expected argument to be a #{inspect atom} record with #{expected_fields} fields, got: #{inspect record}"
          raise ArgumentError, msg
      end
    else
      msg = "expected argument to be a literal atom, literal keyword or a #{inspect atom} record, got runtime: #{inspect record}"
      raise ArgumentError, msg
    end
  end

  # Returns a keyword list, or expected number of fields on size mismatch
  defp join_keyword([{field, _default} | fields], [value | values], acc),
    do: join_keyword(fields, values, [{field, value} | acc])
  defp join_keyword([], [], acc),
    do: :lists.reverse(acc)
  defp join_keyword(rest_fields, _rest_values, acc),
    do: length(acc) + length(rest_fields) # expected fields

  defp apply_underscore(fields, keyword) do
    case Keyword.fetch(keyword, :_) do
      {:ok, default} ->
        fields
        |> Enum.map(fn {k, _} -> {k, default} end)
        |> Keyword.merge(keyword)
        |> Keyword.delete(:_)
      :error ->
        keyword
    end
  end
end
