defmodule PhoenixSwagger.Validator do
  @moduledoc """
  The PhoenixSwagger.Validator module provides converter of
  swagger schema to ex_json_schema structure for further validation.

  There are two main functions:

    * parse_swagger_schema/1
    * validate/2

  Before `validate/2` will be called, a swagger schema should be parsed
  for futher validation with the `parse_swagger_schema/1`. This function
  takes path to a swagger schema and returns it in ex_json_schema format.

  During execution of the `parse_swagger_schema/1` function, it creates
  the `validator_table` ets table and stores associative key/value there.
  Where `key` is an API path of a resource and `value` is input parameters
  of a resource.

  To validate of a parsed swagger schema, the `validate/1` should be used.

  For more information, see more in ./phoenix_swagger/tests/ directory.
  """

  @table :validator_table

  @doc """
  The `parse_swagger_schema/1` takes path or list of paths to a swagger schema(s),
  parses it/them into ex_json_schema format and store to the `validator_table` ets
  table.

  ## Examples

      iex(1)> parse_swagger_schema("my_json_spec.json")
      [{"/person",  %{'__struct__' => 'Elixir.ExJsonSchema.Schema.Root',
                      location => root,
                      refs => %{},
                      schema => %{
                        "properties" => %{
                          "name" => %{"type" => "string"},
                          "age" => %{"type" => "integer"}
                        }
                      }
                    }
      }]

  """
  def parse_swagger_schema(specs) when is_list(specs) do
    schemas =
      Enum.map(specs, fn spec ->
        read_swagger_schema(spec)
      end)

    schema =
      Enum.reduce(schemas, %{}, fn schema, acc ->
        acc =
          if acc["paths"] == nil do
            Map.merge(acc, schema)
          else
            acc =
              Map.update!(acc, "paths", fn paths_map -> Map.merge(paths_map, schema["paths"]) end)

            Map.update!(acc, "definitions", fn definitions_map ->
              Map.merge(definitions_map, schema["definitions"])
            end)
          end

        acc
      end)

    collect_schema_attrs(schema)
  end

  def parse_swagger_schema(spec) do
    schema = read_swagger_schema(spec)
    collect_schema_attrs(schema)
  end

  @doc """
  The `validate/2` takes a resource path and input parameters
  of this resource.

  Returns `:ok` in a case when parameters are valid for the
  given resource or:

    * `{:error, :resource_not_exists}` in a case when path is not
      exists in the validator table

    * `{:error, error_message, path}` in a case when at least
      one  parameter is not valid for the given resource
  """
  def validate(path, params) do
    case :ets.lookup(@table, path) do
      [] ->
        {:error, :resource_not_exists}
      [{_, _, schema}] ->
        case ExJsonSchema.Validator.validate(schema, params) do
          :ok ->
            :ok

          {:error, [{error, path}]} ->
            {:error, error, path}

          {:error, error} ->
            {:error, error, path}
        end
    end
  end

  @doc false
  defp collect_schema_attrs(schema) do
    Enum.map(schema["paths"], fn {path, data} ->
      Enum.map(Map.keys(data), fn method ->
        parameters = data[method]["parameters"]
        # we may have a request without parameters, so nothing to validate
        # in this case
        if parameters == nil do
          []
        else
          # Let's go through requests parameters from swagger schema
          # and collect it into json schema properties.
          properties =
            Enum.reduce(parameters, %{}, fn parameter, acc ->
              acc =
                if parameter["type"] == nil do
                  ref = String.split(parameter["schema"]["$ref"], "/") |> List.last()
                  Map.merge(acc, schema["definitions"][ref])
                else
                  acc
                end

              acc
            end)

          # collect request primitive parameters which do not refer to `definitions`
          # these are mostly parameters from query string
          properties =
            Enum.reduce(parameters, properties, fn parameter, acc ->
              if parameter["type"] != nil do
                collect_properties(acc, parameter)
              else
                acc
              end
            end)

          # actually all requests which have parameters are objects
          properties =
            if properties["type"] == nil do
              Map.put_new(properties, "type", "object")
            else
              properties
            end

          # store path concatenated with method. This allows us
          # to identify the same resources with different http methods.
          path = "/" <> method <> path

          schema_object =
            Map.merge(
              %{
                "parameters" => parameters,
                "type" => "object",
                "definitions" => schema["definitions"]
              },
              properties
            )

          schema_object = schema_object
            |> Map.update("definitions", %{}, &swagger_nullable_to_json_schema/1)

          resolved_schema = ExJsonSchema.Schema.resolve(schema_object)
          :ets.insert(@table, {path, schema["basePath"], resolved_schema})
          {path, resolved_schema}
        end
      end)
    end)
    |> List.flatten()
  end

  # Swagger 2.0 and JSON-Schema differ in the treatment of nulls.
  # When the "x-nullable" vendor extension is present in the swagger,
  # convert the type to an array including "null"
  defp swagger_nullable_to_json_schema(schema = %{"type" => type, "x-nullable" => true})
       when is_binary(type) do
    schema = %{schema | "type" => [type, "null"]}
    swagger_nullable_to_json_schema(schema)
  end

  defp swagger_nullable_to_json_schema(schema = %{"$ref" => ref, "x-nullable" => true})
       when is_binary(ref) do
    schema = schema
      |> Map.drop(["$ref", "x-nullable"])
      |> Map.put("oneOf", [%{"type" => "null"}, %{"$ref" => ref}])

    swagger_nullable_to_json_schema(schema)
  end

  defp swagger_nullable_to_json_schema(schema) when is_map(schema) do
    for {k, v} <- schema,
        into: %{},
        do: {k, swagger_nullable_to_json_schema(v)}
  end

  defp swagger_nullable_to_json_schema(schema) when is_list(schema) do
    for v <- schema, do: swagger_nullable_to_json_schema(v)
  end

  defp swagger_nullable_to_json_schema(other), do: other

  @doc false
  defp collect_properties(properties, parameter) when properties == %{} do
    Map.put(
      %{},
      "properties",
      Map.put_new(%{}, parameter["name"], %{"type" => parameter["type"]})
    )
  end

  defp collect_properties(properties, parameter) do
    props = Map.put(properties["properties"], parameter["name"], %{"type" => parameter["type"]})
    Map.put(properties, "properties", props)
  end

  @doc false
  defp read_swagger_schema(file) do
    schema = File.read(file) |> elem(1) |> PhoenixSwagger.json_library().decode() |> elem(1)
    # get rid from all keys besides 'paths' and 'definitions' as we
    # need only in these fields for validation
    Enum.reduce(schema, %{}, fn map, acc ->
      {key, val} = map

      if key in ["basePath", "paths", "definitions"] do
        Map.put_new(acc, key, val)
      else
        acc
      end
    end)
  end
end
