lib/selecto_components/form/drill_down_filters.ex

defmodule SelectoComponents.Form.DrillDownFilters do
  @moduledoc """
  Handles filter creation logic for drill-down operations from aggregate views and charts.

  This module contains complex logic for:
  - Building filters from aggregate drill-down clicks
  - Handling date/time filters with various formats (YYYY, YYYY-MM, YYYY-MM-DD)
  - Processing bucket ranges (age buckets, numeric ranges)
  - Determining appropriate comparison operators for different field types
  """

  @doc """
  Build filter parameters for aggregate drill-down.

  Takes the clicked parameters and socket assigns, returns a map ready for view_from_params.
  """
  def build_agg_drill_down_params(params, socket) do
    base_params =
      case socket.assigns[:used_params] do
        map when is_map(map) -> map
        _ -> %{}
      end

    view_params =
      base_params
      |> Map.put("view_mode", "detail")
      |> Map.put("filters", build_filter_map(params, socket))

    view_params
  end

  @doc """
  Build filters map from indexed drill-down parameters.
  """
  def build_filter_map(params, socket) do
    build_filter_map_indexed(params, socket)
  end

  # New indexed format: field0/value0, field1/value1
  defp build_filter_map_indexed(params, socket) do
    # Extract field/value pairs
    field_value_pairs = extract_indexed_pairs(params)

    Enum.reduce(
      field_value_pairs,
      existing_filters(socket),
      fn {field_name, v}, acc ->
        newid = UUID.uuid4()

        # Get field configuration
        conf = Selecto.field(socket.assigns.selecto, field_name)

        # If filtering on a join mode ID field, find the display field with metadata
        conf = find_join_mode_field(socket.assigns.selecto, field_name, conf)

        # Collect group-by context for bucket-aware drill-down filters
        group_by_config = Map.get(used_params_map(socket), "group_by", %{})
        field_group_config = find_field_group_config(group_by_config, field_name)
        drill_context = drill_context_from_group_config(field_group_config)

        # Determine comparison mode and values based on format
        {comp_mode, v1, v2} = determine_filter_comp_and_values(v, conf, drill_context)

        # Build filter configuration
        filter_config =
          %{
            "comp" => comp_mode,
            "filter" => field_name,
            "index" => "0",
            "section" => "filters",
            "uuid" => newid,
            "value" => v1,
            "value2" => v2,
            "value_start" => if(comp_mode in ["DATE_BETWEEN", "BETWEEN"], do: v1, else: nil),
            "value_end" => if(comp_mode in ["DATE_BETWEEN", "BETWEEN"], do: v2, else: nil)
          }
          |> maybe_put_text_prefix_options(drill_context, comp_mode)

        # Use group_by_filter if configured
        actual_filter_field =
          if conf && Map.get(conf, :group_by_filter) do
            Map.get(conf, :group_by_filter)
          else
            field_name
          end

        # Update the filter config to use the correct field
        filter_config = Map.put(filter_config, "filter", actual_filter_field)
        Map.put(acc, newid, filter_config)
      end
    )
  end

  # Extract field0/value0, field1/value1 pairs from params
  defp extract_indexed_pairs(params) do
    # Find all field<N> keys
    params
    |> Enum.filter(fn {k, _v} -> String.starts_with?(k, "field") end)
    |> Enum.sort_by(fn {k, _v} -> k end)
    |> Enum.map(fn {field_key, field_name} ->
      # Extract index from "field0" -> "0"
      idx = String.replace_prefix(field_key, "field", "")
      value_key = "value#{idx}"
      value = Map.get(params, value_key, "")
      {field_name, value}
    end)
  end

  defp existing_filters(socket) do
    Map.get(used_params_map(socket), "filters", %{})
  end

  defp used_params_map(socket) do
    case socket.assigns[:used_params] do
      map when is_map(map) -> map
      _ -> %{}
    end
  end

  defp find_field_group_config(group_by_config, field_name) do
    Enum.find_value(Map.values(group_by_config), fn config ->
      if Map.get(config, "field") == field_name do
        config
      else
        nil
      end
    end)
  end

  @doc """
  Determine the appropriate comparison operator and values based on the clicked value format.

  Handles:
  - NULL values (creates IS_EMPTY filter)
  - Bucket ranges (1-10, 11+, Other)
  - Date formats (YYYY-MM-DD, YYYY-MM, YYYY)
  - Age buckets on date fields
  - Text-prefix buckets with optional article exclusion
  - Default equality
  """
  def determine_filter_comp_and_values(value, field_conf, drill_context) do
    context = normalize_drill_context(drill_context)

    cond do
      # Special marker for NULL values - create IS_EMPTY filter
      value == "__NULL__" ->
        {"IS_EMPTY", "", ""}

      # Text prefix buckets from aggregate group-by
      text_prefix_context?(context) ->
        handle_text_prefix_bucket(value, context)

      # YYYY-MM-DD format
      String.match?(value, ~r/^\d{4}-\d{2}-\d{2}$/) ->
        if field_conf && Map.get(field_conf, :type) in [:utc_datetime, :naive_datetime, :date] do
          {"DATE=", value, ""}
        else
          {"=", value, ""}
        end

      # YYYY-MM format
      String.match?(value, ~r/^\d{4}-\d{2}$/) ->
        handle_month_format(value, field_conf)

      # YYYY format
      String.match?(value, ~r/^\d{4}$/) ->
        handle_year_format(value, field_conf)

      # Bucket range patterns
      String.match?(value, ~r/^\d+-\d+$/) || String.match?(value, ~r/^\d+\+$/) || value == "Other" ->
        handle_bucket_range(value, field_conf, context.is_age_bucket)

      # Default datetime handling
      field_conf != nil ->
        handle_datetime_field(value, field_conf)

      # No field configuration
      true ->
        {"=", value, ""}
    end
  end

  defp normalize_drill_context(context) when is_boolean(context) do
    %{
      is_age_bucket: context,
      format: if(context, do: "age_buckets", else: nil),
      prefix_length: 2,
      exclude_articles: true
    }
  end

  defp normalize_drill_context(context) when is_map(context) do
    %{
      is_age_bucket:
        Map.get(context, :is_age_bucket) || Map.get(context, "is_age_bucket") || false,
      format: Map.get(context, :format) || Map.get(context, "format"),
      prefix_length:
        parse_prefix_length(
          Map.get(context, :prefix_length) || Map.get(context, "prefix_length"),
          2
        ),
      exclude_articles:
        parse_boolean(
          Map.get(context, :exclude_articles) || Map.get(context, "exclude_articles"),
          true
        )
    }
  end

  defp normalize_drill_context(_context) do
    %{is_age_bucket: false, format: nil, prefix_length: 2, exclude_articles: true}
  end

  defp text_prefix_context?(%{format: format}) do
    format in ["text_prefix", :text_prefix]
  end

  defp handle_text_prefix_bucket(value, context) do
    trimmed = value |> to_string() |> String.trim()

    cond do
      trimmed == "Other" ->
        {"TEXT_PREFIX_OTHER", "", ""}

      trimmed == "" ->
        {"TEXT_PREFIX_OTHER", "", ""}

      true ->
        prefix =
          trimmed
          |> String.downcase()
          |> String.slice(0, context.prefix_length)

        {"STARTS", prefix, ""}
    end
  end

  defp handle_bucket_range(value, field_conf, is_age_bucket) do
    if is_age_bucket && field_conf &&
         Map.get(field_conf, :type) in [:utc_datetime, :naive_datetime, :date] do
      # Age buckets on date fields - convert to date ranges
      today = Date.utc_today()

      cond do
        # Range like "1-10" or "0-10"
        String.match?(value, ~r/^(\d+)-(\d+)$/) ->
          [min_days_str, max_days_str] = String.split(value, "-")
          max_days = String.to_integer(max_days_str)
          min_days = String.to_integer(min_days_str)
          start_date = Date.add(today, -(max_days + 1))
          end_date = Date.add(today, -min_days)
          {"DATE_BETWEEN", Date.to_iso8601(start_date), Date.to_iso8601(end_date)}

        # Open-ended range like "11+"
        String.match?(value, ~r/^(\d+)\+$/) ->
          days = value |> String.replace("+", "") |> String.to_integer()
          cutoff_date = Date.add(today, -days)
          {"<=", Date.to_iso8601(cutoff_date), ""}

        # "Other" bucket
        value == "Other" ->
          {"=", "", ""}

        true ->
          {"=", value, ""}
      end
    else
      # Numeric buckets
      cond do
        # Range like "1-10"
        String.match?(value, ~r/^(\d+)-(\d+)$/) ->
          [min_str, max_str] = String.split(value, "-")
          {"BETWEEN", min_str, max_str}

        # Open-ended range like "11+"
        String.match?(value, ~r/^(\d+)\+$/) ->
          min_str = String.replace(value, "+", "")
          {">=", min_str, ""}

        # "Other" bucket
        value == "Other" ->
          {"=", "", ""}

        true ->
          {"=", value, ""}
      end
    end
  end

  defp drill_context_from_group_config(nil), do: normalize_drill_context(%{})

  defp drill_context_from_group_config(config) when is_map(config) do
    format = Map.get(config, "format") || Map.get(config, :format)

    normalize_drill_context(%{
      format: format,
      is_age_bucket: format == "age_buckets",
      prefix_length: Map.get(config, "prefix_length") || Map.get(config, :prefix_length),
      exclude_articles:
        Map.get(config, "exclude_articles") || Map.get(config, :exclude_articles, true)
    })
  end

  defp maybe_put_text_prefix_options(filter_config, context, comp_mode)

  defp maybe_put_text_prefix_options(filter_config, context, comp_mode)
       when comp_mode in ["STARTS", "TEXT_PREFIX_OTHER"] do
    filter_config
    |> Map.put("bucket_format", "text_prefix")
    |> Map.put("prefix_length", Integer.to_string(context.prefix_length))
    |> Map.put("exclude_articles", if(context.exclude_articles, do: "true", else: "false"))
    |> Map.put("ignore_case", if(context.exclude_articles, do: "true", else: "false"))
  end

  defp maybe_put_text_prefix_options(filter_config, _context, _comp_mode), do: filter_config

  defp parse_prefix_length(value, _default) when is_integer(value) and value > 0,
    do: min(value, 10)

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

  defp parse_prefix_length(_value, default), do: default

  defp parse_boolean(value, _default) when value in [true, "true", "TRUE", "on", "1", 1], do: true

  defp parse_boolean(value, _default) when value in [false, "false", "FALSE", "off", "0", 0],
    do: false

  defp parse_boolean(nil, default), do: default
  defp parse_boolean(_value, default), do: default

  defp handle_month_format(value, field_conf) do
    if field_conf && Map.get(field_conf, :type) in [:utc_datetime, :naive_datetime, :date] do
      [year_str, month_str] = String.split(value, "-")
      {year, _} = Integer.parse(year_str)
      {month, _} = Integer.parse(month_str)

      start_date = Date.new!(year, month, 1)
      days_in_month = Date.days_in_month(start_date)
      end_date = Date.new!(year, month, days_in_month) |> Date.add(1)

      {"DATE_BETWEEN", Date.to_iso8601(start_date), Date.to_iso8601(end_date)}
    else
      {"=", value, ""}
    end
  end

  defp handle_year_format(value, field_conf) do
    if field_conf && Map.get(field_conf, :type) in [:utc_datetime, :naive_datetime, :date] do
      {year, _} = Integer.parse(value)
      start_date = Date.new!(year, 1, 1)
      end_date = Date.new!(year + 1, 1, 1)

      {"DATE_BETWEEN", Date.to_iso8601(start_date), Date.to_iso8601(end_date)}
    else
      {"=", value, ""}
    end
  end

  defp handle_datetime_field(value, field_conf) do
    field_type = Map.get(field_conf, :type, :string)

    case field_type do
      x when x in [:utc_datetime, :naive_datetime] ->
        {v1_parsed, v2_parsed} =
          Selecto.Helpers.Date.val_to_dates(%{"value" => value, "value2" => ""})

        {"=", v1_parsed, v2_parsed}

      _ ->
        {"=", value, ""}
    end
  end

  @doc """
  Build filter tuples for view_config from drill-down parameters (simpler version for view_config.filters).
  """
  def build_filter_tuples(params, socket) do
    params
    |> extract_indexed_pairs()
    |> Enum.map(fn {field_name, v} ->
      conf = Selecto.field(socket.assigns.selecto, field_name)

      if conf != nil do
        field_type = Map.get(conf, :type, :string)

        case field_type do
          x when x in [:utc_datetime, :naive_datetime] ->
            {v1, v2} = Selecto.Helpers.Date.val_to_dates(%{"value" => v, "value2" => ""})
            {UUID.uuid4(), "filters", %{"filter" => field_name, "value" => v1, "value2" => v2}}

          _ ->
            {UUID.uuid4(), "filters", %{"filter" => field_name, "value" => v}}
        end
      else
        {UUID.uuid4(), "filters", %{"filter" => field_name, "value" => v}}
      end
    end)
  end

  defp find_join_mode_field(selecto, field_name, original_conf) do
    cond do
      # Case 1: field_name contains "." like "category.id"
      is_binary(field_name) and String.contains?(field_name, ".") ->
        [schema_name, field_part] = String.split(field_name, ".", parts: 2)

        # Check if this looks like an ID field
        if field_part in ["id", "category_id", "supplier_id", "shipper_id"] or
             String.ends_with?(field_part, "_id") do
          # Get the domain to search for join_mode fields
          domain = Selecto.domain(selecto)

          schema_atom =
            try do
              String.to_existing_atom(schema_name)
            rescue
              ArgumentError -> nil
            end

          if schema_atom do
            schema_config = get_in(domain, [:schemas, schema_atom])

            if schema_config do
              # Search through columns to find one with join_mode metadata matching this ID field
              columns = Map.get(schema_config, :columns, %{})

              found_field =
                Enum.find_value(columns, fn {col_name, col_config} ->
                  # Check if this column has join_mode and its id_field matches our field
                  join_mode = Map.get(col_config, :join_mode)
                  id_field = Map.get(col_config, :id_field)
                  filter_type = Map.get(col_config, :filter_type)

                  # Match if this column is configured for join mode and references our ID field
                  if join_mode in [:lookup, :star, :tag] and filter_type == :multi_select_id and
                       (id_field == :id or Atom.to_string(id_field) == field_part) do
                    # Return the full field name for this display field
                    {col_name, col_config}
                  else
                    nil
                  end
                end)

              case found_field do
                {display_col_name, display_col_config} ->
                  # Build the qualified field name
                  qualified_name = "#{schema_name}.#{display_col_name}"

                  # Merge the display field config with necessary metadata
                  Map.merge(original_conf || %{}, display_col_config)
                  |> Map.put(:_display_field_name, qualified_name)
                  # Remember we're actually filtering on the ID field
                  |> Map.put(:_filter_on_field, field_name)

                nil ->
                  original_conf
              end
            else
              original_conf
            end
          else
            original_conf
          end
        else
          original_conf
        end

      # Case 2: field_name is a foreign key like "category_id" (no dot)
      is_binary(field_name) and String.ends_with?(field_name, "_id") ->
        domain = Selecto.domain(selecto)
        schemas = Map.get(domain, :schemas, %{})

        # Search all schemas for a field with group_by_filter matching this field_name
        found_field =
          Enum.find_value(schemas, fn {schema_name, schema_config} ->
            columns = Map.get(schema_config, :columns, %{})

            Enum.find_value(columns, fn {col_name, col_config} ->
              join_mode = Map.get(col_config, :join_mode)
              filter_type = Map.get(col_config, :filter_type)
              group_by_filter = Map.get(col_config, :group_by_filter)

              # Match if this column has group_by_filter pointing to our field
              if join_mode in [:lookup, :star, :tag] and
                   filter_type == :multi_select_id and
                   group_by_filter == field_name do
                {schema_name, col_name, col_config}
              else
                nil
              end
            end)
          end)

        case found_field do
          {schema_name, display_col_name, display_col_config} ->
            qualified_name = "#{schema_name}.#{display_col_name}"

            # Merge the display field config with necessary metadata
            Map.merge(original_conf || %{}, display_col_config)
            |> Map.put(:_display_field_name, qualified_name)
            # Filter stays on the foreign key field
            |> Map.put(:_filter_on_field, field_name)

          nil ->
            original_conf
        end

      true ->
        original_conf
    end
  end
end