# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#     http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
defmodule Schema.JsonSchema do
  @moduledoc """
  Json schema generator. This module defines functions that generate JSON schema (see http://json-schema.org) schemas for OCSF schema.
  """
  @schema_base_uri "https://schema.ocsf.io/schema/classes"
  @schema_version "http://json-schema.org/draft-07/schema#"

  @doc """
  Generates a JSON schema corresponding to the `type` parameter.
  The `type` can be either a class or an object defintion.

  Options: :package_name | :schema_version
  """
  @spec encode(map(), nil | Keyword.t()) :: map()
  def encode(type, options) when is_map(type) do
    Process.put(:options, options || [])

    try do
      encode(type)
    after
      Process.delete(:options)
    end
  end

  def encode(type) do
    name = type[:name]

    {properties, required, just_one, at_least_one} = map_reduce(name, type)

    ext = type[:extension]

    if Map.has_key?(type, :_links) do
      Map.new()
      |> add_java_class(name)
    else
      class_schema(make_class_ref(name, ext))
    end
    |> Map.put("title", type[:caption])
    |> Map.put("type", "object")
    |> Map.put("properties", properties)
    |> Map.put("additionalProperties", false)
    |> put_required(required)
    |> put_just_one(just_one)
    |> put_at_least_one(at_least_one)
    |> encode_objects(type[:objects])
    |> empty_object(properties)
  end

  defp add_java_class(obj, name) do
    case Process.get(:options) do
      nil ->
        obj

      options ->
        add_java_class(obj, name, Keyword.get(options, :package_name))
    end
  end

  defp add_java_class(obj, _name, nil) do
    obj
  end

  defp add_java_class(obj, name, package) do
    Map.put(obj, "javaType", make_java_name(package, name))
  end

  defp make_java_name(package, name) do
    name = String.split(name, "_") |> Enum.map_join(fn name -> String.capitalize(name) end)
    "#{package}.#{name}"
  end

  defp class_schema(id) do
    %{
      "$schema" => @schema_version,
      "$id" => id,
      "additionalProperties" => false
    }
  end

  defp make_object_ref(name) do
    Path.join([ref_object(), String.replace(name, "/", "_")])
  end

  defp ref_object() do
    "#/$defs"
  end

  defp make_class_ref(name, nil) do
    Path.join([@schema_base_uri, name])
  end

  defp make_class_ref(name, ext) do
    Path.join([@schema_base_uri, ext, name])
  end

  defp empty_object(map, properties) do
    if map_size(properties) == 0 do
      Map.put(map, "additionalProperties", true)
    else
      map
    end
  end

  defp put_required(map, []) do
    map
  end

  defp put_required(map, required) do
    Map.put(map, "required", Enum.sort(required))
  end

  defp put_just_one(map, []) do
    map
  end

  defp put_just_one(map, just_one) do
    one_of =
      Enum.map(just_one, fn item ->
        others = Enum.reject(just_one, &(&1 == item))

        %{
          "required" => [item],
          "not" => %{"required" => others}
        }
      end)

    Map.put(map, "oneOf", one_of)
  end

  defp put_at_least_one(map, []) do
    map
  end

  defp put_at_least_one(map, at_least_one) do
    any_of =
      Enum.map(at_least_one, fn item ->
        %{"required" => [item]}
      end)

    Map.put(map, "anyOf", any_of)
  end

  defp encode_objects(schema, nil) do
    schema
  end

  defp encode_objects(schema, []) do
    schema
  end

  defp encode_objects(schema, objects) do
    defs =
      Enum.into(objects, %{}, fn {name, object} ->
        key = Atom.to_string(name) |> String.replace("/", "_")
        {key, encode(object)}
      end)

    Map.put(schema, "$defs", defs)
  end

  defp map_reduce(type_name, type) do
    {properties, {required, just_one, at_least_one}} =
      Enum.map_reduce(type[:attributes], {[], [], []}, fn {key, attribute},
                                                          {required, just_one, at_least_one} ->
        name = Atom.to_string(key)
        just_one_list = List.wrap(type[:constraints][:just_one])
        at_least_one_list = List.wrap(type[:constraints][:at_least_one])

        cond do
          name in just_one_list ->
            {required, [name | just_one], at_least_one}

          name in at_least_one_list ->
            {required, just_one, [name | at_least_one]}

          attribute[:requirement] == "required" ->
            {[name | required], just_one, at_least_one}

          true ->
            {required, just_one, at_least_one}
        end
        |> (fn {required, just_one, at_least_one} ->
              schema =
                encode_attribute(type_name, attribute[:type], attribute)
                |> encode_array(attribute[:is_array])

              {{name, schema}, {required, just_one, at_least_one}}
            end).()
      end)

    {Map.new(properties), required, just_one, at_least_one}
  end

  defp encode_attribute(_name, "integer_t", attr) do
    new_schema(attr) |> encode_integer(attr)
  end

  defp encode_attribute(_name, "string_t", attr) do
    new_schema(attr) |> encode_string(attr)
  end

  defp encode_attribute(name, "object_t", attr) do
    new_schema(attr) |> encode_object(name, attr)
  end

  defp encode_attribute(_name, "json_t", attr) do
    new_schema(attr)
  end

  defp encode_attribute(_name, type, attr) do
    new_schema(attr) |> Map.put("type", encode_type(type))
  end

  defp new_schema(attr), do: %{"title" => attr[:caption]}

  defp encode_type(type) do
    cond do
      Schema.data_type?(type, "string_t") -> "string"
      Schema.data_type?(type, "integer_t") -> "integer"
      Schema.data_type?(type, "long_t") -> "integer"
      Schema.data_type?(type, "float_t") -> "number"
      Schema.data_type?(type, "boolean_t") -> "boolean"
      true -> type
    end
  end

  defp encode_object(schema, _name, attr) do
    type = attr[:object_type]
    Map.put(schema, "$ref", make_object_ref(type))
  end

  defp encode_integer(schema, attr) do
    encode_enum(schema, attr, "integer", fn name ->
      Atom.to_string(name) |> String.to_integer()
    end)
  end

  defp encode_string(schema, attr) do
    encode_enum(schema, attr, "string", &Atom.to_string/1)
  end

  defp encode_enum(schema, attr, type, encoder) do
    case attr[:enum] do
      nil ->
        schema

      enum ->
        case encode_enum_values(enum, encoder) do
          [uid] ->
            Map.put(schema, "const", uid)

          values ->
            Map.put(schema, "enum", values)
        end
    end
    |> Map.put("type", type)
  end

  defp encode_enum_values(enum, encoder) do
    Enum.map(enum, fn {name, _} ->
      encoder.(name)
    end)
  end

  defp encode_array(schema, true) do
    {type, schema} = items_type(schema)

    Map.put(schema, "type", "array") |> Map.put("items", type)
  end

  defp encode_array(schema, _is_array) do
    schema
  end

  defp items_type(schema) do
    case Map.get(schema, "type") do
      nil ->
        {ref, updated} = Map.pop(schema, "$ref")
        {%{"$ref" => ref}, updated}

      type ->
        case Map.pop(schema, "enum") do
          {nil, updated} ->
            {%{"type" => type}, updated}

          {enum, updated} ->
            {%{"type" => type, "enum" => enum}, updated}
        end
    end
  end
end
