lib/selecto_components/views/detail/process.ex

defmodule SelectoComponents.Views.Detail.Process do
  alias SelectoComponents.Helpers.BucketParser
  alias SelectoComponents.Views.Detail.Options
  alias SelectoComponents.Views.Detail.RowActions

  def param_to_state(params, _v) do
    ## state is used to draw the form
    %{
      selected: SelectoComponents.Views.view_param_process(params, "selected", "field"),
      order_by: SelectoComponents.Views.view_param_process(params, "order_by", "field"),
      per_page: normalize_per_page_param(Map.get(params, "per_page")),
      max_rows: Options.normalize_max_rows_param(Map.get(params, "max_rows")),
      count_mode: Options.normalize_count_mode_param(Map.get(params, "count_mode")),
      row_click_action:
        Options.normalize_row_click_action_param(Map.get(params, "row_click_action")),
      prevent_denormalization: prevent_denormalization_enabled?(params)
    }
  end

  def initial_state(selecto, _v) do
    %{
      order_by:
        Map.get(Selecto.domain(selecto), :default_order_by, [])
        |> SelectoComponents.Helpers.build_initial_state(),
      selected:
        Map.get(Selecto.domain(selecto), :default_selected, [])
        |> SelectoComponents.Helpers.build_initial_state(),
      per_page: "30",
      max_rows: Options.default_max_rows(),
      count_mode: Options.default_count_mode(),
      row_click_action: "",
      prevent_denormalization: true
    }
  end

  ### Process incoming params to build Selecto.set for view
  def view(_opt, params, columns, filtered, selecto) do
    per_page = parse_positive_integer(Map.get(params, "per_page"), 30)
    max_rows = Options.normalize_max_rows_param(Map.get(params, "max_rows"))
    count_mode = Options.normalize_count_mode_param(Map.get(params, "count_mode"))

    row_click_action =
      Options.normalize_row_click_action_param(Map.get(params, "row_click_action"))

    detail_columns =
      params
      |> Map.get("selected", %{})
      |> normalize_selected_entries()

    row_action = RowActions.current_action(selecto, row_click_action)

    query_detail_columns =
      detail_columns
      |> append_required_row_action_fields(
        RowActions.additional_required_fields(row_action, detail_columns)
      )

    # Check if denormalization prevention is enabled (checkbox sends "on" when checked)
    prevent_denorm = prevent_denormalization_enabled?(params)

    # Process columns for denormalization if enabled
    {selected_columns, visible_columns, subselect_configs, denorm_groups} =
      if prevent_denorm do
        column_names = Enum.map(query_detail_columns, & &1["field"])

        {normal_cols, denorm_groups} =
          SelectoComponents.DenormalizationDetector.detect_and_group_columns(
            selecto,
            column_names
          )

        if normal_cols == [] and query_detail_columns != [] do
          # If every selected column denormalizes, keep the original selection
          # so we don't build an empty SELECT list.
          {query_detail_columns, detail_columns, [], %{}}
        else
          # Filter query columns to only include normal columns
          normal_query_detail_columns =
            Enum.filter(query_detail_columns, fn col ->
              col["field"] in normal_cols
            end)

          # Keep only visible columns in the rendered table
          normal_detail_columns =
            Enum.filter(detail_columns, fn col ->
              col["field"] in normal_cols
            end)

          visible_denorm_groups =
            filter_visible_denorm_groups(denorm_groups, detail_columns)

          # Generate subselect configurations for UI display
          subselect_configs =
            Enum.map(visible_denorm_groups, fn {path, cols} ->
              config = SelectoComponents.SubselectBuilder.generate_nested_config(path, cols)
              # Add the actual columns to the config for later use
              config =
                Map.put(
                  config,
                  :columns,
                  Enum.map(cols, fn col ->
                    {UUID.uuid4(), col, %{}}
                  end)
                )

              config
            end)

          {normal_query_detail_columns, normal_detail_columns, subselect_configs, denorm_groups}
        end
      else
        {query_detail_columns, detail_columns, [], %{}}
      end

    ### Selecto Set for Detail View, view_meta for view data
    {%{
       columns: visible_columns,
       row_action_query_columns: selected_columns,
       selected: selected_columns |> selected(columns),
       order_by:
         Map.get(params, "order_by", %{})
         |> order_by(columns),
       filtered: filtered,
       group_by: [],
       groups: [],
       subselects: subselect_configs,
       # Store the groups for building actual subselects
       denorm_groups: denorm_groups,
       denormalizing_columns: if(prevent_denorm, do: detail_columns -- visible_columns, else: [])
     },
     %{
       page: String.to_integer(Map.get(params, "detail_page", "0")),
       per_page: per_page,
       max_rows: max_rows,
       count_mode: count_mode,
       row_click_action: row_click_action,
       prevent_denormalization: prevent_denorm,
       subselect_configs: subselect_configs
     }}
  end

  defp normalize_per_page_param(value) do
    value
    |> parse_positive_integer(30)
    |> to_string()
  end

  defp parse_positive_integer(value, _default) when is_integer(value) and value > 0, do: value

  defp parse_positive_integer(value, default) when is_binary(value) do
    case Integer.parse(String.trim(value)) do
      {parsed, ""} when parsed > 0 -> parsed
      _ -> default
    end
  end

  defp parse_positive_integer(_value, default), do: default

  defp normalize_selected_entries(selected) when is_map(selected) do
    selected
    |> Enum.map(fn {uuid, entry} ->
      normalize_selected_entry(entry, to_string(uuid), nil)
    end)
    |> sort_selected_entries()
  end

  defp normalize_selected_entries(selected) when is_list(selected) do
    selected
    |> Enum.with_index()
    |> Enum.map(fn {entry, idx} ->
      normalize_selected_entry(entry, nil, idx)
    end)
    |> sort_selected_entries()
  end

  defp normalize_selected_entries(_), do: []

  defp prevent_denormalization_enabled?(params) when is_map(params) do
    case Map.get(params, "prevent_denormalization") do
      value when value in [true, "true", "on", 1, "1"] ->
        true

      value when value in [false, "false", 0, "0"] ->
        false

      [value | _rest] ->
        value in [true, "true", "on", 1, "1"]

      nil ->
        Map.get(params, "selected") == nil

      _other ->
        false
    end
  end

  defp prevent_denormalization_enabled?(_params), do: true

  defp normalize_selected_entry(%{} = config, fallback_uuid, fallback_index) do
    config
    |> map_string_keys()
    |> Map.put_new("uuid", fallback_uuid || UUID.uuid4())
    |> Map.put_new("index", to_string(fallback_index || 0))
    |> Map.put_new("alias", "")
  end

  defp normalize_selected_entry({uuid, field, %{} = config}, _fallback_uuid, fallback_index) do
    config
    |> map_string_keys()
    |> Map.put_new("uuid", to_string(uuid))
    |> Map.put_new("field", to_string(field))
    |> Map.put_new("index", to_string(fallback_index || 0))
    |> Map.put_new("alias", "")
  end

  defp normalize_selected_entry({uuid, field, config}, fallback_uuid, fallback_index) do
    config_map = if is_map(config), do: map_string_keys(config), else: %{}

    config_map
    |> Map.put_new("uuid", to_string(uuid || fallback_uuid || UUID.uuid4()))
    |> Map.put_new("field", to_string(field))
    |> Map.put_new("index", to_string(fallback_index || 0))
    |> Map.put_new("alias", "")
  end

  defp normalize_selected_entry([uuid, field, %{} = config], _fallback_uuid, fallback_index) do
    config
    |> map_string_keys()
    |> Map.put_new("uuid", to_string(uuid))
    |> Map.put_new("field", to_string(field))
    |> Map.put_new("index", to_string(fallback_index || 0))
    |> Map.put_new("alias", "")
  end

  defp normalize_selected_entry([uuid, field, _config], _fallback_uuid, fallback_index) do
    %{
      "uuid" => to_string(uuid),
      "field" => to_string(field),
      "index" => to_string(fallback_index || 0),
      "alias" => ""
    }
  end

  defp normalize_selected_entry(field, fallback_uuid, fallback_index) when is_binary(field) do
    %{
      "uuid" => fallback_uuid || UUID.uuid4(),
      "field" => field,
      "index" => to_string(fallback_index || 0),
      "alias" => ""
    }
  end

  defp normalize_selected_entry(field, fallback_uuid, fallback_index) when is_atom(field) do
    normalize_selected_entry(Atom.to_string(field), fallback_uuid, fallback_index)
  end

  defp normalize_selected_entry(_entry, fallback_uuid, fallback_index) do
    %{
      "uuid" => fallback_uuid || UUID.uuid4(),
      "field" => nil,
      "index" => to_string(fallback_index || 0),
      "alias" => ""
    }
  end

  defp sort_selected_entries(entries) do
    Enum.sort(entries, fn a, b ->
      selected_entry_index(a) <= selected_entry_index(b)
    end)
  end

  defp append_required_row_action_fields(detail_columns, []), do: detail_columns

  defp append_required_row_action_fields(detail_columns, required_fields) do
    existing_fields =
      detail_columns
      |> Enum.map(&Map.get(&1, "field"))
      |> MapSet.new()

    next_index =
      detail_columns
      |> Enum.map(&selected_entry_index/1)
      |> Enum.max(fn -> -1 end)
      |> Kernel.+(1)

    hidden_columns =
      required_fields
      |> Enum.reject(&MapSet.member?(existing_fields, &1))
      |> Enum.with_index(next_index)
      |> Enum.map(fn {field, index} ->
        %{
          "uuid" => UUID.uuid4(),
          "field" => field,
          "index" => to_string(index),
          "alias" => field,
          "hidden" => true,
          "row_action_required" => true
        }
      end)

    detail_columns ++ hidden_columns
  end

  defp filter_visible_denorm_groups(denorm_groups, detail_columns) do
    visible_fields =
      detail_columns
      |> Enum.map(&Map.get(&1, "field"))
      |> MapSet.new()

    Enum.filter(denorm_groups, fn {_path, cols} ->
      Enum.any?(cols, &MapSet.member?(visible_fields, &1))
    end)
  end

  defp selected_entry_index(entry) do
    case Integer.parse(to_string(Map.get(entry, "index", "0"))) do
      {index, _} -> index
      :error -> 0
    end
  end

  defp map_string_keys(map) when is_map(map) do
    Map.new(map, fn
      {key, value} when is_atom(key) -> {Atom.to_string(key), value}
      {key, value} when is_binary(key) -> {key, value}
      {key, value} -> {to_string(key), value}
    end)
  end

  defp order_by(order_by, _columns) do
    order_by
    |> Map.values()
    |> Enum.sort(fn a, b -> String.to_integer(a["index"]) <= String.to_integer(b["index"]) end)
    |> Enum.map(fn e ->
      case e["dir"] do
        "desc" -> {:desc, e["field"]}
        _ -> e["field"]
      end
    end)
  end

  defp selected(detail_selected, columns) do
    date_formats = SelectoComponents.Helpers.date_formats()

    detail_selected
    |> Enum.map(fn e ->
      col = columns[e["field"]]

      alias =
        case e["alias"] do
          "" -> e["field"]
          nil -> e["field"]
          _ -> e["alias"]
        end

      # move to a validation lib
      case Selecto.Temporal.date_like_type(col) || col.type do
        x when x in [:naive_datetime, :utc_datetime, :date] ->
          datetime_selected(col, e, alias, date_formats)

        :custom_column ->
          case Map.get(col, :requires_select) do
            x when is_list(x) -> {:row, col.requires_select, alias}
            x when is_function(x) -> {:row, col.requires_select.(e), alias}
            nil -> {:field, col.colid, alias}
          end

        _ ->
          {:field, col.colid, alias}
      end
    end)
    |> List.flatten()
  end

  defp datetime_selected(col, config, alias_name, date_formats) do
    format = Map.get(config, "format")
    bucket_ranges = Map.get(config, "bucket_ranges")

    case format do
      "age_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" ->
        field_with_alias = detail_field_ref(col.colid)

        case_sql =
          BucketParser.generate_bucket_case_sql(
            "EXTRACT(DAY FROM AGE(CURRENT_DATE, #{field_with_alias}))",
            bucket_ranges,
            :integer
          )

        {:field, {:raw_sql, case_sql}, alias_name}

      "custom_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" ->
        field_with_alias = detail_field_ref(col.colid)

        case_sql =
          BucketParser.generate_bucket_case_sql(
            field_with_alias,
            bucket_ranges,
            :date
          )

        {:field, {:raw_sql, case_sql}, alias_name}

      "year_buckets" when is_binary(bucket_ranges) and bucket_ranges != "" ->
        field_with_alias = detail_field_ref(col.colid)

        case_sql =
          BucketParser.generate_bucket_case_sql(
            "EXTRACT(YEAR FROM #{field_with_alias})",
            bucket_ranges,
            :integer
          )

        {:field, {:raw_sql, case_sql}, alias_name}

      _ ->
        to_char_format = Map.get(date_formats, format)

        if is_binary(to_char_format) and to_char_format != "" do
          {:field, {:to_char, {col.colid, to_char_format}}, alias_name}
        else
          {:field, col.colid, alias_name}
        end
    end
  end

  defp detail_field_ref(colid) do
    colid_str = to_string(colid)
    if String.contains?(colid_str, "."), do: colid_str, else: "selecto_root." <> colid_str
  end
end