defmodule Apical.Plugs.Parameter do
  @moduledoc false

  # common functions for parameter plugs to use generally
  # note that this module is not a plug itself (this is why it's in an
  # underscored file)
  #
  # the behaviour defined by this module should be considered private.

  @callback name() :: atom()
  @callback default_style() :: String.t()
  @callback style_allowed?(String.t()) :: boolean

  alias Apical.Plugs.Query
  alias Apical.Tools
  alias Apical.Validators
  alias Apical.Exceptions.ParameterError

  alias Plug.Conn

  # INITIALIZATION

  def init([in_, module, operation_id, parameters, plug_opts]) do
    operations =
      plug_opts
      |> opts_to_context
      |> Map.put(:parser_context, %{})

    Enum.reduce(parameters, operations, fn parameter = %{"name" => name}, operations_so_far ->
      merged_opts =
        plug_opts
        |> Keyword.get(:parameters, [])
        |> Enum.find_value(&if Atom.to_string(elem(&1, 0)) == name, do: elem(&1, 1))
        |> List.wrap()

      plug_opts = Tools.deepmerge(plug_opts, merged_opts)

      Tools.assert(
        !parameter["allowEmptyValue"],
        "allowEmptyValue is not supported for parameters due to ambiguity, see https://github.com/OAI/OpenAPI-Specification/issues/1573",
        apical: true
      )

      Tools.assert(
        in_ == Query or !parameter["allowReserved"],
        "allowReserved is only supported for query parameters"
      )

      version = Keyword.fetch!(plug_opts, :version)

      marshal_opts =
        plug_opts
        |> Keyword.get(:marshal)
        |> normalize_marshal(module)

      should_validate = marshal_opts !== false and Keyword.get(plug_opts, :validate, true)

      operations_so_far
      |> Map.update!(:parser_context, &Map.put(&1, name, %{}))
      |> add_if_required(parameter)
      |> add_if_deprecated(parameter)
      |> add_type(parameter, marshal_opts)
      |> add_style(in_, parameter, plug_opts)
      |> add_inner_marshal(parameter, marshal_opts)
      |> add_allow_reserved(parameter)
      |> add_validations(module, version, operation_id, parameter, should_validate)
      |> add_custom_marshal(name, marshal_opts)
    end)
  end

  defp opts_to_context(plug_opts) do
    Enum.reduce(~w(styles)a, %{}, fn
      :styles, so_far ->
        if styles = Keyword.get(plug_opts, :styles) do
          Map.put(so_far, :styles, Map.new(styles))
        else
          so_far
        end
    end)
  end

  defp add_if_required(operations, %{"required" => true, "name" => name}) do
    Map.update(operations, :required, [name], &[name | &1])
  end

  defp add_if_required(operations, _parameters), do: operations

  defp add_if_deprecated(operations, %{"deprecated" => true, "name" => name}) do
    Map.update(operations, :deprecated, [name], &[name | &1])
  end

  defp add_if_deprecated(operations, _parameters), do: operations

  defp add_type(operations, %{"name" => name, "schema" => %{"type" => type}}, nil) do
    types = to_type_list(type)
    parser_context = Map.update!(operations.parser_context, name, &Map.put(&1, :type, types))

    %{operations | parser_context: parser_context}
  end

  defp add_type(operations, %{"name" => name}, marshal) do
    if marshal do
      parser_context =
        Map.update!(operations.parser_context, name, &Map.put(&1, :type, [:string]))

      %{operations | parser_context: parser_context}
    else
      operations
    end
  end

  @types ~w(null boolean integer number string array object)a
  @type_class Map.new(@types, &{"#{&1}", &1})
  @type_order Map.new(Enum.with_index(@types))

  defp to_type_list(type) do
    type
    |> List.wrap()
    |> Enum.map(&Map.fetch!(@type_class, &1))
    |> Enum.sort_by(&Map.fetch!(@type_order, &1))
  end

  @style_to_atom %{
    "form" => :form,
    "simple" => :simple,
    "label" => :label,
    "matrix" => :matrix,
    "spaceDelimited" => :space_delimited,
    "pipeDelimited" => :pipe_delimited
  }

  @explodable ~w(form simple label matrix)

  @builtin_styles Map.keys(@style_to_atom) ++ ["deepObject"]

  @collection_types [
    "array",
    "object",
    ["array"],
    ["object"],
    ["null", "array"],
    ["null", "object"]
  ]

  defp merge_style(opts, name) do
    to_merge =
      opts
      |> Keyword.get(:parameters, [])
      |> Enum.find_value(fn {k, v} ->
        if Atom.to_string(k) == name, do: v
      end)
      |> List.wrap()

    Tools.deepmerge(opts, to_merge)
  end

  defp add_style(operations, _in_, parameter = %{"style" => style, "name" => name}, opts)
       when style not in @builtin_styles do
    opts = merge_style(opts, name)

    with {:ok, styles} <- Keyword.fetch(opts, :styles),
         {_, mf_or_mfa} <- List.keyfind(styles, style, 0) do
      operations
      |> apply_style(name, mf_or_mfa, Map.get(parameter, "explode"))
      |> put_in([:parser_context, name, :style_name], style)
    else
      _ ->
        Tools.assert(
          false,
          "custom style `#{style}` needs to have a definition in the `:styles` option",
          apical: true
        )
    end
  end

  defp add_style(operations, in_, parameter = %{"style" => "deepObject", "name" => name}, _opts) do
    Tools.assert(
      in_ == Query,
      "for parameter `#{name}` deepObject style is only supported for query parameters"
    )

    Tools.assert(
      parameter["explode"] === true,
      "for parameter `#{name}` deepObject style requires `explode: true`"
    )

    update_in(operations, [:parser_context, :deep_object_keys], &[name | List.wrap(&1)])
  end

  defp add_style(
         operations,
         in_,
         parameters = %{"name" => name, "schema" => %{"type" => types}},
         _opts
       )
       when types in @collection_types do
    types = List.wrap(types)

    collection =
      cond do
        "array" in types -> :array
        "object" in types -> :object
        true -> nil
      end

    explode? = Map.get(parameters, "explode")

    case Map.get(parameters, "style", in_.default_style()) do
      "form" when explode? in [nil, true] ->
        Tools.assert(
          in_.style_allowed?("form"),
          "form style is not allowed for #{in_.name()} parameters"
        )

        Tools.assert(
          collection in [:array, :object],
          "for parameter `#{name}`, form (exploded) style requires schema type array or object.  Consider setting `explode: false`",
          apical: true
        )

        update_in(
          operations,
          [:parser_context, :exploded_array_keys],
          &[name | List.wrap(&1)]
        )

      style ->
        Tools.assert(
          in_.style_allowed?(style),
          "#{style} style is not allowed for #{in_.name()} parameters"
        )

        apply_style(operations, name, style, explode?)
    end
  end

  defp add_style(operations, in_, %{"name" => name, "style" => style}, _opts)
       when style in ~w(matrix label) do
    Tools.assert(
      in_.style_allowed?(style),
      "#{style} style is not allowed for #{in_.name()} parameters"
    )

    apply_style(operations, name, style, false)
  end

  defp add_style(operations, _in_, parameters = %{"name" => name}, _opts) do
    style = parameters["style"]

    Tools.assert(
      is_nil(style),
      "for parameter #{name} the default style #{style} is not supported because the schema type must be a collection.",
      apical: true
    )

    operations
  end

  defp apply_style(operations, name, mf_or_mfa, explode) when is_tuple(mf_or_mfa) do
    explode = List.wrap(explode)

    style =
      case mf_or_mfa do
        {m, f} -> {m, f, explode}
        {m, f, a} -> {m, f, explode ++ a}
      end

    put_in(operations, [:parser_context, name, :style], style)
  end

  defp apply_style(operations, name, style, explode?) do
    unless style in @explodable do
      Tools.assert(
        !explode?,
        "for parameter `#{name}`, #{style} style may not be `explode: true`."
      )
    end

    operations
    |> put_in(
      [:parser_context, name, :style],
      Map.fetch!(@style_to_atom, style)
    )
    |> apply_explode(name, explode?)
  end

  defp apply_explode(operations, name, explode?) do
    if explode? do
      put_in(operations, [:parser_context, name, :explode], true)
    else
      operations
    end
  end

  defp add_inner_marshal(
         operations = %{parser_context: context},
         %{
           "schema" => schema,
           "name" => key
         },
         nil
       )
       when is_map_key(context, key) do
    outer_type = Map.get(context[key], :type, [])

    cond do
      :array in outer_type ->
        prefix_items_type =
          case schema do
            %{"prefixItems" => prefix_items} ->
              Enum.map(prefix_items, &to_type_list(&1["type"] || ["string"]))

            %{"items" => items} ->
              Enum.map(items, &to_type_list(&1["type"] || ["string"]))

            _ ->
              []
          end

        items_type =
          case schema do
            %{"items" => items} when is_map(items) ->
              to_type_list(items["type"] || ["string"])

            %{"additionalItems" => additional_items} ->
              to_type_list(additional_items["type"] || ["string"])

            _ ->
              []
          end

        new_key_spec =
          Map.put(operations.parser_context[key], :elements, {prefix_items_type, items_type})

        put_in(operations, [:parser_context, key], new_key_spec)

      :object in outer_type ->
        property_types =
          schema
          |> Map.get("properties", %{})
          |> Map.new(fn {name, property} ->
            {name, to_type_list(property["type"])}
          end)

        pattern_types =
          schema
          |> Map.get("patternProperties", %{})
          |> Map.new(fn {regex, property} ->
            {Regex.compile!(regex), to_type_list(property["type"])}
          end)

        additional_types =
          schema
          |> get_in(["additionalProperties", "type"])
          |> Kernel.||("string")
          |> to_type_list()

        new_key_spec =
          Map.put(
            operations.parser_context[key],
            :properties,
            {property_types, pattern_types, additional_types}
          )

        put_in(operations, [:parser_context, key], new_key_spec)

      true ->
        operations
    end
  end

  defp add_inner_marshal(operations, _, _), do: operations

  defp add_allow_reserved(operations, %{"name" => name, "allowReserved" => true}) do
    put_in(operations, [:parser_context, name, :allow_reserved], true)
  end

  defp add_allow_reserved(operations, _), do: operations

  defp add_validations(
         operations,
         module,
         version,
         operation_id,
         %{
           "schema" => _schema,
           "name" => name
         },
         should_validate
       ) do
    if should_validate do
      fun = {module, validator_name(version, operation_id, name)}
      Map.update(operations, :validations, %{name => fun}, &Map.put(&1, name, fun))
    else
      operations
    end
  end

  defp add_validations(operations, _, _, _, _, _), do: operations

  defp add_custom_marshal(operations, name, marshal) do
    if marshal do
      Map.update(operations, :custom_marshal, %{name => marshal}, &Map.put(&1, name, marshal))
    else
      operations
    end
  end

  # REQUIRED PARAMETERS

  # TODO: refactor this into a recursive call
  def check_required(conn, params, in_, %{required: required}) do
    Enum.each(required, fn
      parameter when is_map_key(params, parameter) ->
        :ok

      parameter ->
        raise ParameterError,
          operation_id: conn.private.operation_id,
          in: in_,
          reason: "required parameter `#{parameter}` not present"
    end)

    conn
  end

  def check_required(conn, _, _, _), do: conn

  # DEPRECATED PARAMETERS

  def warn_deprecated(conn = %{params: params}, in_, %{deprecated: deprecated}) do
    Enum.reduce(deprecated, conn, fn param, conn ->
      if is_map_key(params, param) do
        Conn.put_resp_header(
          conn,
          "warning",
          "299 - the #{in_} parameter `#{param}` is deprecated."
        )
      else
        conn
      end
    end)
  end

  def warn_deprecated(conn, _, _), do: conn

  # CUSTOM MARSHAL STEP
  def custom_marshal(conn = %{params: params}, in_, %{custom_marshal: marshal_map}) do
    Enum.reduce(marshal_map, conn, fn
      {parameter, {mod, fun, args}}, conn ->
        with {:ok, content} <- Map.fetch(params, parameter),
             {:ok, marshalled} <- apply(mod, fun, [content | args]) do
          %{conn | params: %{params | parameter => marshalled}}
        else
          :error ->
            conn

          {:error, reason} ->
            raise Apical.Exceptions.ParameterError,
              operation_id: conn.private.operation_id,
              in: in_,
              reason: reason
        end
    end)
  end

  def custom_marshal(conn, _, _), do: conn

  # VALIDATION STEP

  def validate(conn = %{params: params}, in_, %{validations: validation_map}) do
    Enum.reduce(validation_map, conn, fn
      {param, {mod, fun}}, conn ->
        with {:ok, content} <- Map.fetch(params, param),
             :ok <- apply(mod, fun, [content]) do
          conn
        else
          :error ->
            conn

          {:error, reasons} ->
            raise Apical.Exceptions.ParameterError,
                  [operation_id: conn.private.operation_id, in: in_] ++ reasons
        end
    end)
  end

  def validate(conn, _, _), do: conn

  # REFLECTION

  defp validator_name(version, operation_id, name) do
    :"#{version}-#{operation_id}-#{name}"
  end

  # make function: builds plug and validation macros out of the schema.

  @accumulator %{plugs: %{}, validators: [], parameters: MapSet.new()}

  @no_parameters {[], []}

  @spec make(JsonPtr.t(), subschema :: map(), operation_id :: String.t(), plug_opts :: keyword()) ::
          {plugs :: [Macro.t()], validations :: [Macro.t()]}
  def make(operation_pointer, subschema, operation_id, plug_opts) do
    case JsonPtr.resolve_json!(subschema, operation_pointer) do
      %{"parameters" => _} ->
        %{plugs: plugs, validators: validators} =
          operation_pointer
          |> JsonPtr.join("parameters")
          |> JsonPtr.reduce(
            subschema,
            @accumulator,
            &do_make(&2, &1, &3, subschema, operation_id, plug_opts)
          )

        {Enum.map(plugs, &make_parameter_plug(&1, operation_id, plug_opts)),
         List.flatten(validators)}

      _ ->
        @no_parameters
    end
  end

  @location_modules %{
    "query" => Apical.Plugs.Query,
    "header" => Apical.Plugs.Header,
    "path" => Apical.Plugs.Path,
    "cookie" => Apical.Plugs.Cookie
  }
  @locations Map.keys(@location_modules)

  @object_types ["object", ["object"], [nil, "object"]]

  # special case: reject form-exploded parameters, due to ambiguity.
  defp do_make(
         %{"explode" => true, "style" => "form", "schema" => %{"type" => type}, "name" => name},
         _,
         _,
         _,
         operation_id,
         _
       )
       when type in @object_types do
    Tools.assert(
      false,
      "for parameter `#{name}` in operation `#{operation_id}`: form exploded parameters may not be objects",
      apical: true
    )
  end

  defp do_make(%{"$ref" => ref}, _parameter_pointer, acc, schema, operation_id, plug_opts) do
    # for now, don't handle remote refs
    pointer = JsonPtr.from_uri(ref)

    schema
    |> JsonPtr.resolve_json!(pointer)
    |> do_make(pointer, acc, schema, operation_id, plug_opts)
  end

  defp do_make(
         subschema = %{"name" => name, "in" => in_},
         parameter_pointer,
         acc,
         schema,
         operation_id,
         plug_opts
       )
       when in_ in @locations do
    module = Map.fetch!(@location_modules, in_)

    new_validator =
      make_parameter_validator(subschema, parameter_pointer, operation_id, plug_opts)

    Tools.assert(
      name not in acc.parameters,
      "for unique parameters: the parameter `#{name}` is not unique (in operation `#{operation_id}`)"
    )

    subschema = replace_schema_ref(subschema, schema)

    %{
      plugs: Map.update(acc.plugs, module, [subschema], &[subschema | &1]),
      validators: [new_validator | acc.validators],
      parameters: MapSet.put(acc.parameters, name)
    }
  end

  defp do_make(%{"in" => non_location}, parameter_pointer, _, _, operation_id, _)
       when non_location not in @locations do
    {_, index} = JsonPtr.pop(parameter_pointer)

    Tools.assert(
      false,
      "for parameters, invalid parameter location: `#{non_location}` (in operation `#{operation_id}`, parameter #{index})"
    )
  end

  defp do_make(parameter, parameter_pointer, _, _, operation_id, _) do
    {_, index} = JsonPtr.pop(parameter_pointer)

    Tools.assert(
      is_map_key(parameter, "in"),
      "for parameters, field `in` is required (in operation `#{operation_id}`, parameter #{index})"
    )

    Tools.assert(
      is_map_key(parameter, "name"),
      "for parameters, field `name` is required (in operation `#{operation_id}`, parameter #{index})"
    )
  end

  defp replace_schema_ref(subschema = %{"schema" => %{"$ref" => ref}}, full_schema) do
    # for now, don't handle remote refs
    pointer = JsonPtr.from_uri(ref)

    %{subschema | "schema" => JsonPtr.resolve_json!(full_schema, pointer)}
  end

  defp replace_schema_ref(subschema, _), do: subschema

  defp make_parameter_plug({module, plug_schemas}, operation_id, plug_opts) do
    quote do
      plug(
        unquote(module),
        [__MODULE__] ++
          unquote([operation_id, Macro.escape(plug_schemas), plug_opts])
      )
    end
  end

  defp make_parameter_validator(subschema = %{"name" => name}, pointer, operation_id, plug_opts) do
    version = Keyword.fetch!(plug_opts, :version)
    fn_name = validator_name(version, operation_id, name)
    Validators.make_quoted(subschema, pointer, fn_name, plug_opts)
  end

  defp normalize_marshal(nil, _), do: nil

  defp normalize_marshal(false, _), do: false

  defp normalize_marshal(value = {_module, _fun, _args}, _), do: value

  defp normalize_marshal({fun, args}, module) when is_list(args), do: {module, fun, args}

  defp normalize_marshal({module, fun}, _), do: {module, fun, []}

  defp normalize_marshal(fun, module) when is_atom(fun), do: {module, fun, []}
end
