defmodule JSON.LD.Context.TermDefinition do
  @moduledoc """
  Implementation of the JSON-LD 1.1 _Create Term Definition Algorithm_.

  <https://www.w3.org/TR/json-ld11-api/#create-term-definition>
  """

  alias JSON.LD.{Context, Options}
  alias RDF.IRI

  import JSON.LD.{IRIExpansion, Utils}

  @type value :: map | String.t() | nil

  @type t :: %__MODULE__{
          iri_mapping: String.t(),
          prefix_flag: boolean,
          protected: boolean,
          reverse_property: boolean,
          base_url: nil | String.t(),
          local_context: nil | map | String.t(),
          container_mapping: nil | [String.t()],
          index_mapping: nil | String.t(),
          language_mapping: false | nil | String.t(),
          direction_mapping: false | nil | :ltr | :rtl,
          nest_value: nil | String.t(),
          type_mapping: false | nil | String.t()
        }

  defstruct iri_mapping: nil,
            prefix_flag: false,
            protected: false,
            reverse_property: false,
            local_context: nil,
            base_url: nil,
            container_mapping: nil,
            index_mapping: nil,
            language_mapping: false,
            direction_mapping: false,
            nest_value: nil,
            type_mapping: false

  def language(%__MODULE__{language_mapping: false}, context), do: context.default_language
  def language(%__MODULE__{language_mapping: language_mapping}, _), do: language_mapping
  def language(_, context), do: context.default_language

  def direction(%{direction_mapping: false}, context), do: context.base_direction
  def direction(%{direction_mapping: direction_mapping}, _), do: direction_mapping
  def direction(_, context), do: context.base_direction

  defp init_opts(opts) do
    opts
    # base_url is provided via Options.api_base_url
    |> Keyword.put_new(:protected, false)
    |> Keyword.put_new(:override_protected, false)
    |> Keyword.put_new(:remote_contexts, [])
    |> Keyword.put_new(:validate_scoped_context, true)
  end

  # 3)
  defp validate_term_def_value(value) do
    # SPEC ISSUE: the specified validation rules are too strict ...
    #    if is_map(value) and Enum.empty?(Map.keys(value) -- ~w[@container @protected]) do
    value
    #    else
    #      raise JSON.LD.Error.invalid_term_definition("invalid term value: #{inspect(value)}")
    #    end
  end

  @doc """
  Expands the given input according to the steps in the JSON-LD _Create Term Definition_ algorithm.
  """
  @spec create(Context.t(), map, String.t(), value, map, Options.t(), keyword) ::
          {Context.t(), map}
  def create(active, local, term, value, defined, popts, opts \\ [])

  # 2)
  def create(_, _, "", _, _, _, _) do
    raise JSON.LD.Error.invalid_term_definition("the empty string is not a valid term definition")
  end

  def create(active, local, term, value, defined, popts, opts) do
    cond do
      # 5)
      term in (JSON.LD.keywords() -- ["@type"]) or
          (term == "@type" and
             not (is_map(value) and not Enum.empty?(value) and
                    Map.keys(value) -- ~w[@container @protected] == [] and
                      Map.get(value, "@container", "@set") == "@set")) ->
        raise JSON.LD.Error.keyword_redefinition(
                "#{inspect(term)} is a keyword and can not be defined in context"
              )

      keyword_form?(term) and term != "@type" ->
        warn("Terms beginning with '@' are reserved for future use and ignored: #{term}", popts)
        {active, defined}

      true ->
        # 1)
        case defined[term] do
          true ->
            {active, defined}

          false ->
            raise JSON.LD.Error.cyclic_iri_mapping(term)

          nil ->
            # 6) Initialize previous definition to any existing term definition for term in active context,
            {previous_definition, term_defs} = Map.pop(active.term_defs, term)
            #    removing that term definition from active context.
            active = %Context{active | term_defs: term_defs}

            # 2)
            do_create(
              active,
              local,
              term,
              validate_term_def_value(value),
              previous_definition,
              Map.put(defined, term, false),
              popts,
              init_opts(opts)
            )
        end
    end
  end

  defp do_create(
         _,
         _,
         "@type",
         _,
         _,
         _,
         %Options{processing_mode: "json-ld-1.0"},
         _
       ) do
    raise JSON.LD.Error.keyword_redefinition(
            "@type is a keyword and can not be defined in context"
          )
  end

  # 7) If value is null, convert it to a map consisting of a single entry whose key is @id and whose value is null.
  defp do_create(
         active,
         local,
         term,
         nil,
         previous_definition,
         defined,
         popts,
         opts
       ),
       do:
         do_create(
           active,
           local,
           term,
           %{"@id" => nil},
           previous_definition,
           defined,
           popts,
           opts
         )

  # 8) Otherwise, if value is a string, convert it to a map consisting of a single entry whose key is @id and whose value is value. Set simple term to true.
  defp do_create(
         active,
         local,
         term,
         value,
         previous_definition,
         defined,
         popts,
         opts
       )
       when is_binary(value) do
    do_create(
      active,
      local,
      term,
      %{"@id" => value},
      previous_definition,
      defined,
      popts,
      Keyword.put(opts, :simple_term, true)
    )
  end

  # 9)
  defp do_create(
         active,
         local,
         term,
         %{} = value,
         previous_definition,
         defined,
         popts,
         opts
       ) do
    {simple_term, opts} = Keyword.pop(opts, :simple_term, false)

    # 10)
    definition = %__MODULE__{
      prefix_flag: false,
      protected: protected_term_def?(value, opts[:protected], popts.processing_mode),
      reverse_property: false
    }

    {definition, active, defined} =
      handle_type_definition(definition, active, local, value, defined, popts)

    {done, definition, active, defined} =
      if Map.has_key?(value, "@reverse") do
        handle_reverse_definition(definition, active, local, value, defined, popts)
      else
        handle_id_definition(
          definition,
          active,
          local,
          term,
          value,
          previous_definition,
          defined,
          simple_term,
          popts,
          opts
        )
      end

    definition =
      unless done do
        definition =
          definition
          |> handle_container_definition(value, popts)
          |> handle_index_definition(active, local, term, value, defined, popts)
          |> handle_context_definition(active, term, value, popts, opts)
          |> handle_language_definition(value, popts)
          |> handle_direction_definition(value)
          |> handle_nest_definition(value, popts)
          |> handle_prefix_definition(value, term, popts)

        # 27)
        if !opts[:override_protected] && previous_definition && previous_definition.protected do
          # 27.1)
          if %{definition | protected: previous_definition.protected} != previous_definition do
            raise JSON.LD.Error.protected_term_redefinition(term)
          else
            # 27.2)
            previous_definition
          end
        else
          definition
        end
      else
        definition
      end

    # 28) Set the term definition of term in active context to definition and set the value associated with defined's key term to true.
    if definition do
      {
        %Context{active | term_defs: Map.put(active.term_defs, term, definition)},
        Map.put(defined, term, true)
      }
    else
      {active, defined}
    end
  end

  # 9)
  defp do_create(_, _, term, value, _, _, _, _) do
    raise JSON.LD.Error.invalid_term_definition(
            "Term definition for #{term} is has invalid value #{inspect(value)}"
          )
  end

  # 11) If value has an @protected entry, set the protected flag in definition to the value of this entry. If the value of @protected is not a boolean, an invalid @protected value error has been detected and processing is aborted. If processing mode is json-ld-1.0, an invalid term definition has been detected and processing is aborted.
  defp protected_term_def?(%{"@protected" => _} = invalid, _, "json-ld-1.0") do
    raise JSON.LD.Error.invalid_term_definition(
            "invalid value #{inspect(invalid)} in json-ld-1.0 processing mode"
          )
  end

  defp protected_term_def?(%{"@protected" => protected}, _, _), do: protected
  defp protected_term_def?(_, protected, _), do: protected

  # 12.1)
  defp handle_type_definition(_, _, _, %{"@type" => type}, _, _) when not is_binary(type) do
    raise JSON.LD.Error.invalid_type_mapping("#{inspect(type)} is not a valid type mapping")
  end

  defp handle_type_definition(definition, active, local, %{"@type" => type}, defined, popts) do
    # 12.2)
    # SPEC ISSUE: the new spec seems to have lost the requirement to set vocab to true
    {expanded_type, active, defined} =
      expand_iri(type, active, popts, false, true, local, defined)

    cond do
      # 12.3) If the expanded type is @json or @none, and processing mode is json-ld-1.0, an invalid type mapping error has been detected and processing is aborted.
      expanded_type in ~w[@json @none] and popts.processing_mode == "json-ld-1.0" ->
        raise JSON.LD.Error.invalid_type_mapping("#{inspect(type)} is not a valid type mapping")

      # 12.4 and 12.5)
      IRI.absolute?(expanded_type) or expanded_type in ~w[@id @vocab @json @none ] ->
        {%__MODULE__{definition | type_mapping: expanded_type}, active, defined}

      true ->
        raise JSON.LD.Error.invalid_type_mapping("#{inspect(type)} is not a valid type mapping")
    end
  end

  defp handle_type_definition(definition, active, _, _, defined, _),
    do: {definition, active, defined}

  # 13) If value contains the key @reverse
  defp handle_reverse_definition(
         definition,
         active,
         local,
         %{"@reverse" => reverse} = value,
         defined,
         popts
       ) do
    cond do
      # 13.1)
      Map.has_key?(value, "@id") or Map.has_key?(value, "@nest") ->
        raise JSON.LD.Error.invalid_reverse_property(
                "#{inspect(reverse)} is not a valid reverse property"
              )

      # 13.2)
      not is_binary(reverse) ->
        raise JSON.LD.Error.invalid_iri_mapping(
                "Expected string for @reverse value, but got #{inspect(reverse)}"
              )

      # 13.3)
      keyword_form?(reverse) ->
        warn(
          "Values beginning with '@' are reserved for future use and ignored: #{reverse}",
          popts
        )

        {true, nil, active, defined}

      # 13.4)
      true ->
        # SPEC ISSUE: the new spec seems to have lost the requirement to set vocab to true
        {expanded_reverse, active, defined} =
          expand_iri(reverse, active, popts, false, true, local, defined)

        definition =
          if IRI.absolute?(expanded_reverse) or blank_node_id?(expanded_reverse) do
            %__MODULE__{definition | iri_mapping: expanded_reverse}
          else
            raise JSON.LD.Error.invalid_iri_mapping(
                    "Non-absolute @reverse IRI: #{inspect(reverse)}"
                  )
          end

        # 13.5)
        definition =
          case Map.get(value, "@container", :not_present) do
            :not_present ->
              definition

            container when is_nil(container) or container in ~w[@set @index] ->
              %__MODULE__{definition | container_mapping: [container]}

            _ ->
              raise JSON.LD.Error.invalid_reverse_property(
                      "#{inspect(reverse)} is not a valid reverse property; reverse properties only support set- and index-containers"
                    )
          end

        # 13.6) & 13.7)
        {false, %__MODULE__{definition | reverse_property: true}, active, defined}
    end
  end

  defp handle_reverse_definition(definition, active, _, _, defined, _),
    do: {false, definition, active, defined}

  # 14.1) If the @id entry of value is null, the term is not used for IRI expansion, but is retained to be able to detect future redefinitions of this term.
  defp handle_id_definition(definition, active, _, _, %{"@id" => nil}, _, defined, _, _, _),
    do: {false, definition, active, defined}

  # 14)
  defp handle_id_definition(
         definition,
         active,
         local,
         term,
         %{"@id" => id},
         _,
         defined,
         simple_term,
         popts,
         _opts
       )
       when id != term do
    cond do
      # 14.2.1)
      not is_binary(id) ->
        raise JSON.LD.Error.invalid_iri_mapping(
                "expected value of @id to be a string, but got #{inspect(id)}"
              )

      # 14.2.2)
      not JSON.LD.keyword?(id) and keyword_form?(id) ->
        warn("Values beginning with '@' are reserved for future use and ignored: #{id}", popts)
        {true, nil, active, defined}

      true ->
        # 14.2.3)
        # SPEC ISSUE: the new spec seems to have lost the requirement to set vocab to true
        {expanded_id, active, defined} =
          expand_iri(id, active, popts, false, true, local, defined)

        cond do
          expanded_id == "@context" ->
            raise JSON.LD.Error.invalid_keyword_alias("cannot alias @context")

          not (JSON.LD.keyword?(expanded_id) or IRI.absolute?(expanded_id) or
                   blank_node_id?(expanded_id)) ->
            raise JSON.LD.Error.invalid_iri_mapping(
                    "#{inspect(id)} is not a valid IRI mapping; resulting IRI mapping should be a keyword, absolute IRI or blank node"
                  )

          true ->
            # 14.2.4)
            {active, defined} =
              if term |> String.slice(1..-2//1) |> String.contains?(":") or
                   String.contains?(term, "/") do
                {term_iri, active, defined} =
                  expand_iri(
                    term,
                    active,
                    popts,
                    false,
                    true,
                    local,
                    Map.put(defined, term, true)
                  )

                if term_iri != expanded_id do
                  raise JSON.LD.Error.invalid_iri_mapping(
                          "term #{term} expands to #{expanded_id}, not #{term_iri}"
                        )
                end

                {active, defined}
              else
                {active, defined}
              end

            {false,
             %__MODULE__{
               definition
               | iri_mapping: expanded_id,
                 # 14.2.5)
                 prefix_flag:
                   (not String.contains?(term, [":", "/"]) and
                      simple_term and
                      (String.ends_with?(expanded_id, ~w(: / ? # [ ] @)) or
                         blank_node_id?(expanded_id))) or
                     definition.prefix_flag
             }, active, defined}
        end
    end
  end

  defp handle_id_definition(
         definition,
         active,
         local,
         term,
         _,
         previous_definition,
         defined,
         _,
         popts,
         opts
       ) do
    # 15)
    # SPEC ISSUE: the spec seems to contain an error by requiring only to check for a collon. What's when an absolute IRI is given and an "http" term is defined in the context?
    cond do
      term |> String.slice(1..-1//1) |> String.contains?(":") ->
        case compact_iri_parts(term) do
          [prefix, suffix] ->
            prefix_mapping = local[prefix]

            {active, defined} =
              if prefix_mapping do
                do_create(
                  active,
                  local,
                  prefix,
                  prefix_mapping,
                  previous_definition,
                  defined,
                  popts,
                  opts
                )
              else
                {active, defined}
              end

            if prefix_def = active.term_defs[prefix] do
              {false, %__MODULE__{definition | iri_mapping: prefix_def.iri_mapping <> suffix},
               active, defined}
            else
              {false, %__MODULE__{definition | iri_mapping: term}, active, defined}
            end

          nil ->
            {false, %__MODULE__{definition | iri_mapping: term}, active, defined}
        end

      # 16) Otherwise if the term contains a slash (/): Term is a relative IRI reference
      String.contains?(term, "/") ->
        term_iri = expand_iri(term, active, popts, false, true)

        if IRI.absolute?(term_iri) do
          {false, %__MODULE__{definition | iri_mapping: term_iri}, active, defined}
        else
          raise JSON.LD.Error.invalid_iri_mapping(
                  "expected term #{inspect(term)} to expand to an absolute IRI"
                )
        end

      # 17)
      term == "@type" ->
        {false, %__MODULE__{definition | iri_mapping: "@type"}, active, defined}

      # 18)
      true ->
        if active.vocabulary_mapping do
          {false, %__MODULE__{definition | iri_mapping: active.vocabulary_mapping <> term},
           active, defined}
        else
          raise JSON.LD.Error.invalid_iri_mapping(
                  "#{inspect(term)} is not a valid IRI mapping; relative term definition without vocab mapping"
                )
        end
    end
  end

  defp handle_container_definition(definition, %{"@container" => container}, popts) do
    # 19.1) and 19.2)
    container_mapping = valid_container_mapping(container, container, popts.processing_mode)

    %__MODULE__{
      definition
      | # 19.3)
        container_mapping: container_mapping,
        # 19.4)
        type_mapping:
          if "@type" in container_mapping do
            case definition.type_mapping do
              false ->
                "@id"

              type_mapping when type_mapping in ~w[@id @vocab] ->
                type_mapping

              invalid ->
                raise JSON.LD.Error.invalid_type_mapping(
                        message:
                          "@container: @type requires @type to be @id or @vocab; got #{inspect(invalid)}"
                      )
            end
          else
            definition.type_mapping
          end
    }
  end

  defp handle_container_definition(definition, _, _), do: definition

  @container_keywords ~w[@graph @id @index @language @list @set @type]

  defp valid_container_mapping(term, container, processing_mode) do
    values = List.wrap(container)

    cond do
      processing_mode == "json-ld-1.0" and
          (container in ~w[@graph @id @type] or not is_binary(container)) ->
        raise JSON.LD.Error.invalid_container_mapping(
                "'@container' on term #{inspect(term)} has invalid value in 1.0 mode: #{inspect(container)}"
              )

      # MUST be either @graph, @id, @index, @language, @list, @set, @type, or an array containing exactly any one of those keywords
      length(values) == 1 and hd(values) in @container_keywords ->
        :ok

      # an array containing @graph and either @id or @index optionally including @set
      "@graph" in values ->
        case values -- ["@graph", "@set"] do
          [] ->
            :ok

          ["@id"] ->
            :ok

          ["@index"] ->
            :ok

          _ ->
            raise JSON.LD.Error.invalid_container_mapping(
                    "'@container' with @graph can only have @id or @index and optional @set"
                  )
        end

      # an array containing a combination of @set and any of @index, @graph, @id, @type, @language in any order
      "@set" in values ->
        unless values -- ["@set", "@index", "@graph", "@id", "@type", "@language"] == [] do
          raise JSON.LD.Error.invalid_container_mapping(
                  "'@container' with @set can only have @index, @graph, @id, @type, or @language"
                )
        end

      true ->
        raise JSON.LD.Error.invalid_container_mapping(
                "Invalid @container value: #{inspect(container)}"
              )
    end

    values
  end

  # 20)
  defp handle_index_definition(
         definition,
         active,
         local,
         term,
         %{"@index" => index},
         defined,
         popts
       ) do
    # 20.1)
    cond do
      popts.processing_mode == "json-ld-1.0" ->
        raise JSON.LD.Error.invalid_term_definition(
                "invalid @index value in json-ld-1.0 processing mode"
              )

      "@index" not in List.wrap(definition.container_mapping) ->
        raise JSON.LD.Error.invalid_term_definition(
                "@index without @index in @container: #{inspect(index)} on term #{inspect(term)}"
              )

      is_binary(index) ->
        # 20.2)
        {expanded_index, _active, _defined} =
          expand_iri(index, active, popts, false, true, local, defined)

        if IRI.absolute?(expanded_index) do
          # 20.3)
          %__MODULE__{definition | index_mapping: index}
        else
          raise JSON.LD.Error.invalid_term_definition(
                  "@index without @index in @container: #{inspect(index)} on term #{inspect(term)}"
                )
        end

      true ->
        raise JSON.LD.Error.invalid_term_definition(
                "invalid @index value: #{inspect(index)} on term #{inspect(term)}"
              )
    end
  end

  defp handle_index_definition(definition, _, _, _, _, _, _), do: definition

  # 21.1)
  defp handle_context_definition(
         _,
         _,
         _,
         %{"@context" => _},
         %{
           processing_mode: "json-ld-1.0"
         },
         _
       ) do
    raise JSON.LD.Error.invalid_term_definition(
            "invalid @context value in json-ld-1.0 processing mode"
          )
  end

  defp handle_context_definition(definition, active, term, %{"@context" => context}, popts, opts) do
    # 21.3)
    _new_context =
      try do
        Context.update(
          active,
          context,
          override_protected: true,
          validate_scoped_context: false,
          remote_contexts: opts[:remote_contexts],
          processor_options: popts
        )
      rescue
        e ->
          reraise JSON.LD.Error.invalid_scoped_context(
                    "Term definition for #{inspect(term)} contains illegal value for @context: #{Exception.message(e)}"
                  ),
                  __STACKTRACE__
      end

    # 21.4)
    # SPEC ISSUE: "Record null context in array form" from JSON-LD.rb was needed
    %__MODULE__{definition | local_context: if(is_nil(context), do: [nil], else: context)}
  end

  defp handle_context_definition(definition, _, _, _, _, _),
    do: definition

  # 22)
  defp handle_language_definition(definition, %{"@language" => language} = value, popts) do
    unless Map.has_key?(value, "@type") do
      case language do
        language when is_binary(language) ->
          %__MODULE__{
            definition
            | language_mapping: validate_and_normalize_language(language, popts)
          }

        language when is_nil(language) ->
          %__MODULE__{definition | language_mapping: nil}

        _ ->
          raise JSON.LD.Error.invalid_language_mapping(language)
      end
    else
      definition
    end
  end

  defp handle_language_definition(definition, _, _), do: definition

  # 23)
  defp handle_direction_definition(definition, %{"@direction" => direction}) do
    cond do
      # SPEC ISSUE: we had to ignore "... and does not contain the entry @type" from the spec to make "tdi03" pass
      #      not Map.has_key?(value, "@type") ->
      #        definition

      direction && direction not in ~w[ltr rtl] ->
        raise JSON.LD.Error.invalid_base_direction(direction)

      true ->
        %__MODULE__{definition | direction_mapping: direction && String.to_atom(direction)}
    end
  end

  defp handle_direction_definition(definition, _), do: definition

  # 24.1)
  defp handle_nest_definition(_, %{"@nest" => _}, %Options{processing_mode: "json-ld-1.0"}) do
    raise JSON.LD.Error.invalid_term_definition(
            "invalid @nest value in json-ld-1.0 processing mode"
          )
  end

  # 24.2)
  defp handle_nest_definition(definition, %{"@nest" => nest}, _) do
    cond do
      not is_binary(nest) ->
        raise JSON.LD.Error.invalid_nest_value("nest must be a string, was #{inspect(nest)}")

      nest != "@nest" and JSON.LD.keyword?(nest) ->
        raise JSON.LD.Error.invalid_nest_value(
                "nest must not be a keyword other than @nest, was #{inspect(nest)}"
              )

      true ->
        %__MODULE__{definition | nest_value: nest}
    end
  end

  defp handle_nest_definition(definition, _, _), do: definition

  # 25.1)
  defp handle_prefix_definition(_, %{"@prefix" => _}, _, %Options{processing_mode: "json-ld-1.0"}) do
    raise JSON.LD.Error.invalid_term_definition(
            "invalid @prefix value in json-ld-1.0 processing mode"
          )
  end

  # 25.2)
  defp handle_prefix_definition(definition, %{"@prefix" => prefix}, term, _) do
    cond do
      String.contains?(term, [":", "/"]) ->
        raise JSON.LD.Error.invalid_term_definition(
                "@prefix used on compact or relative IRI term"
              )

      not is_boolean(prefix) ->
        raise JSON.LD.Error.invalid_prefix_value(prefix)

      # 25.3)
      prefix and JSON.LD.keyword?(definition.iri_mapping) ->
        raise JSON.LD.Error.invalid_term_definition("keywords may not be used as prefixes")

      true ->
        %__MODULE__{definition | prefix_flag: prefix}
    end
  end

  defp handle_prefix_definition(definition, _, _, _), do: definition
end
