# SPDX-FileCopyrightText: 2020 Zach Daniel
#
# SPDX-License-Identifier: MIT

defmodule AshJsonApi.Serializer do
  @moduledoc false

  alias Plug.Conn

  def serialize_to_many_relationship(request, source_record, relationship, records, meta) do
    links =
      %{self: at_host(request, request.url)}
      |> add_related_link(request, source_record, relationship)

    %{
      links: links,
      data:
        Enum.map(
          List.wrap(records),
          &serialize_relationship_data(&1, source_record, relationship)
        ),
      meta: meta
    }
    |> Jason.encode!()
  end

  def serialize_to_one_relationship(request, source_record, relationship, record) do
    links =
      %{self: at_host(request, request.url)}
      |> add_related_link(request, source_record, relationship)

    %{
      links: links,
      data: serialize_relationship_data(record, source_record, relationship)
    }
    |> Jason.encode!()
  end

  @spec serialize_many(
          AshJsonApi.Request.t(),
          Ash.Page.page() | list(Ash.Resource.record()),
          Keyword.t(),
          map()
        ) :: String.t()
  def serialize_many(request, paginator, includes, meta) do
    links = many_links(request, paginator)
    meta = add_page_metadata(meta, paginator)
    data = page_data(paginator, request)

    json_api = %{version: "1.0"}

    %{data: data, jsonapi: json_api, links: links}
    |> add_includes(request, includes)
    |> add_top_level_meta(meta)
    |> Jason.encode!()
  end

  defp page_data(%struct{} = page, request) when struct in [Ash.Page.Offset, Ash.Page.Keyset] do
    page_data(page.results, request)
  end

  defp page_data(data, request) when is_list(data) do
    Enum.map(data, &serialize_one_record(request, &1))
  end

  # Adds page level metadata, like total count of records
  defp add_page_metadata(meta, %struct{} = page)
       when struct in [Ash.Page.Offset, Ash.Page.Keyset] do
    page_meta =
      if page.count do
        %{page: %{total: page.count}}
      else
        %{page: %{}}
      end

    Map.merge(meta, page_meta)
  end

  # This is added because some tests on `Test.Acceptance.IndexTest` fail
  # because its passing in a list of resources instead of a paginator
  defp add_page_metadata(meta, _), do: meta

  def serialize_one(request, record, includes, meta \\ nil)

  def serialize_one(request, nil, _, meta) do
    json_api = %{version: "1.0"}
    links = one_links(request)

    %{data: nil, jsonapi: json_api, links: links}
    |> add_top_level_meta(meta)
    |> Jason.encode!()
  end

  def serialize_one(request, record, includes, meta) do
    data = serialize_one_record(request, record)
    json_api = %{version: "1.0"}
    links = one_links(request)

    %{data: data, jsonapi: json_api, links: links}
    |> add_includes(request, includes)
    |> add_top_level_meta(meta)
    |> Jason.encode!()
  end

  def serialize_errors(request, error_or_errors, meta \\ nil) do
    json_api = %{version: "1.0"}

    errors =
      error_or_errors
      |> List.wrap()
      |> Enum.map(&serialize_one_error(&1, request))

    %{errors: errors, jsonapi: json_api}
    |> add_top_level_meta(meta)
    |> Jason.encode!()
  end

  @doc false
  def serialize_one_error(error, request) do
    %{}
    |> add_if_defined(:id, error.id)
    |> add_if_defined(:status, to_string(error.status_code))
    |> add_if_defined(:code, error.code)
    |> add_if_defined(:title, error.title)
    |> add_if_defined(:detail, error.detail)
    |> add_if_defined([:source, :pointer], error.source_pointer)
    |> add_if_defined([:source, :parameter], error.source_parameter)
    |> add_if_defined(:meta, parse_error(error.meta))
    |> add_about_link(error.about, request)
  end

  defp add_about_link(payload, _, nil) do
    payload
  end

  defp add_about_link(payload, about, request) when is_bitstring(about) do
    url = at_host(request, about)
    Map.put(payload, :links, %{about: url})
  end

  defp add_about_link(payload, _, _request), do: payload

  defp add_if_defined(params, _, :undefined) do
    params
  end

  defp add_if_defined(params, [key1, key2], value) do
    params
    |> Map.put_new(key1, %{})
    |> Map.update!(key1, &Map.put(&1, key2, value))
  end

  defp add_if_defined(params, key, value) do
    Map.put(params, key, value)
  end

  defp serialize_relationship_data(record, source_record, relationship) do
    %{
      id: AshJsonApi.Resource.encode_primary_key(record),
      type: AshJsonApi.Resource.Info.type(relationship.destination)
    }
    |> add_relationship_meta(record, source_record, relationship)
  end

  defp add_relationship_meta(payload, _row, _source_record, _relationship) do
    payload
    # case relationship.join_attributes do
    #   [] ->
    #     payload

    #   attributes ->
    #     destination_value = Map.get(row, relationship.destination_attribute)

    #     source_record
    #     |> Map.get(relationship.join_relationship)
    #     |> Enum.find(fn join_row ->
    #       Map.get(join_row, relationship.destination_attribute_on_join_resource) == destination_value
    #     end)
    #     |> case do
    #       nil ->
    #         Map.put(payload, :meta, %{})

    #       join_row ->
    #         Map.put(payload, :meta, Map.take(join_row, attributes))
    #     end
    # end
  end

  defp add_top_level_meta(payload, meta) when is_map(meta), do: Map.put(payload, :meta, meta)
  defp add_top_level_meta(payload, _), do: payload

  defp add_includes(payload, %{includes_keyword: []}, _), do: payload

  defp add_includes(payload, request, includes) do
    includes = Enum.map(includes, &serialize_one_record(request, &1, top_level?: false))
    Map.put(payload, :included, includes)
  end

  defp many_links(request, %{results: _} = paginator) do
    uri = URI.parse(request.url)

    query =
      if uri.query do
        Conn.Query.decode(uri.query)
      else
        %{}
      end

    %{
      first: first_link(uri, query, paginator),
      self: many_self_link(uri, query, paginator)
    }
    |> add_last_link(uri, query, paginator)
    |> add_prev_link(uri, query, paginator)
    |> add_next_link(uri, query, paginator)
  end

  defp many_links(%{url: url}, _), do: %{self: url}

  defp first_link(uri, query, paginator) do
    paginator =
      case paginator do
        %{after: _} -> %{paginator | after: nil, before: nil}
        %{offset: _} -> %{paginator | offset: nil}
      end

    new_query =
      query
      |> put_page_params(paginator)
      |> put_count_param(paginator)
      |> Conn.Query.encode()

    uri
    |> put_query(new_query)
    |> URI.to_string()
    |> encode_link()
  end

  defp many_self_link(uri, query, paginator) do
    new_query =
      query
      |> put_page_params(paginator)
      |> put_count_param(paginator)
      |> Conn.Query.encode()

    uri
    |> put_query(new_query)
    |> URI.to_string()
    |> encode_link()
  end

  defp put_count_param(query, %{count: count}) when is_integer(count) do
    Map.update(query, "page", %{count: true}, &Map.put(&1, :count, true))
  end

  defp put_count_param(query, _), do: query

  defp put_page_params(query, %Ash.Page.Offset{} = paginator) do
    %{limit: limit, offset: offset} = paginator

    cond do
      is_nil(limit) and offset in [0, nil] ->
        query

      offset in [0, nil] ->
        Map.put(query, "page", %{
          limit: limit
        })

      is_nil(limit) ->
        Map.put(query, "page", %{
          offset: offset
        })

      true ->
        Map.put(query, "page", %{
          limit: limit,
          offset: offset
        })
    end
  end

  defp put_page_params(query, %Ash.Page.Keyset{} = paginator) do
    %{after: after_cursor, before: before_cursor, limit: limit} = paginator

    cond do
      is_nil(limit) ->
        Map.put(query, "page", %{after: after_cursor})

      is_binary(after_cursor) ->
        Map.put(query, "page", %{after: after_cursor, limit: limit})

      is_nil(limit) and is_binary(before_cursor) ->
        Map.put(query, "page", %{before: before_cursor})

      is_binary(before_cursor) ->
        Map.put(query, "page", %{before: before_cursor, limit: limit})

      true ->
        Map.put(query, "page", %{limit: limit})
    end
  end

  defp put_page_params(query, _), do: query

  # Offset pagination

  defp add_next_link(links, uri, query, %Ash.Page.Offset{} = paginator) do
    %{results: results, count: count, offset: offset, limit: limit} = paginator

    cond do
      not is_nil(count) and not is_nil(offset) and offset + limit >= count ->
        Map.put(links, :next, nil)

      not is_nil(count) and is_nil(offset) and limit >= count ->
        Map.put(links, :next, nil)

      Enum.count(results) < limit ->
        Map.put(links, :next, nil)

      true ->
        Map.put(links, :next, build_link(uri, query, next_page(paginator)))
    end
  end

  ## Cursor pagination

  defp add_next_link(links, uri, query, %Ash.Page.Keyset{} = paginator) do
    case paginator do
      # This is a query for the first page, no cursors are set
      %{results: results, after: nil, before: nil, more?: true} ->
        paginator = %{paginator | after: List.last(results).__metadata__.keyset}

        Map.put(links, :next, build_link(uri, query, paginator))

      # No results at all
      %{after: nil, before: nil, more?: false} ->
        Map.put(links, :next, nil)

      # Pagination forward with after, but no more results
      %{results: [], after: _, before: nil, more?: false} ->
        Map.put(links, :next, nil)

      # Pagination forward with after, there are results
      # If there is more, add next link, else next link is nil
      %{results: results, after: _, before: nil, more?: true} ->
        paginator = %{paginator | after: List.last(results).__metadata__.keyset}

        Map.put(links, :next, build_link(uri, query, paginator))

      # Pagination backward, there are results
      # Since we are paginating backwards from a result, we assume there will be more
      %{results: results, after: nil} ->
        paginator = %{paginator | after: List.last(results).__metadata__.keyset}

        Map.put(
          links,
          :next,
          build_link(uri, query, paginator)
        )

      _ ->
        Map.put(links, :next, nil)
    end
  end

  defp next_page(%Ash.Page.Offset{limit: nil} = paginator), do: paginator

  defp next_page(%Ash.Page.Offset{limit: limit, offset: offset} = paginator),
    do: %{paginator | offset: limit + (offset || 0)}

  # Offset pagination

  defp add_prev_link(links, uri, query, %Ash.Page.Offset{} = paginator) do
    if paginator.offset in [0, nil] do
      Map.put(links, :prev, nil)
    else
      Map.put(links, :prev, build_link(uri, query, prev_page(paginator)))
    end
  end

  ## Cursor pagination

  defp add_prev_link(links, uri, query, %Ash.Page.Keyset{} = paginator) do
    case paginator do
      # First page in request, there should be no previous links since its fetching the latest data at the point in time
      %{before: nil, after: nil} ->
        Map.put(links, :prev, nil)

      # When paginating with before, and there are no results, prev is nil
      %{results: [], before: _, after: nil} ->
        Map.put(links, :prev, nil)

      # If there are results and paginating with before,
      # Previous link is set, unless there is no more in that direction
      %{results: results, more?: true, after: nil} ->
        paginator = Map.put(paginator, :before, List.first(results).__metadata__.keyset)

        Map.put(links, :prev, build_link(uri, query, paginator))

      # If paginating fowards with after, we set the previous link to the first in the result set
      %{results: results, before: nil} ->
        paginator =
          paginator
          |> Map.put(:before, List.first(results).__metadata__.keyset)
          |> Map.put(:after, nil)

        Map.put(links, :prev, build_link(uri, query, paginator))

      _ ->
        Map.put(links, :prev, nil)
    end
  end

  defp build_link(uri, query, paginator) do
    new_query =
      query
      |> put_page_params(paginator)
      |> put_count_param(paginator)
      |> Conn.Query.encode()

    uri
    |> put_query(new_query)
    |> URI.to_string()
    |> encode_link()
  end

  defp prev_page(%Ash.Page.Offset{} = paginator) do
    offset =
      if paginator.limit do
        max(paginator.offset - (paginator.limit || 0), 0)
      else
        0
      end

    %{paginator | offset: offset}
  end

  defp add_last_link(links, _uri, _query, %{count: nil}) do
    links
  end

  defp add_last_link(links, uri, query, %Ash.Page.Offset{count: total, limit: limit} = paginator) do
    new_query =
      query
      |> Map.put("page", %{
        limit: limit,
        offset: total - limit
      })
      |> put_count_param(paginator)
      |> Conn.Query.encode()

    link =
      uri
      |> put_query(new_query)
      |> URI.to_string()
      |> encode_link()

    Map.put(links, "last", link)
  end

  defp add_last_link(links, _, _, _), do: links

  defp one_links(request) do
    %{
      self: encode_link(request.url)
    }
  end

  defp serialize_one_record(request, %resource{} = record, opts \\ []) do
    %{
      id: AshJsonApi.Resource.encode_primary_key(record),
      type: AshJsonApi.Resource.Info.type(resource),
      attributes:
        serialize_attributes(request, record, top_level?: Keyword.get(opts, :top_level?, true)),
      relationships: serialize_relationships(request, record),
      links: %{} |> add_one_record_self_link(request, record)
    }
    |> add_meta(record)
  end

  defp add_one_record_self_link(links, request, %resource{} = record) do
    resource
    |> AshJsonApi.Resource.route(request.all_domains, %{type: :get, primary?: true})
    |> case do
      nil ->
        links

      %{route: route} ->
        link =
          request
          |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)})
          |> at_host(route)

        Map.put(links, "self", link)
    end
  end

  defp add_meta(json_record, record) do
    meta =
      %{}
      |> add_keyset(record)

    Map.put(json_record, :meta, meta)
  end

  defp add_keyset(meta, %{metadata: %{keyset: keyset}}) do
    Map.put(meta, :keyset, keyset)
  end

  defp add_keyset(meta, _), do: meta

  defp serialize_relationships(request, %resource{} = record) do
    resource
    |> Ash.Resource.Info.public_relationships()
    |> Enum.into(%{}, fn relationship ->
      value =
        %{
          links: relationship_links(record, request, relationship),
          meta: %{}
        }
        |> add_linkage(record, relationship)

      {relationship.name, value}
    end)
  end

  defp relationship_links(record, request, relationship) do
    %{}
    |> add_relationship_link(request, record, relationship)
    |> add_related_link(request, record, relationship)
  end

  defp add_relationship_link(links, request, %resource{} = record, relationship) do
    resource
    |> AshJsonApi.Resource.route(request.all_domains, %{
      relationship: relationship.name,
      primary?: true,
      action_type: :relationship
    })
    |> case do
      nil ->
        links

      %{route: route} ->
        link =
          request
          |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)})
          |> at_host(route)

        Map.put(links, "self", link)
    end
  end

  defp add_related_link(links, request, %resource{} = record, relationship) do
    resource
    |> AshJsonApi.Resource.route(request.all_domains, %{
      relationship: relationship.name,
      primary?: true,
      action_type: :get_related
    })
    |> case do
      nil ->
        links

      %{route: route} ->
        link =
          request
          |> with_path_params(%{"id" => AshJsonApi.Resource.encode_primary_key(record)})
          |> at_host(route)

        Map.put(links, "related", link)
    end
  end

  defp add_linkage(payload, record, %{destination: destination, cardinality: :one, name: name}) do
    case record do
      %{__linkage__: %{^name => [record | _]}} ->
        Map.put(payload, :data, %{
          id: AshJsonApi.Resource.encode_primary_key(record),
          type: AshJsonApi.Resource.Info.type(destination)
        })

      %{__linkage__: %{^name => []}} ->
        Map.put(payload, :data, nil)

      # There could be another case here if a bug in the system gave us a list
      # of more than one shouldn't happen though

      _ ->
        payload
    end
  end

  defp add_linkage(
         payload,
         record,
         %{destination: destination, cardinality: :many, name: name} = relationship
       ) do
    case record do
      %{__linkage__: %{^name => linkage}} ->
        type = AshJsonApi.Resource.Info.type(destination)

        Map.put(
          payload,
          :data,
          linkage
          |> Enum.map(
            &(%{id: AshJsonApi.Resource.encode_primary_key(&1), type: type}
              |> add_relationship_meta(&1, record, relationship))
          )
          |> Enum.uniq_by(& &1.id)
        )

      _ ->
        payload
    end
  end

  defp with_path_params(request, params) do
    Map.update!(request, :path_params, &Map.merge(&1, params))
  end

  defp at_host(request, route) do
    path =
      if request.json_api_prefix do
        if route do
          Path.join(request.json_api_prefix, route)
        else
          request.json_api_prefix
        end
      else
        route || ""
      end

    request.url
    |> URI.parse()
    |> Map.put(:query, nil)
    |> Map.put(:path, "/" <> path)
    |> Map.update!(:path, &replace_path_params(&1, request))
    |> URI.to_string()
    |> encode_link()
  end

  defp put_query(uri, query) do
    if query == "" do
      Map.put(uri, :query, nil)
    else
      Map.put(uri, :query, query)
    end
  end

  defp replace_path_params(path, request) do
    path
    |> Path.split()
    |> Enum.map(fn path_element ->
      if String.starts_with?(path_element, ":") do
        Map.get(request.path_params, String.slice(path_element, 1..-1//1)) || ""
      else
        path_element
      end
    end)
    |> Path.join()
  end

  defp field_type_from_aggregate(resource, agg) do
    if agg.field do
      related = Ash.Resource.Info.related(resource, agg.relationship_path)
      field = Ash.Resource.Info.field(related, agg.field)

      if field do
        {field.type, field.constraints}
      end
    end
  end

  defp serialize_attributes(_, nil, _opts), do: nil

  defp serialize_attributes(request, records, opts) when is_list(records) do
    Enum.map(records, &serialize_attributes(request, &1, opts))
  end

  defp serialize_attributes(request, %resource{} = record, opts) do
    load = Keyword.get(opts, :load, [])

    load_fields =
      load
      |> Enum.map(fn
        {key, _} -> key
        key -> key
      end)

    fields =
      if opts[:top_level?] do
        Map.get(request.fields, resource) || Map.get(request.route, :default_fields) ||
          default_attributes(resource)
      else
        Map.get(request.fields, resource) ||
          default_attributes(resource)
      end
      |> Enum.concat(load_fields)

    Enum.reduce(fields, %{}, fn field_name, acc ->
      field = Ash.Resource.Info.field(resource, field_name)

      {type, constraints} =
        case field do
          %Ash.Resource.Aggregate{} = agg ->
            case field_type_from_aggregate(resource, agg) do
              {field_type, field_constraints} ->
                {:ok, type, constraints} =
                  Ash.Query.Aggregate.kind_to_type(agg.kind, field_type, field_constraints)

                {type, constraints}

              _ ->
                {:ok, type, constraints} =
                  Ash.Query.Aggregate.kind_to_type(agg.kind, nil, nil)

                {type, constraints}
            end

          %relationship{destination: destination}
          when relationship in [
                 Ash.Resource.Relationships.HasMany,
                 Ash.Resource.Relationships.ManyToMany
               ] ->
            {{:array, destination}, []}

          %relationship{destination: destination}
          when relationship in [
                 Ash.Resource.Relationships.HasOne,
                 Ash.Resource.Relationships.BelongsTo
               ] ->
            {destination, []}

          nil ->
            {:string, []}

          attr ->
            {attr.type, attr.constraints}
        end

      cond do
        AshJsonApi.Resource.only_primary_key?(resource, field_name) &&
            Keyword.get(opts, :skip_only_primary_key?, true) ->
          acc

        !field ->
          acc

        match?(%Ash.Resource.Relationships.HasMany{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        match?(%Ash.Resource.Relationships.HasOne{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        match?(%Ash.Resource.Relationships.BelongsTo{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        match?(%Ash.Resource.Relationships.ManyToMany{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        match?(%Ash.Resource.Calculation{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        match?(%Ash.Resource.Aggregate{}, field) &&
            match?(%Ash.NotLoaded{}, Map.get(record, field.name)) ->
          acc

        true ->
          new_load =
            load
            |> Enum.find_value([], fn
              {^field_name, value} -> value
              _ -> nil
            end)

          new_opts = opts |> Keyword.put(:load, new_load)

          value =
            serialize_value(
              Map.get(record, field.name),
              type,
              constraints,
              request.domain,
              new_opts
            )

          if not is_nil(value) or include_nil_values?(request, record) do
            Map.put(acc, field.name, value)
          else
            acc
          end
      end
    end)
  end

  @doc false
  def serialize_value(value, type, constraints, domain, opts \\ [])

  def serialize_value(nil, _, _, _, _), do: nil

  def serialize_value(value, {:array, type}, constraints, domain, opts) when is_list(value) do
    Enum.map(value, &serialize_value(&1, type, constraints[:items] || [], domain, opts))
  end

  def serialize_value(value, Ash.Type.Decimal, _constraints, _domain, _opts) do
    Decimal.to_string(value)
  end

  def serialize_value(value, type, constraints, domain, opts) do
    {type, constraints} = flatten_new_type(type, constraints || [])
    opts = [skip_only_primary_key?: false, top_level?: false] |> Keyword.merge(opts)

    with Ash.Type.Struct <- type,
         instance_of when not is_nil(instance_of) <- constraints[:instance_of],
         true <- Ash.Resource.Info.resource?(instance_of) do
      req = %{fields: %{}, route: %{}, domain: domain}
      serialize_attributes(req, value, opts)
    else
      _ ->
        if Ash.Resource.Info.resource?(type) do
          req = %{fields: %{}, route: %{}, domain: domain}
          serialize_attributes(req, value, opts)
        else
          value
        end
    end
  end

  defp flatten_new_type(type, constraints) do
    if Ash.Type.NewType.new_type?(type) do
      new_constraints = Ash.Type.NewType.constraints(type, constraints)
      new_type = Ash.Type.NewType.subtype_of(type)

      {new_type, new_constraints}
    else
      {type, constraints}
    end
  end

  defp include_nil_values?(request, %resource{} = _record) do
    # Whether we include nil values in the output depends on the include_nil_values?
    # setting of the resource, or if it isn't set the include_nil_values? setting of
    # the API.
    case AshJsonApi.Resource.Info.include_nil_values?(resource) do
      nil -> AshJsonApi.Domain.Info.include_nil_values?(request.domain)
      val -> val
    end
  end

  # make this always false in 2.0
  if Application.compile_env(:ash_json_api, :show_public_calculations_when_loaded?, true) do
    defp default_attributes(resource) do
      AshJsonApi.Resource.Info.default_fields(resource) ||
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Enum.concat(Ash.Resource.Info.public_calculations(resource))
        |> Enum.map(& &1.name)
    end
  else
    defp default_attributes(resource) do
      AshJsonApi.Resource.Info.default_fields(resource) ||
        resource
        |> Ash.Resource.Info.public_attributes()
        |> Enum.map(& &1.name)
    end
  end

  defp encode_link(value) do
    value
    # value
    # |> URI.parse()
    # |> Map.update(:query, nil, fn query ->
    #   URI.encode_www_form(query)
    # end)
    # |> URI.to_string()
  end

  defp parse_error(%{match: %Regex{} = match} = error) do
    %{error | match: Regex.source(match)}
  end

  defp parse_error(error), do: error
end
