defmodule EctoFoundationDB.Layer.Metadata do
  @moduledoc false
  alias EctoFoundationDB.Future
  alias EctoFoundationDB.Indexer
  alias EctoFoundationDB.Layer.Metadata.Cache
  alias EctoFoundationDB.Layer.MetadataVersion
  alias EctoFoundationDB.Layer.Pack
  alias EctoFoundationDB.Layer.Tx
  alias EctoFoundationDB.Migration.SchemaMigration
  alias EctoFoundationDB.MigrationsPJ
  alias EctoFoundationDB.QueryPlan

  @metadata_source "indexes"
  @metadata_operation_failed {:erlfdb_error, 1020}

  @app_metadata_version_idx [
    id: "version",
    type: :index,
    indexer: Indexer.MDVAppVersion,
    source: SchemaMigration.source(),
    fields: [:version],
    options: []
  ]

  @builtin_metadata %{
    SchemaMigration.source() => %{
      partial_indexes: [],
      other: [],
      indexes: [@app_metadata_version_idx]
    }
  }

  defstruct indexes: [], partial_indexes: [], other: []

  @type t() :: %__MODULE__{}

  def source(), do: @metadata_source

  def new(metadata_kwl) do
    new(%__MODULE__{}, metadata_kwl)
  end

  defp new(struct, []) do
    %__MODULE__{indexes: indexes, other: other} = struct
    %{struct | indexes: Enum.reverse(indexes), other: Enum.reverse(other)}
  end

  defp new(struct, [meta | metadata_kwl]) do
    %__MODULE__{indexes: indexes, other: other} = struct

    # Default type is index for historical reasons
    case meta[:type] || :index do
      :index ->
        new(%{struct | indexes: [meta | indexes]}, metadata_kwl)

      _ ->
        new(%{struct | other: [meta | other]}, metadata_kwl)
    end
  end

  def find_indexes_using_field(metadata, field) do
    %__MODULE__{indexes: indexes, partial_indexes: partial_indexes} = metadata

    indexes = indexes ++ for({idx, _} <- partial_indexes, do: idx)

    Enum.filter(indexes, fn idx -> field in idx[:fields] end)
  end

  @doc """
  Create an FDB kv to be stored in the schema_migrations source. This kv contains
  the information necessary to manage data objects' associated indexes.

  ## Examples

    iex> tenant = %EctoFoundationDB.Tenant{backend: EctoFoundationDB.Tenant.ManagedTenant}
    iex> {key, obj} = EctoFoundationDB.Layer.Metadata.new_index(tenant, "users", "users_name_index", [:name], [])
    iex> {EctoFoundationDB.Tenant.unpack(tenant, key), obj}
    {{"\\xFE", "indexes", "users", "users_name_index"}, [id: "users_name_index", type: :index, indexer: EctoFoundationDB.Indexer.Default, source: "users", fields: [:name], options: []]}

  """
  def new_index(tenant, source, index_name, index_fields, options) do
    metadata_key = metadata_key(tenant, source, index_name)

    idx = [
      id: index_name,
      type: :index,
      indexer: get_indexer(options),
      source: source,
      fields: index_fields,
      options: options
    ]

    {metadata_key, idx}
  end

  def metadata_key(tenant, source, index_name) do
    Pack.namespaced_pack(tenant, source(), source, ["#{index_name}"])
  end

  defp get_indexer(options) do
    case Keyword.get(options, :indexer) do
      nil -> Indexer.Default
      module -> module
    end
  end

  def select_index(metadata, constraints) do
    %__MODULE__{indexes: indexes} = metadata

    case Enum.min(indexes, &choose_a_over_b_or_throw(&1, &2, constraints), fn -> nil end) do
      nil -> nil
      idx -> if(sufficient?(idx, constraints), do: idx, else: nil)
    end
  catch
    {:best, idx} -> idx
  end

  def arrange_constraints(constraints, idx) do
    constraints = for op <- constraints, do: {op.field, op}

    for(field <- idx[:fields], do: constraints[field])
    |> Enum.filter(fn
      nil -> false
      _ -> true
    end)
  end

  defp sufficient?(idx, constraints) when is_list(idx) and is_list(constraints) do
    fields = idx[:fields] |> Enum.into(MapSet.new())
    where_fields_list = for(op <- constraints, do: op.field)
    where_fields = Enum.into(where_fields_list, MapSet.new())
    sufficient?(fields, where_fields)
  end

  defp sufficient?(fields, where_fields) do
    0 == MapSet.difference(where_fields, fields) |> MapSet.size()
  end

  defp choose_a_over_b_or_throw(idx_a, idx_b, constraints) do
    fields_a = idx_a[:fields] |> Enum.into(MapSet.new())
    fields_b = idx_b[:fields] |> Enum.into(MapSet.new())
    where_fields_list = for(op <- constraints, do: op.field)
    where_fields = Enum.into(where_fields_list, MapSet.new())

    overlap_a = MapSet.intersection(where_fields, fields_a) |> MapSet.size()
    overlap_b = MapSet.intersection(where_fields, fields_b) |> MapSet.size()

    match_a? = overlap_a == MapSet.size(fields_a) and overlap_a == MapSet.size(where_fields)
    match_b? = overlap_b == MapSet.size(fields_b) and overlap_b == MapSet.size(where_fields)

    exact_match_short_circuit(match_a?, idx_a, match_b?, idx_b)

    # Most indexes will be Default indexes, so we can optimize for that case. Default index allows 0 or 1
    # Between ops, and it always must be the last field in the index.
    between_fields = for %QueryPlan.Between{field: field} <- constraints, do: field

    final_between_a? =
      length(between_fields) <= 1 and
        Enum.slice(idx_a[:fields], 0, length(where_fields_list)) ==
          where_fields_list

    final_between_b? =
      length(between_fields) <= 1 and
        Enum.slice(idx_b[:fields], 0, length(where_fields_list)) ==
          where_fields_list

    default_index_short_circuit(
      match_a?,
      final_between_a?,
      idx_a,
      match_b?,
      final_between_b?,
      idx_b
    )

    # index_sufficient is true when the where fields are a subset of the index fields
    index_sufficient_a = sufficient?(fields_a, where_fields)
    index_sufficient_b = sufficient?(fields_b, where_fields)

    choose_a_over_b_partial(
      index_sufficient_a,
      final_between_a?,
      overlap_a,
      fields_a,
      index_sufficient_b,
      final_between_b?,
      overlap_b,
      fields_b
    )
  end

  defp exact_match_short_circuit(match_a?, idx_a, match_b?, idx_b)
  defp exact_match_short_circuit(true, idx_a, false, _idx_b), do: throw({:best, idx_a})
  defp exact_match_short_circuit(false, _idx_a, true, idx_b), do: throw({:best, idx_b})
  defp exact_match_short_circuit(_, _idx_a, _, _idx_b), do: nil

  defp default_index_short_circuit(
         match_a?,
         final_between_a?,
         indx_a,
         match_b?,
         final_between_b?,
         idx_b
       )

  defp default_index_short_circuit(true, true, idx_a, true, false, _idx_b),
    do: throw({:best, idx_a})

  defp default_index_short_circuit(true, false, _idx_a, true, true, idx_b),
    do: throw({:best, idx_b})

  defp default_index_short_circuit(true, _, idx_a, true, _, _idx_b), do: throw({:best, idx_a})
  defp default_index_short_circuit(_, _, _idx_a, _, _, _idx_b), do: nil

  defp choose_a_over_b_partial(
         index_sufficient_a,
         final_between_a?,
         overlap_a,
         fields_a,
         index_sufficient_b,
         final_between_b?,
         overlap_b,
         fields_b
       )

  defp choose_a_over_b_partial(true, _, _, _, false, _, _, _), do: true
  defp choose_a_over_b_partial(false, _, _, _, true, _, _, _), do: false

  # Then, check to see if the final field is a between constraint. This
  # optimizes for Default indexes
  defp choose_a_over_b_partial(_, true, _, _, _, false, _, _), do: true
  defp choose_a_over_b_partial(_, false, _, _, _, true, _, _), do: false

  # Finally, check for the best partial matches
  defp choose_a_over_b_partial(_, _, overlap_a, fields_a, _, _, overlap_b, fields_b) do
    constraints_partially_determined_a = overlap_a / MapSet.size(fields_a)

    constraints_partially_determined_b = overlap_b / MapSet.size(fields_b)

    constraints_partially_determined_a > constraints_partially_determined_b
  end

  @doc """
  Executes function within a transaction, while also supplying the indexes currently
  existing for the schema.

  This function uses the Ecto cache and clever use of FDB constructs to guarantee
  that the cache is consistent with transactional semantics.
  """
  def transactional(tenant, source, fun) do
    transactional(tenant, %{cache: nil}, source, fun)
  end

  def transactional(tenant, %{cache: cache}, source, fun) do
    cache? = :enabled == (tenant.options[:metadata_cache] || :enabled)
    cache = if cache?, do: cache, else: nil

    future = Future.before_transactional()

    Tx.transactional(tenant, fn tx ->
      transactional_(tenant, tx, cache, source, future, fun)
    end)
  end

  def transactional_(tenant, tx, nil, source, future, fun) do
    {_vsn, metadata, validator} = get_metadata(tenant, tx, source, nil, future)

    apply_optimistically(tx, metadata, fun, validator, nil, nil)
  end

  def transactional_(tenant, tx, cache, source, future, fun) do
    now = System.monotonic_time(:millisecond)

    cache_key = Cache.key(tenant, source)
    cache_item = Cache.lookup(cache, cache_key)

    {mdv, metadata, validator} = get_metadata(tenant, tx, source, cache_item, future)

    Cache.update(cache, Cache.CacheItem.new(cache_key, mdv, metadata, now))

    apply_optimistically(tx, metadata, fun, validator, cache, cache_key)
  end

  defp apply_optimistically(tx, metadata, fun, validator, cache, cache_key) do
    fun.(tx, metadata)
  after
    unless validator.() do
      if cache, do: :ets.delete(cache, cache_key)
      :erlang.error(@metadata_operation_failed)
    end
  end

  defp get_metadata(tenant, tx, source, cache_item, future) do
    valid = fn -> true end

    case Map.get(@builtin_metadata, source, nil) do
      nil ->
        global_only_mdv = MetadataVersion.tx_get_new(tx, future) |> Future.result()

        # We skip the global check if this is not our first time through the transaction.
        # Otherwise, another tenant's index creation can cause us to retry more than is required.
        if not Tx.repeated?(tx) and Cache.CacheItem.match_global?(cache_item, global_only_mdv) do
          # Waiting on the global metadataVersion key is cheap (sent with every GRV). This buys
          # us the privilege of having a trivial validator. If the global metadataVersion matches,
          # the transaction will always succeed (wrt our Metadata checks)
          mdv = Cache.CacheItem.get_metadata_version(cache_item)
          metadata = Cache.CacheItem.get_metadata(cache_item)
          {mdv, metadata, valid}
        else
          mdv_future =
            MetadataVersion.tx_with_app(
              global_only_mdv,
              tenant,
              tx,
              @app_metadata_version_idx,
              future
            )

          claim_future = MigrationsPJ.tx_get_claim_status(tenant, tx, source, future)
          _get_metadata(tenant, tx, source, cache_item, mdv_future, claim_future, future)
        end

      metadata ->
        {nil, struct(__MODULE__, metadata), valid}
    end
  end

  defp _get_metadata(tenant, tx, source, nil, mdv_future, claim_future, future) do
    read_metadata(tenant, tx, source, future, mdv_future, claim_future)
  end

  defp _get_metadata(_tenant, _tx, _source, cache_item, mdv_future, claim_future, _future) do
    get_metadata_from_cache(cache_item, mdv_future, claim_future)
  end

  defp get_metadata_from_cache(cache_item, mdv_future, claim_future) do
    mdv = Cache.CacheItem.get_metadata_version(cache_item)

    vsn_validator = fn ->
      [mdv2, claim_status] =
        [mdv_future, claim_future]
        |> Future.await_stream()
        |> Enum.map(&Future.result/1)

      {claim_active?, _} = claim_status

      Cache.CacheItem.match_local?(cache_item, mdv2) and not claim_active?
    end

    {mdv, Cache.CacheItem.get_metadata(cache_item), vsn_validator}
  end

  defp read_metadata(tenant, tx, source, future, mdv_future, claim_future) do
    metadata_future = tx_get_metadata(tenant, tx, source, future)

    [mdv, claim_status, metadata] =
      [mdv_future, claim_future, metadata_future]
      |> Future.await_stream()
      |> Enum.map(&Future.result/1)

    case claim_status do
      {claim_active?, []} ->
        {mdv, metadata, fn -> not claim_active? end}

      {_, partial_idxs} ->
        # Adding a write conflict here allows writes to commit at a lower latency
        # at the expense of migration taking longer, because we're not letting the
        # migration job starve us out
        MigrationsPJ.tx_add_claim_write_conflict(tenant, tx, source)
        metadata = %{metadata | partial_indexes: partial_idxs}
        {mdv, metadata, fn -> true end}
    end
  end

  defp metadata_range(tenant, source) do
    Pack.namespaced_range(tenant, source(), source, [])
  end

  def tx_get_metadata(tenant, tx, source, future) do
    {start_key, end_key} = metadata_range(tenant, source)

    Future.set(
      future,
      tx,
      :erlfdb.get_range(tx, start_key, end_key, wait: false),
      &decode_metadata/1
    )
  end

  defp decode_metadata(result) do
    result
    |> Enum.map(fn {_key, fdb_value} -> Pack.from_fdb_value(fdb_value) end)
    # high priority first
    |> Enum.sort(&(Keyword.get(&1, :priority, 0) > Keyword.get(&2, :priority, 0)))
    |> new()
  end
end
