defmodule Numerix.Optimization do
  @moduledoc """
  Optimization algorithms to select the best element from a set of possible solutions.
  """

  @default_opts [population_size: 50, mutation_prob: 0.2, elite_fraction: 0.2, iterations: 100]

  @doc """
  Genetic algorithm to find the solution with the lowest cost where `domain` is
  a set of all possible values (i.e. ranges) in the solution and `cost_fun` determines
  how optimal each solution is.

  Example

      iex> domain = [0..9] |> Stream.cycle |> Enum.take(10)
      iex> cost_fun = fn(x) -> Enum.sum(x) end
      iex> Numerix.Optimize.genetic(domain, cost_fun)
      [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

  ## Options
    * `:population_size` - the size of population to draw the solutions from
    * `:mutation_prob` - the minimum probability that decides if mutation should occur
    * `:elite_fraction` - the percentage of population that will form the elite group in each generation
    * `:iterations` - the maximum number of generations to evolve the solutions
  """
  @spec genetic([Range.t()], ([integer] -> number), Keyword.t()) :: [integer]
  def genetic(domain, cost_fun, opts \\ []) do
    merged_opts = Keyword.merge(@default_opts, opts)
    top_elite = round(merged_opts[:elite_fraction] * merged_opts[:population_size])
    population = init_population(domain, merged_opts[:population_size])

    # credo:disable-for-lines:20
    evolve = fn
      [best | _rest], 0, _fun ->
        best

      pop, iteration, fun ->
        Stream.repeatedly(fn ->
          if :rand.uniform() < merged_opts[:mutation_prob] do
            pop |> mutate(domain, top_elite)
          else
            pop |> crossover(top_elite)
          end
        end)
        |> Stream.take(merged_opts[:population_size])
        |> Stream.concat(pop |> Enum.take(top_elite))
        |> Stream.map(&{cost_fun.(&1), &1})
        |> Enum.sort()
        |> Enum.map(fn {_cost, solution} -> solution end)
        |> fun.(iteration - 1, fun)
    end

    evolve.(population, merged_opts[:iterations], evolve)
  end

  defp init_population(domain, size) do
    Stream.repeatedly(fn -> Enum.map(domain, &Enum.random/1) end)
    |> Enum.take(size)
  end

  defp crossover(population, top_elite) do
    vector1 = population |> Enum.at(Enum.random(0..top_elite))
    vector2 = population |> Enum.at(Enum.random(0..top_elite))
    idx = random_index(vector1)

    vector1
    |> Enum.take(idx)
    |> Enum.concat(vector2 |> Enum.drop(idx))
  end

  defp mutate(population, domain, top_elite) do
    elite_idx = Enum.random(0..top_elite)
    target_vector = population |> Enum.at(elite_idx)
    target_idx = random_index(target_vector)

    target_vector
    |> Stream.with_index()
    |> Stream.map(&do_mutate(&1, target_idx, domain))
    |> Enum.to_list()
  end

  defp do_mutate({x, i}, target_idx, domain) when i == target_idx do
    {min, max} = domain |> Enum.at(i) |> Enum.min_max()

    cond do
      :rand.uniform() < 0.5 and x > min ->
        x - 1

      x < max ->
        x + 1

      true ->
        x
    end
  end

  defp do_mutate({x, _i}, _target_idx, _domain), do: x

  defp random_index(vector) do
    max = Enum.count(vector) - 1
    Enum.random(0..max)
  end
end
