defmodule JaSerializer.ReservedKeywordError do
  defexception [:message]

  def exception(key) do
    msg = """
    The attribute `#{key}` is a reserved keyword and cannot be used in the
    attributes/1 DSL macro.

    In order to serialize an attribute named `#{key}`, you must use the
    attributes/2 callback.
    """

    %JaSerializer.ReservedKeywordError{message: msg}
  end
end

defmodule JaSerializer.DSL do
  @moduledoc """
  A DSL for defining JSON-API.org spec compliant payloads.

  Built on top of the `JaSerializer.Serializer` behaviour.

  The following macros are available:

    * `location/1` - Define the url of a single serialized object.
    * `attributes/1` - Define the attributes to be returned.
    * `has_many/2` - Define a has_many relationship.
    * `has_one/2` - Define a has_one or belongs_to relationship.

  This module should always be used in conjunction with
  `JaSerializer.Serializer`, see `JaSerializer` for the best way to do so.

  ## DSL Usage Example

      defmodule PostSerializer do
        use JaSerializer, dsl: true

        location "/posts/:id"
        attributes [:title, :body, :excerpt, :tags]
        has_many :comments, links: [related: "/posts/:id/comments"]
        has_one :author, serializer: PersonSerializer, include: true

        def excerpt(post, _conn) do
          [first | _ ] = String.split(post.body, ".")
          first
        end
      end

      post = %Post{
        id: 1,
        title: "jsonapi.org + Elixir = Awesome APIs",
        body: "so. much. awesome.",
        author: %Person{name: "Alan"}
      }

      post
      |> PostSerializer.format
      |> Poison.encode!

  When `use`ing JaSerializer.DSL the default implementations of the `links/2`,
  `attributes/2`, and `relationships/2` callbacks will be defined on your module.

  Overriding these callbacks can be a great way to customize your serializer
  beyond what the DSL provides. See `JaSerializer.Serializer` for examples.
  """

  alias JaSerializer.Relationship.HasMany
  alias JaSerializer.Relationship.HasOne

  @doc false
  defmacro __using__(_) do
    quote do
      @attributes []
      @relations []
      @location nil

      import JaSerializer.DSL,
        only: [
          attributes: 1,
          location: 1,
          has_many: 2,
          has_one: 2,
          has_many: 1,
          has_one: 1
        ]

      unquote(define_default_attributes())
      unquote(define_default_relationships())
      unquote(define_default_links())

      @before_compile JaSerializer.DSL
    end
  end

  @doc false
  defmacro __before_compile__(env) do
    quote do
      def __relations, do: @relations
      def __location, do: @location
      def __attributes, do: @attributes

      unquote(define_inlined_attributes_map(env))
    end
  end

  @reserved_keywords [
    :id,
    :type,
    :attributes,
    :relationships,
    :links,
    :meta,
    :preload
  ]

  @error JaSerializer.ReservedKeywordError
  defp define_inlined_attributes_map(env) do
    view = env.module
    attributes = Module.get_attribute(view, :attributes)
    conn = quote do: conn
    struct = quote do: struct

    quote do
      @compile {:inline, inlined_attributes_map: 2}
      def inlined_attributes_map(unquote(struct), unquote(conn)) do
        # Construct ASL for map with keys from attributes calling the attribute fn
        unquote(
          {:%{}, [],
           Enum.map(attributes, fn k ->
             if k in @reserved_keywords do
               raise @error, k
             end

             {k,
              {{:., [], [{:__aliases__, [], [view]}, k]}, [], [struct, conn]}}
           end)}
        )
      end

      defoverridable attributes: 2
    end
  end

  defp define_default_attributes do
    quote do
      @compile {:inline, attributes: 2}
      def attributes(struct, conn) do
        inlined_attributes_map(struct, conn)
      end

      defoverridable attributes: 2
    end
  end

  defp define_default_relationships do
    quote do
      def relationships(struct, _conn) do
        JaSerializer.DSL.default_relationships(__MODULE__)
      end

      defoverridable relationships: 2
    end
  end

  @doc false
  def default_relationships(serializer) do
    serializer.__relations()
    |> Enum.map(&dsl_to_struct/1)
    |> Enum.into(%{})
  end

  defp dsl_to_struct({:has_one, name, opts}),
    do: {name, HasOne.from_dsl(name, opts)}

  defp dsl_to_struct({:has_many, name, opts}),
    do: {name, HasMany.from_dsl(name, opts)}

  defp define_default_links do
    quote do
      def links(data, conn) do
        JaSerializer.DSL.default_links(__MODULE__)
      end

      defoverridable links: 2
    end
  end

  @doc false
  def default_links(serializer) do
    %{self: serializer.__location()}
  end

  @doc """
  Defines the canonical path for retrieving this resource.

  ## String Examples

  String may be either a relative or absolute path. Path segments beginning
  with a colon are called as functions on the serializer with the struct and
  conn passed in.

      defmodule PostSerializer do
        use JaSerializer

        location "/posts/:id"
      end

      defmodule CommentSerializer do
        use JaSerializer

        location "http://api.example.com/posts/:post_id/comments/:id"

        def post_id(comment, _conn), do: comment.post_id
      end

  ## Atom Example

  When an atom is passed in, it is called as a function on the serializer with
  the struct and conn passed in. The function should return a full path/url.

      defmodule PostSerializer do
        use JaSerializer
        import MyPhoenixApp.Router.Helpers

        location :post_url

        def post_url(post, conn) do
          post_path(conn, :show, post.id)
        end
      end

  """
  defmacro location(uri) do
    quote bind_quoted: [uri: uri] do
      @location uri
    end
  end

  @doc """
  Defines a list of attributes as atoms to be included in the payload.

  An overridable function for each attribute is generated with the same name
  as the attribute. The function's default behavior is to retrieve a field with
  the same name from the struct.

  For example, if you have `attributes [:body]` a function `body/2` is defined
  on the serializer with a default behavior of `Map.get(struct, :body)`.

      defmodule PostSerializer do
        use JaSerializer, dsl: true
        attributes [:title, :body, :html]

        def html(post, _conn) do
          Earmark.to_html(post.body)
        end
      end

  ## Conditional attribute inclusion

  JaSerializer supports the `fields` option as per the JSONAPI spec. This
  option allows clients to request only the fields they want. For example if
  you only wanted the html and the title for the post:

      field_param = %{"post" => "title,html", "comment" => "html"}

      # Direct Serialization
      PostSerializer.format(post, conn, fields: field_param)

      # via PhoenixView integrations from controller
      render(conn, :show, data: post, opts: [fields: field_param])

  ## Further customization

  Further customization of the attributes returned can be handled by overriding
  the `attributes/2` callback. This can be done in conjunction with the DSL
  using super, or without the DSL just returning a map.

  """
  defmacro attributes(atts) when is_list(atts) do
    quote bind_quoted: [atts: atts] do
      # Save attributes
      @attributes @attributes ++ atts

      # Define default attribute function, make overridable
      for att <- atts do
        @compile {:inline, [{att, 1}, {att, 2}]}

        def unquote(att)(m), do: Map.get(m, unquote(att))
        def unquote(att)(m, c), do: __MODULE__.unquote(att)(m)
        defoverridable [{att, 2}, {att, 1}]
      end
    end
  end

  @doc """
  Add a has_many relationship to be serialized.

  JSONAPI.org supports three types or relationships:

    * As links - Great for clients lazy loading relationships with lots of data.
    * As "Resource Indentifiers" - A type/id pair, useful to relate to data the client already has.
    * As Included Resources - The full resource is serialized in the same request (also includes Resource Identifiers).

  Links can be combined with either resource identifiers or fully included resources.

  See http://jsonapi.org/format/#document-resource-object-relationships for more
  details on the spec.

  ## Link based relationships

  Specify a URI or path which responds with the related resource. For example:

      defmodule MyApp.PostView do
        use JaSerializer

        has_many :comments, link: :comments_link
        has_one  :author, link: "/api/posts/:id/author"

        def comments_link(post, conn) do
          MyApp.Router.Helpers.post_comment_url(conn, :index, post.id)
        end
      end

  Links can be defined with an atom or string.

  String may be either a relative or absolute path. Path segments beginning
  with a colon are called as functions on the serializer with the struct and
  conn passed in. In the above example id/2 would be called which is defined as
  a default callback.

  When an atom is passed in, it is called as a function on the serializer with
  the struct and conn passed in. The function should return a full path/url.

  Both `related` and `self` links are supported, the default `link` creates a
  related link:

      defmodule PostSerializer do
        use JaSerializer

        has_many :comments, links: [
          related: "/posts/:id/comments"
          self: "/posts/:id/relationships/comments"
        ]
      end

  ## Resource Identifiers (without including)

  Return id and type for each related object ("Resource Identifier"). For example:

      defmodule MyApp.PostView do
        use JaSerializer

        has_many :comments, serializer: MyApp.CommentView, include: false
        has_many :tags, type: "tags"
        has_one  :author, type: "user", field: :created_by_id

        # ...
      end

  When you use the `has_many` and `has_one` macros an overridable "data source"
  function is defined on your module. The data source function has the same name
  as the relationship name and accepts the struct and conn. The data source
  function should return the related struct(s) or id(s). In the example above
  the following functions are defined for you:

      def comments(post, _conn), do: Map.get(post, :comments)
      def tags(post, _conn),     do: Map.get(post, :tags)
      def author(post, _conn),   do: Map.get(post, :created_by_id)

  These data source functions are expected to return either related objects or
  ids, by default they just access the field with the same name as the
  relationship. The `field` option can be used to grab the id or struct from a
  different field in the serialized object. The author is an example of
  customizing this, and is frequently used when returning resource identifiers
  for has_one relationships when you have the foreign key in the serialized
  struct.

  In the comments example when a `serializer` plus `include: false` options are
  used the `id/2` and `type/2` functions are called on the defined serializer.

  In the tags example where just the `type` option is used the `id` field is
  automatically used on each map/struct returned by the data source.

  It is important to note that when accessing the relationship fields it is
  expected that the relationship is preloaded. For this reason you may want to
  consider using links for has_many relationships where possible.

  ## Including related data

  Returns a "Resource Identifier" (see above) as well as the fully serialized
  object in the top level `included` key. Example:

      defmodule MyApp.PostView do
        use JaSerializer

        has_many :comments, serializer: MyApp.CommentView, include: true, identifiers: :when_included
        has_many :tags,     serializer: MyApp.TagView,     include: true, identifiers: :always
        has_many :author,   serializer: MyApp.AuthorView,  include: true, field: :created_by

        # ...
      end

  Just like when working with only Resource Identifiers this will define a
  'data source' function for each relationship with an arity of two. They will
  be overridable and are expected to return maps/structs.

  ## Conditional Inclusion

  JaSerializer supports the `include` option as per the JSONAPI spec. This
  option allows clients to include only the relationships they want.
  JaSerializer handles the serialization of this for you, however you will have
  to handle intelligent preloading of relationships yourself.

  When a relationship is not loaded via includes the `identifiers` option will
  be used to determine if Resource Identifiers should be serialized or not. The
  `identifiers` options accepts the atoms `:when_included` and `:always`.

  When specifying the include param, only the relationship requested will be
  included. For example, to only include the author and comments:

      include_param = "author,comments"

      # Direct Serialization
      PostSerializer.format(post, conn, include: include_param)

      # via PhoenixView integrations from controller
      render(conn, :show, data: post, opts: [include: include_param])

  ## Further Customization

  For further customization override the `relationships/2` callback directly.

  """
  defmacro has_many(name, opts \\ []) do
    normalized_opts = normalize_relation_opts(opts, __CALLER__)

    quote do
      @relations [
        {:has_many, unquote(name), unquote(normalized_opts)} | @relations
      ]
      unquote(JaSerializer.Relationship.default_function(name, normalized_opts))
    end
  end

  @doc """
  See documentation for <a href='#has_many/2'>has_many/2</a>.

  API is the exact same with one exception:

  In the case of a belongs to relationship, JaSerializer will attempt to work
  out the resource identifier when the relationship is not included/preloaded.
  This reduces the amount of boiler plate code needed when relationship inclusion
  is specified by the client (as opposed to using `include: true` in the serializer).

  By default, JaSerializer appends `_id` to the resource name, but this can be
  overridden with the `foreign_key` option.

  In the following example, the user and author identifiers will be serialized
  even if those relationships are not preloaded.

      defmodule MyApp.PostView do
        use JaSerializer

        has_one :user, identifiers: :always, serializer: MyApp.UserView
        has_one :author, identifiers: :always, foreign_key: :user_id, serializer: MyApp.UserView
      end
  """
  defmacro has_one(name, opts \\ []) do
    normalized_opts = normalize_relation_opts(opts, __CALLER__)

    quote do
      @relations [
        {:has_one, unquote(name), unquote(normalized_opts)} | @relations
      ]
      unquote(JaSerializer.Relationship.default_function(name, normalized_opts))
    end
  end

  defp normalize_relation_opts(opts, caller) do
    include = opts[:include]

    if opts[:field] && !opts[:type] do
      IO.write(
        :stderr,
        IO.ANSI.format([
          :red,
          :bright,
          "warning: The `field` option must be used with a `type` option\n" <>
            Exception.format_stacktrace(Macro.Env.stacktrace(caller))
        ])
      )
    end

    opts =
      if opts[:link] do
        updated =
          opts
          |> Keyword.get(:links, [])
          |> Keyword.put_new(:related, opts[:link])

        Keyword.put(opts, :links, updated)
      else
        opts
      end

    case is_boolean(include) or is_nil(include) do
      true ->
        opts

      false ->
        IO.write(
          :stderr,
          IO.ANSI.format([
            :red,
            :bright,
            "warning: Specifying a non-boolean as the `include` option is " <>
              "deprecated. If you are specifying the serializer for this " <>
              "relation, use the `serializer` option instead. To always " <>
              "side-load the relationship, use `include: true` in addition to " <>
              "the `serializer` option\n" <>
              Exception.format_stacktrace(Macro.Env.stacktrace(caller))
          ])
        )

        [serializer: include, include: true] ++ opts
    end
  end
end
