lib/selecto/config/overlay.ex

defmodule Selecto.Config.Overlay do
  @moduledoc """
  Merges overlay configurations into base domain configurations.

  Overlays provide a flexible way to customize domain configurations at compile-time
  or runtime without modifying the base configuration. This is particularly useful for:

  - Separating generated code from user customizations
  - Multi-tenant applications with per-tenant configurations
  - Feature flags that change available filters or columns
  - A/B testing different domain configurations
  - Environment-specific domain customizations

  ## Merge Strategy

  - **Column configurations**: Deep merge - overlay extends/overrides base column properties
  - **Filters**: Additive merge - both base and overlay filters are available
  - **Functions**: Deep merge - overlay can add or override named UDF specs
  - **Query members** (`query_members.ctes/values/subqueries`): Deep merge - overlay can
    add or override named query-member presets without replacing the full registry
  - **Schemas** (`schemas`): Deep merge - overlay can add/override schema entries
    without replacing the full schemas map
  - **Joins** (`joins`): Deep merge - overlay can add/override join entries
    without replacing the full joins map
  - **Source associations** (`source.associations`): Deep merge - overlay can add
    or override root associations without replacing the full source map
  - **Redact fields**: Union - unique list of all redacted fields
  - **Other fields**: Shallow merge - overlay takes precedence

  ## Examples

  ### Basic Overlay Merge

      base = %{
        source: %{
          columns: %{
            price: %{type: :decimal}
          },
          redact_fields: []
        },
        filters: %{}
      }

      overlay = %{
        columns: %{
          price: %{
            label: "Product Price",
            format: :currency
          }
        },
        redact_fields: [:internal_notes]
      }

      merged = Selecto.Config.Overlay.merge(base, overlay)
      # => %{
      #   source: %{
      #     columns: %{
      #       price: %{type: :decimal, label: "Product Price", format: :currency}
      #     },
      #     redact_fields: [:internal_notes]
      #   },
      #   filters: %{}
      # }

  ### Runtime Multi-Tenant Configuration

      defmodule MyApp.ProductDomain do
        def domain(tenant_id) do
          base_domain()
          |> Selecto.Config.Overlay.merge(tenant_overlay(tenant_id))
        end

        defp tenant_overlay("premium"), do: %{
          columns: %{price: %{format: :currency_with_symbol}}
        }

        defp tenant_overlay(_), do: %{}
      end

  ### Compile-Time Customization (Generated Domains)

      # Generated by selecto_mix
      defmodule MyApp.SelectoDomains.ProductDomain do
        def domain do
          base_domain()
          |> Selecto.Config.Overlay.merge(overlay())
        end

        # Generated base configuration
        defp base_domain, do: %{...}

        # User-defined overlay
        defp overlay do
          if Code.ensure_loaded?(MyApp.SelectoDomains.Overlays.ProductDomainOverlay) do
            MyApp.SelectoDomains.Overlays.ProductDomainOverlay.overlay()
          else
            %{}
          end
        end
      end
  """

  @doc """
  Merges an overlay configuration into a base domain configuration.

  The overlay is intelligently merged based on the semantics of each configuration key:
  - Columns are deep-merged to allow fine-grained property overrides
  - Filters are combined additively
  - Redact fields are unioned
  - Other fields use overlay value if present

  Returns the merged configuration map.

  ## Parameters

    - `base` - The base domain configuration map
    - `overlay` - The overlay configuration map to merge in

  ## Examples

      iex> base = %{source: %{columns: %{id: %{type: :integer}}, redact_fields: []}}
      iex> overlay = %{columns: %{id: %{label: "ID"}}}
      iex> Selecto.Config.Overlay.merge(base, overlay)
      %{source: %{columns: %{id: %{type: :integer, label: "ID"}}, redact_fields: []}}
  """
  def merge(base, overlay) when is_map(base) and is_map(overlay) do
    base
    |> merge_columns(overlay)
    |> merge_jsonb_schemas(overlay)
    |> merge_filters(overlay)
    |> merge_functions(overlay)
    |> merge_detail_actions(overlay)
    |> merge_query_members(overlay)
    |> merge_schemas(overlay)
    |> merge_joins(overlay)
    |> merge_source_associations(overlay)
    |> merge_redact_fields(overlay)
    |> merge_other_fields(overlay)
  end

  def merge(base, _overlay) when is_map(base) do
    # No overlay or invalid overlay, return base as-is
    base
  end

  # Merges column configurations from overlay into base.
  #
  # Column configurations in the overlay are deeply merged with base columns.
  # This allows overlays to extend or override specific column properties
  # without replacing the entire column configuration.
  defp merge_columns(base, overlay) do
    overlay_columns = get_in(overlay, [:columns]) || %{}

    if map_size(overlay_columns) > 0 do
      # Deep merge overlay columns into base.source.columns
      update_in(base, [:source, :columns], fn base_columns ->
        base_columns = base_columns || %{}
        deep_merge(base_columns, overlay_columns)
      end)
    else
      base
    end
  end

  # Merges JSONB schema definitions from overlay into base columns.
  #
  # For each JSONB schema defined in the overlay, the schema is added
  # to the corresponding column's configuration. This replaces the
  # `schema: :stub` placeholder with the actual schema.
  defp merge_jsonb_schemas(base, overlay) do
    overlay_jsonb_schemas = get_in(overlay, [:jsonb_schemas]) || %{}

    if map_size(overlay_jsonb_schemas) > 0 do
      Enum.reduce(overlay_jsonb_schemas, base, fn {column_name, schema}, acc ->
        # Update the column configuration with the JSONB schema
        update_in(acc, [:source, :columns, column_name], fn column_config ->
          column_config = column_config || %{}
          Map.put(column_config, :schema, schema)
        end)
      end)
    else
      base
    end
  end

  # Merges filter configurations from overlay into base.
  #
  # Overlay filters extend the base filters - both are available.
  # If there's a conflict, overlay filter takes precedence.
  defp merge_filters(base, overlay) do
    overlay_filters = get_in(overlay, [:filters]) || %{}

    if map_size(overlay_filters) > 0 do
      update_in(base, [:filters], fn base_filters ->
        base_filters = base_filters || %{}
        Map.merge(base_filters, overlay_filters)
      end)
    else
      base
    end
  end

  defp merge_functions(base, overlay) do
    overlay_functions = get_in(overlay, [:functions]) || %{}

    if map_size(overlay_functions) > 0 do
      update_in(base, [:functions], fn base_functions ->
        base_functions = base_functions || %{}
        deep_merge(base_functions, overlay_functions)
      end)
    else
      base
    end
  end

  # Merges detail-row action definitions from overlay into base.
  #
  # Detail actions are deep-merged so overlays can add or override individual
  # action definitions without replacing sibling actions.
  defp merge_detail_actions(base, overlay) do
    overlay_detail_actions = get_in(overlay, [:detail_actions]) || %{}

    if map_size(overlay_detail_actions) > 0 do
      update_in(base, [:detail_actions], fn base_detail_actions ->
        base_detail_actions = base_detail_actions || %{}
        deep_merge(base_detail_actions, overlay_detail_actions)
      end)
    else
      base
    end
  end

  # Merges redact_fields from overlay into base.
  #
  # The result is a union of both lists (no duplicates).
  # Also updates the source.redact_fields for consistency.
  defp merge_redact_fields(base, overlay) do
    overlay_redact = get_in(overlay, [:redact_fields]) || []

    if length(overlay_redact) > 0 do
      base
      |> update_in([:source, :redact_fields], fn base_redact ->
        base_redact = base_redact || []
        Enum.uniq(base_redact ++ overlay_redact)
      end)
    else
      base
    end
  end

  # Merges named query members from overlay into base.
  #
  # Query members include named CTE, VALUES, and subquery presets. They are
  # deep-merged so overlays can add or override specific members while keeping
  # the rest of the base registry intact.
  defp merge_query_members(base, overlay) do
    overlay_query_members = get_in(overlay, [:query_members])

    cond do
      is_map(overlay_query_members) and map_size(overlay_query_members) > 0 ->
        update_in(base, [:query_members], fn base_query_members ->
          base_query_members = if is_map(base_query_members), do: base_query_members, else: %{}
          deep_merge(base_query_members, overlay_query_members)
        end)

      true ->
        base
    end
  end

  # Merges top-level schema definitions from overlay into base.
  #
  # Schema entries are deep-merged so overlays can add or override one schema
  # without replacing sibling schemas defined by the base domain.
  defp merge_schemas(base, overlay) do
    overlay_schemas = get_in(overlay, [:schemas])

    cond do
      is_map(overlay_schemas) and map_size(overlay_schemas) > 0 ->
        update_in(base, [:schemas], fn base_schemas ->
          base_schemas = if is_map(base_schemas), do: base_schemas, else: %{}
          deep_merge(base_schemas, overlay_schemas)
        end)

      true ->
        base
    end
  end

  # Merges top-level join definitions from overlay into base.
  #
  # Join entries are deep-merged so overlays can add or override one join
  # without replacing sibling joins defined by the base domain.
  defp merge_joins(base, overlay) do
    overlay_joins = get_in(overlay, [:joins])

    cond do
      is_map(overlay_joins) and map_size(overlay_joins) > 0 ->
        update_in(base, [:joins], fn base_joins ->
          base_joins = if is_map(base_joins), do: base_joins, else: %{}
          deep_merge(base_joins, overlay_joins)
        end)

      true ->
        base
    end
  end

  # Merges root source associations from overlay into base.
  #
  # Source associations are deep-merged so overlays can extend the source graph
  # without replacing sibling source settings such as source_table or fields.
  defp merge_source_associations(base, overlay) do
    overlay_source_associations = get_in(overlay, [:source, :associations])

    cond do
      is_map(overlay_source_associations) and map_size(overlay_source_associations) > 0 ->
        update_in(base, [:source, :associations], fn base_source_associations ->
          base_source_associations =
            if is_map(base_source_associations), do: base_source_associations, else: %{}

          deep_merge(base_source_associations, overlay_source_associations)
        end)

      true ->
        base
    end
  end

  # Merges other overlay fields that aren't handled specially.
  #
  # These fields use shallow merge - overlay replaces base value entirely.
  # Skip special fields that have their own merge logic.
  defp merge_other_fields(base, overlay) do
    skip_fields = [
      :columns,
      :filters,
      :functions,
      :detail_actions,
      :redact_fields,
      :jsonb_schemas,
      :query_members,
      :schemas,
      :joins,
      :source
    ]

    overlay
    |> Map.drop(skip_fields)
    |> Enum.reduce(base, fn {key, value}, acc ->
      Map.put(acc, key, value)
    end)
  end

  # Deep merges two maps recursively.
  #
  # When both values are maps, recursively merges them.
  # Otherwise, the value from map2 takes precedence.
  defp deep_merge(map1, map2) when is_map(map1) and is_map(map2) do
    Map.merge(map1, map2, fn _key, val1, val2 ->
      if is_map(val1) and is_map(val2) do
        deep_merge(val1, val2)
      else
        val2
      end
    end)
  end

  defp deep_merge(_map1, map2), do: map2
end