defmodule Bumblebee.Shared do
  @moduledoc false

  @doc """
  Returns specification for the given common options.
  """
  @spec common_options(list(atom())) :: keyword()
  def common_options(keys) do
    common_options = [
      output_hidden_states: [
        default: false,
        doc: "whether the model should return all hidden states"
      ],
      output_attentions: [
        default: false,
        doc: "whether the model should return all attentions"
      ],
      num_labels: [
        default: 2,
        doc: "the number of labels to use in the last layer for the classification task"
      ],
      id_to_label: [
        default: %{},
        doc: "a map from class index to label"
      ],
      use_cross_attention: [
        default: false,
        doc:
          "whether cross-attention layers should be added to the model. " <>
            "This is only relevant for decoder models"
      ]
    ]

    Keyword.take(common_options, keys)
  end

  @doc """
  Returns specification for the token options with the corresponding
  defaults.
  """
  @spec token_options(keyword()) :: keyword()
  def token_options(defaults) do
    for {key, default} <- defaults do
      {key, [default: default, doc: nil]}
    end
  end

  @doc """
  Generates documentation string for the given options specification.
  """
  @spec options_doc(keyword()) :: String.t()
  def options_doc(options) do
    items =
      for {key, info} <- options, doc = info[:doc] do
        doc = String.replace(doc, "\n", "\n    ")
        item = "  * `#{inspect(key)}` - #{doc}"

        case info[:default] do
          nil -> item
          default -> "#{item}. Defaults to `#{inspect(default)}`"
        end
      end

    Enum.join(items, "\n\n")
  end

  @doc """
  Generates documentation string for the given global layer options.
  """
  @spec global_layer_options_doc(list(atom())) :: String.t()
  def global_layer_options_doc(names) do
    docs = [
      output_hidden_states: "when `true`, the model output includes all hidden states",
      output_attentions: "when `true`, the model output includes all attention weights"
    ]

    Enum.map_join(names, "\n\n", fn name ->
      doc = Keyword.fetch!(docs, name)
      "  * `#{inspect(name)}` - #{doc}"
    end)
  end

  @doc """
  Returns option defaults form the options specification.

  This function is useful in combination with `defstruct`.
  """
  @spec option_defaults(keyword()) :: keyword()
  def option_defaults(options) do
    for {key, info} <- options, do: {key, info[:default]}
  end

  @doc """
  Converts common options from huggingface/transformers configuration.
  """
  @spec common_options_from_transformers(map(), Bumblebee.ModelSpec.t()) :: keyword()
  def common_options_from_transformers(data, spec) do
    import Bumblebee.Shared.Converters

    converters = [
      output_hidden_states: {"output_hidden_states", boolean()},
      output_attentions: {"output_attentions", boolean()},
      num_labels: {"num_labels", number()},
      id_to_label: {"id2label", map(integer_as_string(), string())},
      use_cross_attention: {"use_cross_attention", false},
      # Tokens
      pad_token_id: {"pad_token_id", number()},
      bos_token_id: {"bos_token_id", number()},
      eos_token_id: {"eos_token_id", number()},
      decoder_start_token_id: {"decoder_start_token_id", number()}
    ]

    converters =
      Keyword.filter(converters, fn {key, _} ->
        Map.has_key?(spec, key)
      end)

    opts = convert!(data, converters)

    if Map.has_key?(spec, :num_labels) and
         not Keyword.has_key?(opts, :num_labels) and opts[:id_to_label] do
      Keyword.put(opts, :num_labels, map_size(opts[:id_to_label]))
    else
      opts
    end
  end

  @doc """
  Merges the given list of attributes into a configuration struct.

  Raises `ArgumentError` if an invalid attribute name is found.
  """
  @spec put_config_attrs(struct(), keyword()) :: struct()
  def put_config_attrs(config, opts) do
    Enum.reduce(opts, config, fn {key, value}, config ->
      case config do
        %{^key => _} ->
          %{config | key => value}

        _ ->
          raise ArgumentError,
                "unexpected attribute #{inspect(key)} for %#{inspect(config.__struct__)}{}"
      end
    end)
  end

  @doc """
  Validates that label-related attributes have consistent size.
  """
  @spec validate_label_options(Bumblebee.ModelSpec.t()) :: Bumblebee.ModelSpec.t()
  def validate_label_options(%{num_labels: num_labels, id_to_label: id_to_label} = spec) do
    if id_to_label != %{} and map_size(id_to_label) != spec.num_labels do
      raise ArgumentError,
            "size mismatch between :num_labels (#{inspect(num_labels)}) and :id_to_label (#{inspect(id_to_label)})"
    end

    spec
  end

  @doc """
  Optionally unwraps a singular list.
  """
  @spec normalize_output(list(), boolean()) :: list(term()) | term()
  def normalize_output(list, multi?)

  def normalize_output([term], false), do: term
  def normalize_output(list, true), do: list

  @doc """
  Validates and normalizes task input.
  """
  @spec validate_serving_input!(
          term(),
          (term() -> {:ok, term()} | {:error, String.t()})
        ) :: {list(term()), multi? :: boolean()}
  def validate_serving_input!(input, validator)

  def validate_serving_input!(input, validator) when is_list(input) do
    input =
      for item <- input do
        case validator.(item) do
          {:ok, normalized} -> normalized
          {:error, message} -> raise ArgumentError, "invalid input in the batch, #{message}"
        end
      end

    {input, true}
  end

  def validate_serving_input!(input, validator) do
    case validator.(input) do
      {:ok, normalized} -> {[normalized], false}
      {:error, message} -> raise ArgumentError, "invalid input, #{message}"
    end
  end

  def validate_image(input) do
    if image?(input) do
      {:ok, input}
    else
      {:error, "expected an image, got: #{inspect(input)}"}
    end
  end

  def validate_string(input) do
    if is_binary(input) do
      {:ok, input}
    else
      {:error, "expected a string, got: #{inspect(input)}"}
    end
  end

  def validate_string_or_pairs(input) do
    case input do
      input when is_binary(input) -> {:ok, input}
      {left, right} when is_binary(left) and is_binary(right) -> {:ok, input}
      _other -> {:error, "expected a string or a pair of strings, got: #{inspect(input)}"}
    end
  end

  @doc """
  Validates that the input is a single value and not a batch.
  """
  @spec validate_input_for_stream!(term()) :: :ok
  def validate_input_for_stream!(input) do
    if is_list(input) do
      raise ArgumentError,
            "this serving only accepts singular input when stream is enabled," <>
              " call the serving with each input in the batch separately"
    end

    :ok
  end

  @doc """
  Asserts that the model architecture matches one of the expected
  architectures.
  """
  def validate_architecture!(spec, architecture)

  def validate_architecture!(spec, architectures) when is_list(architectures) do
    unless spec.architecture in architectures do
      raise ArgumentError,
            "expected a model architecture to be either of #{inspect(architectures)}, got #{inspect(spec.architecture)}"
    end
  end

  def validate_architecture!(spec, architecture) do
    unless spec.architecture == architecture do
      raise ArgumentError,
            "expected a model with architecture #{inspect(architecture)}, got #{inspect(spec.architecture)}"
    end
  end

  @doc """
  Asserts that the given options keyword list has all of the given
  keys.
  """
  def require_options!(opts, keys) do
    missing = keys -- Keyword.keys(opts)

    if missing != [] do
      raise ArgumentError, "missing keys #{inspect(missing)} in #{inspect(opts)}"
    end

    opts
  end

  @doc """
  Checks if the given term is an image.
  """
  @spec image?(term()) :: boolean()
  def image?(image) do
    try do
      Nx.to_template(image)
    rescue
      Protocol.UndefinedError -> false
    else
      %Nx.Tensor{shape: {_, _, channels}} when channels in 1..4 -> true
      _ -> false
    end
  end

  @doc """
  Pads a batch to the given size, if given.

  When the batch exceeds `batch_size`, raises an error.
  """
  @spec maybe_pad(Nx.Batch.t(), non_neg_integer() | nil) :: Nx.Batch.t()
  def maybe_pad(batch, batch_size)

  def maybe_pad(batch, nil), do: batch

  def maybe_pad(%{size: size}, batch_size) when size > batch_size do
    raise ArgumentError,
          "input batch size (#{size}) exceeds the maximum configured batch size (#{batch_size})"
  end

  def maybe_pad(%{size: size} = batch, batch_size) do
    Nx.Batch.pad(batch, batch_size - size)
  end

  @doc """
  Shared logic applied after serving computation to the resulting tensor
  or container.
  """
  @spec serving_post_computation(result) :: result when result: Nx.Tensor.t() | Nx.Container.t()
  def serving_post_computation(result) do
    # We transfer to binary backend so tensor access in post-processing
    # is not blocked by the serving the serving computation. It is also
    # necessary when partitions are enabled since we may need to
    # concatenate results for input exceeding the expected batch size.
    Nx.backend_transfer(result, Nx.BinaryBackend)
  end

  @doc """
  Compiles or wraps the function with just-in-time compilation.

  When `compile?` is `true`, runs `template_fun` to get template args
  and calls compiles the function upfront. The template function may
  return a mix of tensors and templates, all arguments are automatically
  converter to templates.

  If `defn_options[:cache]` is set, the given `scope` is used to create
  a suffix.
  """
  @spec compile_or_jit(
          function(),
          scope,
          keyword(),
          boolean(),
          (-> list(Nx.Tensor.t()))
        ) :: function()
        when scope: String.Chars.t() | {scope, scope}
  def compile_or_jit(fun, scope, defn_options, compile?, template_fun) do
    defn_options =
      case defn_options[:cache] do
        cache when is_binary(cache) ->
          suffix = "__bumblebee_" <> scope_to_string(scope)
          Keyword.replace!(defn_options, :cache, cache <> suffix)

        _ ->
          defn_options
      end

    if compile? do
      template_args = template_fun.() |> templates()
      Nx.Defn.compile(fun, template_args, defn_options)
    else
      Nx.Defn.jit(fun, defn_options)
    end
  end

  defp scope_to_string({left, right}) do
    scope_to_string(left) <> "_" <> scope_to_string(right)
  end

  defp scope_to_string(scope), do: to_string(scope)

  @doc """
  Returns at template for the given model input.

  Replaces leading axis sizes with `overrides`.
  """
  @spec input_template(
          Bumblebee.ModelSpec.t(),
          String.t(),
          list(non_neg_integer())
        ) :: Nx.Tensor.t()
  def input_template(%module{} = spec, name, overrides) do
    %{^name => template} = module.input_template(spec)

    shape =
      overrides
      |> Enum.with_index()
      |> Enum.reduce(Nx.shape(template), fn {size, idx}, shape ->
        put_elem(shape, idx, size)
      end)

    Nx.template(shape, Nx.type(template))
  end

  @doc """
  Converts tensors to templates.
  """
  @spec templates(list(Nx.Tensor.t())) :: list(Nx.Tensor.t())
  def templates(list) do
    Enum.map(list, fn
      %Nx.Tensor{data: %Nx.TemplateBackend{}} = template -> template
      other -> Nx.to_template(other)
    end)
  end

  @doc """
  Converts logits to scores as per the given scores function.

  Raises `ArgumentError` if the scores function is invalid.
  """
  @spec logits_to_scores(Nx.Tensor.t(), atom()) :: Nx.Tensor.t()
  def logits_to_scores(logits, scores_function) do
    case scores_function do
      :softmax ->
        Axon.Activations.softmax(logits)

      :sigmoid ->
        Axon.Activations.sigmoid(logits)

      :none ->
        logits

      other ->
        raise ArgumentError,
              "expected :scores_function to be either of :softmax, :sigmoid or :none, got: #{inspect(other)}"
    end
  end

  @doc """
  Returns batch keys for the given sequence length specified in text
  serving compile options.
  """
  @spec sequence_batch_keys(nil | non_neg_integer() | list(non_neg_integer())) :: list()
  def sequence_batch_keys(sequence_length)

  def sequence_batch_keys(nil), do: [:default]

  def sequence_batch_keys(length) when is_number(length) do
    [{:sequence_length, length}]
  end

  def sequence_batch_keys(lengths) when is_list(lengths) do
    Enum.map(lengths, &{:sequence_length, &1})
  end

  @doc """
  Determines batch key compatible with `sequence_batch_keys/1` based
  on tokenized inputs.
  """
  @spec sequence_batch_key_for_inputs(
          inputs :: any(),
          nil | non_neg_integer() | list(non_neg_integer())
        ) :: term()
  def sequence_batch_key_for_inputs(inputs, sequence_length) do
    if sequence_length do
      {:sequence_length, Nx.axis_size(inputs["input_ids"], 1)}
    else
      :default
    end
  end

  @doc """
  If `preallocate?` is `true`, allocates `params` using `defn_options`.
  """
  @spec maybe_preallocate(map(), boolean(), keyword()) :: map()
  def maybe_preallocate(params, preallocate?, defn_options) do
    if preallocate? do
      backend = Nx.Defn.to_backend(defn_options)
      Nx.backend_copy(params, backend)
    else
      params
    end
  end

  @doc """
  Slices a subset of dense layer parameters.

  Expects `out_template` to be a tuple representing a "shape" of the
  output units. The tuple should include a list in place of the axis
  along which the parameters are concatenated. The list should contain
  chunk sizes. `chunk_idx` indicates which chunk to slice.
  """
  def sliced_dense_params_source(source_layer_name, out_template, chunk_idx) do
    out_template = Tuple.to_list(out_template)
    chunk_axis = Enum.find_index(out_template, &is_list/1)
    chunk_sizes = Enum.at(out_template, chunk_axis)
    {prev_chunk_sizes, [chunk_size | _]} = Enum.split(chunk_sizes, chunk_idx)
    offset = Enum.sum(prev_chunk_sizes)
    out_shape = List.replace_at(out_template, chunk_axis, Enum.sum(chunk_sizes))

    %{
      "kernel" => {
        [{source_layer_name, "weight"}],
        fn [kernel] ->
          in_size = Nx.axis_size(kernel, -1)

          kernel =
            kernel
            |> Nx.reshape(List.to_tuple(out_shape ++ [in_size]))
            |> Nx.slice_along_axis(offset, chunk_size, axis: chunk_axis)
            |> Nx.reshape({:auto, in_size})

          # Transpose the kernel
          [out_features, in_features] = Nx.axes(kernel)
          Nx.transpose(kernel, axes: [in_features, out_features])
        end
      },
      "bias" => {
        [{source_layer_name, "bias"}],
        fn [bias] ->
          bias
          |> Nx.reshape(List.to_tuple(out_shape))
          |> Nx.slice_along_axis(offset, chunk_size, axis: chunk_axis)
          |> Nx.flatten()
        end
      }
    }
  end

  @type featurizer_image_size ::
          %{height: non_neg_integer(), width: non_neg_integer()}
          | %{shortest_edge: non_neg_integer()}

  @doc """
  Returns an exact `{height, width}` size to resize images into.

  Accepts a featurizer size map.
  """
  @spec featurizer_resize_size(Nx.Tensor.t(), featurizer_image_size()) ::
          {height :: non_neg_integer(), width :: non_neg_integer()}
  def featurizer_resize_size(images, size)

  def featurizer_resize_size(_images, %{height: height, width: width}), do: {height, width}

  def featurizer_resize_size(images, %{shortest_edge: size}) do
    {height, width} = images_spatial_sizes(images)

    {short, long} = if height < width, do: {height, width}, else: {width, height}

    out_short = size
    out_long = floor(size * long / short)

    if height < width, do: {out_short, out_long}, else: {out_long, out_short}
  end

  defp images_spatial_sizes(images) do
    height = Nx.axis_size(images, -3)
    width = Nx.axis_size(images, -2)
    {height, width}
  end

  @doc """
  Checks whether if the given featurizer image size is fixed or depends
  on the input size.
  """
  @spec featurizer_size_fixed?(featurizer_image_size()) :: boolean()
  def featurizer_size_fixed?(size)

  def featurizer_size_fixed?(%{height: _, width: _}), do: true
  def featurizer_size_fixed?(%{shortest_edge: _}), do: false
end
