defmodule JSON.LD.Context do
  @moduledoc """
  Implementation of the JSON-LD 1.1 _Context Processing Algorithm_.

  <https://www.w3.org/TR/json-ld11-api/#context-processing-algorithms>
  """

  import JSON.LD.{IRIExpansion, Utils}

  alias JSON.LD.Context.TermDefinition
  alias JSON.LD.{DocumentLoader, Options}

  alias RDF.IRI

  @type local :: map | String.t() | nil
  @type remote :: [map]

  @type t :: %__MODULE__{
          term_defs: map,
          base_iri: String.t() | nil | :not_present,
          original_base_url: String.t() | nil,
          api_base_iri: String.t() | nil,
          inverse_context: map | nil,
          previous_context: t | nil,
          vocabulary_mapping: String.t() | nil,
          default_language: String.t() | nil,
          base_direction: String.t() | nil
        }

  defstruct term_defs: %{},
            base_iri: :not_present,
            original_base_url: nil,
            # This is the base IRI set via options
            api_base_iri: nil,
            vocabulary_mapping: nil,
            default_language: nil,
            base_direction: nil,
            inverse_context: nil,
            previous_context: nil

  @max_contexts_loaded Application.compile_env(:json_ld, :max_contexts_loaded, 50)

  @spec base(t) :: String.t() | nil
  def base(%__MODULE__{base_iri: :not_present, api_base_iri: api_base_iri}),
    do: api_base_iri

  def base(%__MODULE__{base_iri: base_iri}),
    do: base_iri

  @spec language(t, String.t()) :: String.t() | nil
  def language(active, term) do
    case Map.get(active.term_defs, term, %TermDefinition{}).language_mapping do
      false -> active.default_language
      language -> language
    end
  end

  @spec new(Options.convertible()) :: t
  def new(options \\ %Options{}),
    do: %__MODULE__{api_base_iri: Options.new(options).base}

  @spec create(map, Options.convertible()) :: t
  def create(%{"@context" => json_ld_context}, options),
    do: options |> new() |> update(json_ld_context, options)

  defp init_options(options) do
    options
    # used to detect cyclical context inclusions
    |> Keyword.put_new(:remote_contexts, [])
    # used to allow changes to protected terms,
    |> Keyword.put_new(:override_protected, false)
    # to mark term definitions associated with non-propagated contexts
    |> Keyword.put_new(:propagate, true)
    #  used to limit recursion when validating possibly recursive scoped contexts..
    |> Keyword.put_new(:validate_scoped_context, true)
  end

  @spec update(t, [local] | local, Options.convertible()) :: t
  def update(active, local, options \\ [])

  def update(active, local, %Options{} = processor_options) do
    update(active, local, init_options([]), processor_options)
  end

  def update(active, local, options) do
    {processor_options, options} =
      case Keyword.pop(options, :processor_options) do
        {nil, options} -> Options.extract(options)
        {processor_options, options} -> {struct(processor_options, options), options}
      end

    update(active, local, init_options(options), processor_options)
  end

  @spec update(t, [local] | local, keyword, Options.convertible()) :: t
  def update(%__MODULE__{} = active, local, options, processor_options) when is_list(local) do
    # 1) Initialize result to the result of cloning active context, with inverse context set to null
    result = %{active | inverse_context: nil}

    # 3) If propagate is false, and result does not have a previous context, set previous context in result to active context
    result =
      if options[:propagate] == false && is_nil(active.previous_context) do
        %{result | previous_context: active}
      else
        result
      end

    {remote, options} = options |> init_options() |> Keyword.pop(:remote_contexts)
    # 5) For each item context in local context
    Enum.reduce(local, result, fn context, result ->
      do_update(result, context, remote, options, processor_options)
    end)
  end

  # 2) If local context has a @propagate entry, its value MUST be boolean true or false, set propagate to that value
  def update(%__MODULE__{}, %{"@propagate" => propagate}, _, _) when not is_boolean(propagate) do
    raise JSON.LD.Error.invalid_propagate_value(propagate)
  end

  def update(%__MODULE__{} = active, %{"@propagate" => propagate} = local, options, popts) do
    # 4) If local context is not an array, set it to an array containing only local context.
    update(active, [local], Keyword.put(options, :propagate, propagate), popts)
  end

  # 4) If local context is not an array, set it to an array containing only local context.
  def update(%__MODULE__{} = active, local, options, processor_options),
    do: update(active, [local], options, processor_options)

  # 5.1) If context is null
  @spec do_update(t, local, remote, keyword, Options.t()) :: t
  defp do_update(%__MODULE__{} = active, nil, _, options, processor_options) do
    # 5.1.1) If override protected is false and active context contains any protected term definitions, an invalid context nullification has been detected and processing is aborted.

    if not options[:override_protected] and
         active.term_defs |> Map.values() |> Enum.any?(& &1.protected) do
      raise JSON.LD.Error.invalid_context_nullification()
    else
      # 5.1.2) Initialize result as a newly-initialized active context, setting both base IRI and original base URL to the value of original base URL in active context, and, if propagate is false, previous context in result to the previous value of result.
      %{
        new(processor_options)
        | base_iri: active.original_base_url || :not_present,
          original_base_url: active.original_base_url,
          previous_context: if(!options[:propagate], do: active)
      }
    end
  end

  # 5.2) If context is a string
  defp do_update(%__MODULE__{} = active, context, remote, options, processor_options)
       when is_binary(context) do
    # 5.2.1) Initialize context to the result of resolving context against base URL. If base URL is not a valid IRI, then context MUST be a valid IRI, otherwise a loading document failed error has been detected and processing is aborted.
    # SPEC ISSUE: we try the unmentioned original_base_url here to get expand#tc031 pass
    base = active.original_base_url || processor_options.base || base(active)

    context =
      cond do
        IRI.absolute?(context) ->
          if IRI.valid?(context) do
            context
          else
            raise JSON.LD.Error.loading_document_failed("Invalid context IRI: #{context}")
          end

        not IRI.valid?(base) ->
          raise JSON.LD.Error.loading_document_failed("Invalid base IRI: #{base || "nil"}")

        true ->
          absolute_iri(context, base)
      end

    # 5.2.2) If validate scoped context is false, and remote contexts already includes context do not process context further and continue to any next context in local context.
    if not options[:validate_scoped_context] and context in remote do
      context
    else
      # 5.2.3) If the number of entries in the remote contexts array exceeds a processor defined limit, a context overflow error has been detected and processing is aborted;
      if length(remote) > @max_contexts_loaded do
        raise JSON.LD.Error.context_overflow(context)
      end

      # 5.2.3) otherwise, add context to remote contexts.
      remote = [context | remote]

      # 5.2.5)
      {loaded_context, document_url} = dereference_context(context, processor_options)

      # If context was previously dereferenced, processors MUST make provisions for retaining the base URL of that context for this step to enable the resolution of any relative context URLs that may be encountered during processing.
      %{
        active
        | base_iri: if(active.base_iri == :not_present, do: document_url, else: active.base_iri),
          original_base_url: document_url
      }
      # 5.2.6)
      |> update(
        loaded_context,
        Keyword.put(options, :remote_contexts, remote),
        processor_options |> Options.set_base(document_url)
      )
    end
  end

  # 5.4) Otherwise, context is a context definition
  defp do_update(%__MODULE__{} = active, local, remote, options, processor_options)
       when is_map(local) do
    {import_ctx, local} = Map.pop(local, "@import", :not_present)
    local = process_import(import_ctx, local, active, processor_options)

    {version, local} = Map.pop(local, "@version", :not_present)
    {base, local} = Map.pop(local, "@base", :not_present)
    {vocab, local} = Map.pop(local, "@vocab", :not_present)
    {language, local} = Map.pop(local, "@language", :not_present)
    {direction, local} = Map.pop(local, "@direction", :not_present)
    {propagate, local} = Map.pop(local, "@propagate", :not_present)
    {protected, local} = Map.pop(local, "@protected", false)

    active
    |> check_version(version, processor_options.processing_mode)
    |> set_base(base, remote)
    |> set_vocab(vocab, processor_options)
    |> set_language(language, processor_options)
    |> set_direction(direction, processor_options.processing_mode)
    |> validate_propagate(propagate, processor_options.processing_mode)
    |> create_term_definitions(
      local,
      options
      |> Keyword.put(:protected, protected)
      |> Keyword.put(:remote_contexts, remote),
      processor_options
    )
  end

  # 5.3) If context is not a map, an invalid local context error has been detected and processing is aborted.
  defp do_update(_, context, _, _, _) do
    raise JSON.LD.Error.invalid_local_context(context)
  end

  defp dereference_import(url, options) do
    # 5.6.4) (5.6.5 and 5.6.6 is done as part of dereference_context)
    {document, _url} = dereference_context(url, options)

    case document do
      # 5.6.7)
      %{"@import" => _} ->
        raise JSON.LD.Error.invalid_context_entry(
                "#{inspect(document)} must not include @import entry"
              )

      document ->
        document
    end
  end

  defp dereference_context(context_url, options) do
    {document, url} =
      case DocumentLoader.load(context_url, %{
             options
             | profile: "http://www.w3.org/ns/json-ld#context",
               request_profile: "http://www.w3.org/ns/json-ld#context"
           }) do
        {:ok, result} ->
          {result.document, result.document_url}

        {:error, %{__exception__: true} = exception} ->
          raise JSON.LD.Error.loading_remote_context_failed(
                  context_url,
                  Exception.message(exception)
                )

        {:error, reason} ->
          raise JSON.LD.Error.loading_remote_context_failed(context_url, reason)
      end

    document =
      cond do
        is_map(document) ->
          document

        # Note: This actually not necessary, as our document loaders always transform to "the internal representation", but custom document loaders may not.
        is_binary(document) ->
          case Jason.decode(document) do
            {:ok, result} ->
              result

            {:error, reason} ->
              raise JSON.LD.Error.invalid_remote_context(invalid: reason)
          end

        true ->
          raise JSON.LD.Error.invalid_remote_context(invalid: document)
      end

    {
      document["@context"] ||
        raise(JSON.LD.Error.invalid_remote_context("No @context key in #{inspect(document)}")),
      url
    }
  end

  defp check_version(active, :not_present, _), do: active

  # 5.5.2) If processing mode is set to json-ld-1.0, a processing mode conflict error has been detected and processing is aborted.
  defp check_version(_, 1.1, "json-ld-1.0") do
    raise JSON.LD.Error.processing_mode_conflict()
  end

  defp check_version(active, 1.1, _), do: active

  # 5.5.1) If the associated value is not 1.1, an invalid @version value has been detected, and processing is aborted.
  defp check_version(_, invalid, _) do
    raise JSON.LD.Error.invalid_version_value(invalid)
  end

  defp process_import(:not_present, context, _, _), do: context

  # 5.6.1) If processing mode is json-ld-1.0, an invalid context entry error has been detected and processing is aborted.
  defp process_import(import, _, _, %Options{processing_mode: "json-ld-1.0"}) do
    raise JSON.LD.Error.invalid_context_entry("@import with value #{inspect(import)}")
  end

  # 5.6.2) Otherwise, if the value of @import is not a string, an invalid @import value error has been detected and processing is aborted.
  defp process_import(import, _, _, _) when not is_binary(import) do
    raise JSON.LD.Error.invalid_import_value(import)
  end

  defp process_import(import, context, active, options) do
    import
    # 5.6.3) Initialize import to the result of resolving the value of @import against base URL.
    |> absolute_iri(base(active))
    # 5.6.4) Dereference import using the LoadDocumentCallback, passing import for url, and http://www.w3.org/ns/json-ld#context for profile and for requestProfile.
    |> dereference_import(options)
    |> case do
      # 5.6.8) Set context to the result of merging context into import context, replacing common entries with those from context.
      %{} = import_context ->
        Map.merge(import_context, context)

      invalid ->
        raise JSON.LD.Error.invalid_remote_context(invalid: invalid)
    end
  end

  # 5.7)
  defp set_base(active, :not_present, _), do: active

  defp set_base(active, _, remote) when is_list(remote) and length(remote) > 0,
    do: active

  defp set_base(active, nil, _), do: %__MODULE__{active | base_iri: nil}

  defp set_base(active, base, _) when is_binary(base) do
    cond do
      IRI.absolute?(base) ->
        %__MODULE__{active | base_iri: base}

      active_base = base(active) ->
        %__MODULE__{active | base_iri: absolute_iri(base, active_base)}

      true ->
        raise JSON.LD.Error.invalid_base_iri(base, :relative_without_active_base)
    end
  end

  defp set_base(_, invalid, _) do
    raise JSON.LD.Error.invalid_base_iri(invalid)
  end

  defp set_vocab(active, :not_present, _), do: active

  # 5.8.2) If value is null, remove any vocabulary mapping from result.
  defp set_vocab(active, nil, _), do: %__MODULE__{active | vocabulary_mapping: nil}

  # 5.8.3) Otherwise, if value is an IRI or blank node identifier, the vocabulary mapping of result is set to the result of IRI expanding value using true for document relative.
  defp set_vocab(active, vocab, options) do
    cond do
      # Note: The use of blank node identifiers to value for @vocab is obsolete, and may be removed in a future version of JSON-LD.
      blank_node_id?(vocab) ->
        %__MODULE__{active | vocabulary_mapping: vocab}

      not IRI.absolute?(vocab) and options.processing_mode == "json-ld-1.0" ->
        raise JSON.LD.Error.invalid_vocab_mapping(
                message: "@vocab must be an absolute IRI in 1.0 mode: #{inspect(vocab)}"
              )

      is_binary(vocab) ->
        # SPEC ISSUE: vocab must be set to true
        %__MODULE__{active | vocabulary_mapping: expand_iri(vocab, active, options, true, true)}

      true ->
        raise JSON.LD.Error.invalid_vocab_mapping(vocab)
    end
  end

  defp set_language(active, :not_present, _), do: active

  # 5.9.2) If value is null, remove any default language from result.
  defp set_language(active, nil, _), do: %__MODULE__{active | default_language: nil}

  # 5.9.3) Otherwise, if value is a string, the default language of result is set to value.
  defp set_language(active, language, popts) when is_binary(language) do
    %__MODULE__{active | default_language: validate_and_normalize_language(language, popts)}
  end

  # 5.9.3) If it is not a string, an invalid default language error has been detected and processing is aborted.
  defp set_language(_, language, _) do
    raise JSON.LD.Error.invalid_default_language(language)
  end

  defp set_direction(active, :not_present, _), do: active

  # 5.10.1) If processing mode is json-ld-1.0, an invalid context entry error has been detected and processing is aborted.
  defp set_direction(_, direction, "json-ld-1.0") do
    raise JSON.LD.Error.invalid_context_entry("@direction with value #{inspect(direction)}")
  end

  # 5.10.3) If value is null, remove any default language from result.
  defp set_direction(active, nil, _), do: %__MODULE__{active | base_direction: nil}

  # 5.10.4) Otherwise, if value is a string, the base direction of result is set to value. If it is not null, "ltr", or "rtl", an invalid base direction error has been detected and processing is aborted.
  defp set_direction(active, direction, _) when direction in ~w[ltr rtl],
    do: %__MODULE__{active | base_direction: String.to_atom(direction)}

  # 5.9.3) If it is not a string, an invalid default language error has been detected and processing is aborted.
  defp set_direction(_, direction, _) do
    raise JSON.LD.Error.invalid_base_direction(direction)
  end

  defp validate_propagate(active, :not_present, _), do: active

  # 5.11.1) If processing mode is json-ld-1.0, an invalid context entry error has been detected and processing is aborted.
  defp validate_propagate(_, propagate, "json-ld-1.0") do
    raise JSON.LD.Error.invalid_context_entry("@propagate with value #{inspect(propagate)}")
  end

  # 5.11.2) Otherwise, if the value of @propagate is not boolean true or false, an invalid @propagate value error has been detected and processing is aborted.
  defp validate_propagate(_, propagate, _) when not is_boolean(propagate) do
    raise JSON.LD.Error.invalid_propagate_value(propagate)
  end

  # Note: The previous context is actually set earlier in this algorithm (step 2 and 3)
  defp validate_propagate(active, _, _), do: active

  defp create_term_definitions(active, local, opts, popts, defined \\ %{}) do
    {active, _} =
      Enum.reduce(local, {active, defined}, fn {term, value}, {active, defined} ->
        TermDefinition.create(active, local, term, value, defined, popts, opts)
      end)

    active
  end

  @doc """
  Inverse Context Creation algorithm

  See <https://www.w3.org/TR/json-ld11-api/#inverse-context-creation>
  """
  @spec inverse(t) :: map
  def inverse(%__MODULE__{} = context) do
    # 2) Initialize default language to @none. If the active context has a default language, set default language to the default language from the active context normalized to lower case.
    default_language =
      (context.default_language && String.downcase(context.default_language)) || "@none"

    # 3) For each key term and value term definition in the active context:
    context.term_defs
    #    ordered by shortest term first (breaking ties by choosing the lexicographically least term)
    |> Enum.sort(fn {term1, _}, {term2, _} ->
      case {String.length(term1), String.length(term2)} do
        {length, length} -> term1 < term2
        {length1, length2} -> length1 < length2
      end
    end)
    |> Enum.reduce(%{}, fn {term, term_def}, result ->
      # 3.1) If the term definition is null, term cannot be selected during compaction, so continue to the next term.
      if term_def do
        # 3.2) Initialize container to @none. If the container mapping is not empty, set container to the concatenation of all values of the container mapping in lexicographical order.
        container =
          if term_def.container_mapping && !Enum.empty?(term_def.container_mapping) do
            term_def.container_mapping |> Enum.sort() |> Enum.join()
          else
            "@none"
          end

        # 3.3) Initialize var to the value of the IRI mapping for the term definition.
        var = term_def.iri_mapping

        type_map = get_in(result, [var, container, "@type"]) || %{}
        language_map = get_in(result, [var, container, "@language"]) || %{}
        any_map = get_in(result, [var, container, "@any"]) || %{"@none" => term}

        {type_map, language_map} =
          case term_def do
            # 3.10) If the term definition indicates that the term represents a reverse property
            %TermDefinition{reverse_property: true} ->
              {Map.put_new(type_map, "@reverse", term), language_map}

            # 3.11) Otherwise, if term definition has a type mapping which is @none
            %TermDefinition{type_mapping: "@none"} ->
              {
                Map.put_new(type_map, "@any", term),
                Map.put_new(language_map, "@any", term)
              }

            # 3.12) Otherwise, if term definition has a type mapping
            %TermDefinition{type_mapping: type_mapping} when type_mapping != false ->
              {Map.put_new(type_map, type_mapping, term), language_map}

            # 3.13) Otherwise, if term definition has both a language mapping and a direction mapping:
            %TermDefinition{
              language_mapping: language_mapping,
              direction_mapping: direction_mapping
            }
            when language_mapping != false and direction_mapping != false ->
              lang_dir =
                case {language_mapping, direction_mapping} do
                  {nil, nil} -> "@null"
                  {language_mapping, nil} -> String.downcase(language_mapping)
                  _ -> String.downcase("#{language_mapping}_#{direction_mapping}")
                end

              {type_map, Map.put_new(language_map, lang_dir, term)}

            # 3.14) Otherwise, if term definition has a language mapping (might be null)
            %TermDefinition{language_mapping: language_mapping} when language_mapping != false ->
              language = (language_mapping && String.downcase(language_mapping)) || "@null"
              {type_map, Map.put_new(language_map, language, term)}

            # 3.15) Otherwise, if term definition has a direction mapping (might be null)
            %TermDefinition{direction_mapping: direction_mapping} when direction_mapping != false ->
              direction = (direction_mapping && "_#{direction_mapping}") || "@none"
              {type_map, Map.put_new(language_map, direction, term)}

            _ ->
              # 3.16) Otherwise, if active context has a default base direction
              if context.base_direction do
                lang_dir = String.downcase("#{default_language}_#{context.base_direction}")

                {
                  Map.put_new(type_map, "@none", term),
                  language_map
                  |> Map.put_new(lang_dir, term)
                  |> Map.put_new("@none", term)
                }
              else
                # 3.17) Otherwise
                {
                  Map.put_new(type_map, "@none", term),
                  language_map
                  |> Map.put_new(default_language, term)
                  |> Map.put_new("@none", term)
                }
              end
          end

        result
        |> Map.put_new(var, %{})
        |> Map.update(var, %{}, fn container_map ->
          Map.put(container_map, container, %{
            "@type" => type_map,
            "@language" => language_map,
            "@any" => any_map
          })
        end)
      else
        result
      end
    end)
  end

  @spec set_inverse(t) :: t
  def set_inverse(%__MODULE__{inverse_context: nil} = context) do
    %__MODULE__{context | inverse_context: inverse(context)}
  end

  def set_inverse(%__MODULE__{} = context), do: context

  @spec empty?(t) :: boolean
  def empty?(%__MODULE__{
        term_defs: term_defs,
        vocabulary_mapping: nil,
        base_iri: :not_present,
        default_language: nil
      })
      when map_size(term_defs) == 0,
      do: true

  def empty?(_),
    do: false
end
