defmodule JSON.LD.Encoder do
  @moduledoc """
  An encoder for JSON-LD serializations of RDF.ex data structures.

  As for all encoders of `RDF.Serialization.Format`s, you normally won't use these
  functions directly, but via one of the `write_` functions on the `JSON.LD`
  format module or the generic `RDF.Serialization` module.


  ## Options

  - `:context`: When a context map or remote context URL string is given,
    compaction is performed using this context
  - `:base`: : Allows to specify a base URI to be used during compaction
    (only when `:context` is provided).
    Default is the base IRI of the encoded graph or if none present or in case of
    encoded datasets the `RDF.default_base_iri/0`.
  - `:use_native_types`: If this flag is set to `true`, RDF literals with a datatype IRI
    that equals `xsd:integer` or `xsd:double` are converted to a JSON numbers and
    RDF literals with a datatype IRI that equals `xsd:boolean` are converted to `true`
    or `false` based on their lexical form. (default: `false`)
  - `:use_rdf_type`: Unless this flag is set to `true`, `rdf:type` predicates will be
    serialized as `@type` as long as the associated object is either an IRI or blank
    node identifier. (default: `false`)

  The given options are also passed through to `Jason.encode/2`, so you can also
  provide any the options this function supports, most notably the `:pretty` option.

  """

  use RDF.Serialization.Encoder

  alias JSON.LD.Options

  alias RDF.{
    BlankNode,
    Dataset,
    Description,
    Graph,
    IRI,
    LangString,
    Literal,
    NS,
    XSD
  }

  import JSON.LD.Utils
  import RDF.Guards

  @type input :: Dataset.t() | Description.t() | Graph.t()

  @rdf_type to_string(RDF.NS.RDF.type())
  @rdf_value to_string(RDF.NS.RDF.value())
  @rdf_nil to_string(RDF.NS.RDF.nil())
  @rdf_first to_string(RDF.NS.RDF.first())
  @rdf_rest to_string(RDF.NS.RDF.rest())
  @rdf_list to_string(RDF.uri(RDF.NS.RDF.List))
  @rdf_direction RDF.__base_iri__() <> "direction"
  @rdf_language RDF.__base_iri__() <> "language"

  @impl RDF.Serialization.Encoder
  @spec encode(RDF.Data.t(), keyword) :: {:ok, String.t()} | {:error, any}
  def encode(data, opts \\ []) do
    opts = set_base_iri(data, opts)

    with {:ok, json_ld_object} <- from_rdf(data, opts),
         {:ok, json_ld_object} <- maybe_compact(json_ld_object, opts) do
      encode_json(json_ld_object, opts)
    end
  end

  defp set_base_iri(%Graph{base_iri: base_iri}, opts) when not is_nil(base_iri) do
    Keyword.put_new(opts, :base, IRI.to_string(base_iri))
  end

  defp set_base_iri(_, opts) do
    if base = RDF.default_base_iri() do
      Keyword.put_new(opts, :base, IRI.to_string(base))
    else
      opts
    end
  end

  defp maybe_compact(json_ld_object, opts) do
    if context = Keyword.get(opts, :context) do
      {:ok, JSON.LD.compact(json_ld_object, context, opts)}
    else
      {:ok, json_ld_object}
    end
  end

  @spec from_rdf(RDF.Data.t(), Options.t() | Enum.t()) :: {:ok, [map]} | {:error, any}
  def from_rdf(dataset, options \\ %Options{}) do
    {:ok, from_rdf!(dataset, options)}
  rescue
    exception ->
      {:error, Exception.message(exception)}
  end

  @spec from_rdf!(RDF.Data.t(), Options.t() | Enum.t()) :: [map]
  def from_rdf!(rdf_data, options \\ %Options{})

  def from_rdf!(%Dataset{} = dataset, options) do
    options = Options.new(options)

    {graph_map, referenced_once, compound_literal_subjects} =
      Enum.reduce(Dataset.graphs(dataset), {%{}, %{}, %{}}, fn
        graph, {graph_map, referenced_once, compound_literal_subjects} ->
          # 5.1)
          name = to_string(graph.name || "@default")
          # 5.2)
          graph_map = Map.put_new(graph_map, name, %{})
          # 5.3)
          compound_literal_subjects = Map.put_new(compound_literal_subjects, name, %{})

          # 5.4)
          graph_map =
            if graph.name && !get_in(graph_map, ["@default", name]) do
              Map.update(graph_map, "@default", %{name => %{"@id" => name}}, fn default_graph ->
                Map.put(default_graph, name, %{"@id" => name})
              end)
            else
              graph_map
            end

          # 5.5) & 5.6) & 5.7)
          {node_map, referenced_once, compound_map} =
            process_graph_triples(
              graph,
              graph_map[name],
              referenced_once,
              compound_literal_subjects[name],
              options
            )

          {
            Map.put(graph_map, name, node_map),
            referenced_once,
            Map.put(compound_literal_subjects, name, compound_map)
          }
      end)

    # 6)
    {graph_map, _referenced_once} =
      Enum.reduce(graph_map, {%{}, referenced_once}, fn
        {name, graph_object}, {graph_map, referenced_once} ->
          # 6.1)
          {graph_object, referenced_once} =
            process_compound_literals(
              graph_object,
              compound_literal_subjects[name],
              referenced_once
            )

          # 6.2) - 6.4)
          {graph_object, referenced_once} = convert_list(graph_object, referenced_once)

          {Map.put(graph_map, name, graph_object), referenced_once}
      end)

    # 7+8)
    graph_map
    |> Map.get("@default", %{})
    |> maybe_sort_by(options.ordered, fn {subject, _} -> subject end)
    |> Enum.reduce([], fn {subject, node}, result ->
      # 8.1)
      node =
        if Map.has_key?(graph_map, subject) do
          Map.put(
            node,
            "@graph",
            graph_map[subject]
            |> maybe_sort_by(options.ordered, fn {s, _} -> s end)
            |> Enum.reduce([], fn {_s, n}, graph_nodes ->
              n = Map.delete(n, :usages)

              if map_size(n) == 1 and Map.has_key?(n, "@id") do
                graph_nodes
              else
                [n | graph_nodes]
              end
            end)
            |> Enum.reverse()
          )
        else
          node
        end

      # 8.2)
      node = Map.delete(node, :usages)

      if map_size(node) == 1 and Map.has_key?(node, "@id") do
        result
      else
        [node | result]
      end
    end)
    |> Enum.reverse()
  end

  def from_rdf!(rdf_data, options),
    do: rdf_data |> Dataset.new() |> from_rdf!(options)

  # Process triples in the graph (steps 5.5 through 5.7)
  defp process_graph_triples(graph, node_map, referenced_once, compound_map, options) do
    Enum.reduce(graph, {node_map, referenced_once, compound_map}, fn
      {subject, predicate, object}, {node_map, referenced_once, compound_map} ->
        subject = to_string(subject)
        predicate = to_string(predicate)

        # 5.7.1)
        node_map = Map.put_new(node_map, subject, %{"@id" => subject})
        # 5.7.2)
        node = Map.get(node_map, subject)

        # 5.7.3) Handle the compound-literal direction case
        compound_map =
          if options.rdf_direction == "compound-literal" && predicate == @rdf_direction do
            Map.put(compound_map, subject, true)
          else
            compound_map
          end

        # 5.7.4)
        {object_id, node_map} =
          if is_node_object = is_rdf_resource(object) do
            object_id = to_string(object)
            node_map = Map.put_new(node_map, object_id, %{"@id" => object_id})
            {object_id, node_map}
          else
            {nil, node_map}
          end

        # 5.7.5)
        {node, node_map, referenced_once} =
          if is_node_object and !options.use_rdf_type and predicate == @rdf_type do
            node =
              Map.update(node, "@type", [object_id], fn types ->
                if object_id in types, do: types, else: types ++ [object_id]
              end)

            {node, node_map, referenced_once}
          else
            # 5.7.6)
            value = rdf_to_object(object, options)

            # 5.7.7) & 5.7.8)
            node =
              Map.update(node, predicate, [value], fn objects ->
                if value in objects, do: objects, else: objects ++ [value]
              end)

            {node_map, referenced_once} =
              cond do
                # 5.7.9)
                object_id == @rdf_nil ->
                  usage = %{node: subject, property: predicate, value: value}

                  node_map =
                    Map.update(node_map, @rdf_nil, %{usages: [usage]}, fn object_node ->
                      Map.update(object_node, :usages, [usage], &[usage | &1])
                    end)

                  {node_map, referenced_once}

                # 5.7.10)
                Map.has_key?(referenced_once, object_id) ->
                  {node_map, Map.put(referenced_once, object_id, false)}

                # 5.7.11)
                is_rdf_bnode(object) ->
                  {node_map,
                   Map.put(referenced_once, object_id, %{
                     # We're using here the node id as the reference to the respective graph map entry.
                     node: subject,
                     property: predicate,
                     value: value
                   })}

                true ->
                  {node_map, referenced_once}
              end

            {node, node_map, referenced_once}
          end

        {Map.put(node_map, subject, node), referenced_once, compound_map}
    end)
  end

  # 6.1)
  defp process_compound_literals(graph_object, nil, referenced_once),
    do: {graph_object, referenced_once}

  defp process_compound_literals(graph_object, compound_map, referenced_once) do
    Enum.reduce(compound_map, {graph_object, referenced_once}, fn
      {cl, _}, {graph_object, referenced_once} ->
        case referenced_once[cl] do
          # SPEC ISSUE: "6.1.4) Initialize value to value of value in cl entry." seems unnecessary, since value is never used
          %{node: node_id, property: property, value: _value} ->
            node = graph_object[node_id]
            # 6.1.5)
            case Map.pop(graph_object, cl) do
              {%{} = cl_node, graph_object} ->
                # 6.1.6)
                node
                |> Map.get(property, [])
                |> Enum.reduce({graph_object, referenced_once}, fn
                  %{"@id" => ^cl} = cl_reference, {graph_object, referenced_once} ->
                    cl_reference =
                      cl_reference
                      # 6.1.6.1)
                      |> Map.delete("@id")
                      # 6.1.6.2)
                      |> Map.put(
                        "@value",
                        (List.first(cl_node[@rdf_value] || []) || %{})["@value"]
                      )

                    # 6.1.6.3)
                    cl_reference =
                      case cl_node[@rdf_language] do
                        [%{"@value" => language} | _] ->
                          if not valid_language?(language) do
                            raise JSON.LD.Error.invalid_language_tagged_string(language)
                          end

                          Map.put(cl_reference, "@language", language)

                        _ ->
                          cl_reference
                      end

                    # 6.1.6.4)
                    cl_reference =
                      case cl_node[@rdf_direction] do
                        [%{"@value" => direction} | _] ->
                          if direction not in ~w[ltr rtl] do
                            raise JSON.LD.Error.invalid_base_direction(direction)
                          end

                          Map.put(cl_reference, "@direction", direction)

                        _ ->
                          cl_reference
                      end

                    {
                      update_in(graph_object, [node_id, property], fn props ->
                        Enum.map(props, fn
                          %{"@id" => ^cl} -> cl_reference
                          other -> other
                        end)
                      end),
                      Map.delete(referenced_once, cl)
                    }

                  _, {graph_object, referenced_once} ->
                    {graph_object, referenced_once}
                end)

              _ ->
                {graph_object, referenced_once}
            end

          _ ->
            {graph_object, referenced_once}
        end
    end)
  end

  #  # 6.3) - 6.4)
  defp convert_list(%{@rdf_nil => %{usages: usages}} = graph_object, referenced_once) do
    Enum.reduce(usages, {graph_object, referenced_once}, fn
      # 6.4.1)
      # Note: original_head is always an rdf:nil node
      %{node: node_id, property: property, value: original_head}, {graph_object, referenced_once} ->
        node = graph_object[node_id]

        # 6.4.2) & 6.4.3)
        {list, list_nodes, head_path, head} =
          extract_list(node, property, original_head, referenced_once, graph_object)

        updated_head =
          head
          # 6.4.4)
          |> Map.delete("@id")
          # 6.4.5) is not needed since extract_list returns the list in reverse order already
          # 6.4.6)
          |> Map.put("@list", list)

        {graph_object, referenced_once} =
          update_head(graph_object, referenced_once, head_path, head, updated_head)

        # 6.4.7)
        graph_object =
          Enum.reduce(list_nodes, graph_object, fn node_id, graph_object ->
            Map.delete(graph_object, node_id)
          end)

        {graph_object, referenced_once}
    end)
  end

  defp convert_list(graph_object, referenced_once), do: {graph_object, referenced_once}

  # 6.4.2) & 6.4.3)
  defp extract_list(
         node,
         property,
         head,
         referenced_once,
         graph_object,
         list \\ [],
         list_nodes \\ []
       )

  defp extract_list(
         %{"@id" => "_:" <> _ = id, @rdf_rest => [_rest]} = node,
         @rdf_rest = property,
         head,
         referenced_once,
         graph_object,
         list,
         list_nodes
       ) do
    do_extract_list(
      node,
      referenced_once[id],
      property,
      head,
      referenced_once,
      graph_object,
      list,
      list_nodes
    )
  end

  defp extract_list(node, property, head, _referenced_once, _graph_object, list, list_nodes) do
    {list, list_nodes, [node["@id"], property], head}
  end

  defp do_extract_list(
         %{
           "@id" => "_:" <> _ = id,
           @rdf_first => [first],
           "@type" => [@rdf_list]
         } = node,
         %{node: next_node_id, property: next_property, value: next_head},
         _property,
         _head,
         referenced_once,
         graph_object,
         list,
         list_nodes
       )
       when map_size(node) == 4 do
    extract_list(
      graph_object[next_node_id],
      next_property,
      next_head,
      referenced_once,
      graph_object,
      [first | list],
      [id | list_nodes]
    )
  end

  defp do_extract_list(
         %{
           "@id" => "_:" <> _ = id,
           @rdf_first => [first]
         } = node,
         %{node: next_node_id, property: next_property, value: next_head},
         _property,
         _head,
         referenced_once,
         graph_object,
         list,
         list_nodes
       )
       when map_size(node) == 3 do
    extract_list(
      graph_object[next_node_id],
      next_property,
      next_head,
      referenced_once,
      graph_object,
      [first | list],
      [id | list_nodes]
    )
  end

  defp do_extract_list(
         node,
         _id_ref,
         property,
         head,
         _referenced_once,
         _graph_object,
         list,
         list_nodes
       ) do
    {list, list_nodes, [node["@id"], property], head}
  end

  defp rdf_to_object(%IRI{} = iri, _) do
    %{"@id" => to_string(iri)}
  end

  defp rdf_to_object(%BlankNode{} = bnode, _) do
    %{"@id" => to_string(bnode)}
  end

  defp rdf_to_object(%Literal{literal: %datatype{}} = literal, options) do
    result = %{}
    value = Literal.value(literal)
    converted_value = literal
    type = nil

    {converted_value, type, result} =
      cond do
        options.use_native_types ->
          cond do
            datatype == XSD.String ->
              {value, type, result}

            datatype == XSD.Boolean ->
              if RDF.XSD.Boolean.valid?(literal) do
                {value, type, result}
              else
                {converted_value, NS.XSD.boolean(), result}
              end

            datatype in [XSD.Integer, XSD.Double] ->
              if Literal.valid?(literal) do
                {value, type, result}
              else
                {converted_value, type, result}
              end

            true ->
              {converted_value, Literal.datatype_id(literal), result}
          end

        options.processing_mode != "json-ld-1.0" and datatype == RDF.JSON ->
          if RDF.JSON.valid?(literal) do
            {value, "@json", result}
          else
            raise JSON.LD.Error.invalid_json_literal(literal)
          end

        (i18n_datatype_parts = i18n_datatype_parts(literal)) &&
            options.rdf_direction == "i18n-datatype" ->
          {language, direction} = i18n_datatype_parts

          {
            value,
            type,
            if language do
              Map.put(result, "@language", language)
            else
              result
            end
            |> Map.put("@direction", direction)
          }

        datatype == LangString ->
          {converted_value, type, Map.put(result, "@language", Literal.language(literal))}

        datatype == XSD.String ->
          {converted_value, type, result}

        true ->
          {Literal.lexical(literal), Literal.datatype_id(literal), result}
      end

    result = (type && Map.put(result, "@type", to_string(type))) || result

    Map.put(
      result,
      "@value",
      (match?(%Literal{}, converted_value) && Literal.lexical(converted_value)) || converted_value
    )
  end

  defp i18n_datatype_parts(%Literal{} = literal),
    do: literal |> Literal.datatype_id() |> i18n_datatype_parts()

  defp i18n_datatype_parts(%IRI{} = datatype),
    do: datatype |> to_string() |> i18n_datatype_parts()

  defp i18n_datatype_parts("https://www.w3.org/ns/i18n#" <> suffix) do
    case String.split(suffix, "_", parts: 2) do
      ["", direction] -> {nil, direction}
      [language, direction] -> {language, direction}
      _ -> nil
    end
  end

  defp i18n_datatype_parts(_), do: nil

  #  # This function is necessary because we have no references and use this instead to update the head by path
  defp update_head(graph_object, referenced_once, [subject, property], old, new) do
    {
      deep_replace(graph_object, old, new),
      case old do
        %{"@id" => head_id} ->
          Map.new(referenced_once, fn
            {^head_id, %{node: ^subject, property: ^property} = usage} ->
              {head_id, %{usage | value: new}}

            other ->
              other
          end)

        _ ->
          Map.new(referenced_once, fn
            {key, %{node: ^subject, property: ^property} = usage} -> {key, %{usage | value: new}}
            other -> other
          end)
      end
    }
  end

  defp deep_replace(old, old, new), do: new

  defp deep_replace(map, old, new) when is_map(map) do
    Map.new(map, fn
      {:usages, value} -> {:usages, value}
      {key, value} -> {key, deep_replace(value, old, new)}
    end)
  end

  defp deep_replace(list, old, new) when is_list(list),
    do: Enum.map(list, &deep_replace(&1, old, new))

  defp deep_replace(old, _, _), do: old

  defp encode_json(value, opts) do
    Jason.encode(value, opts)
  end
end
