lib/selecto_components/views/aggregate/component.ex

defmodule SelectoComponents.Views.Aggregate.Component do
  @doc """
    display results of aggregate view
  """
  use Phoenix.LiveComponent
  alias SelectoComponents.Views.Aggregate.Options

  @impl true
  def mount(socket) do
    {:ok,
     assign(socket,
       aggregate_page: 0,
       aggregate_page_loading?: false,
       aggregate_requested_page: nil
     )}
  end

  @impl true
  def update(assigns, socket) do
    previous_exe_id = get_in(socket.assigns, [:view_meta, :exe_id])
    incoming_exe_id = get_in(assigns, [:view_meta, :exe_id])
    incoming_server_paged? = get_in(assigns, [:view_meta, :aggregate_server_paged?]) == true

    incoming_page =
      assigns
      |> Map.get(:aggregate_page, get_in(assigns, [:view_meta, :aggregate_page]))
      |> normalize_page()

    aggregate_page =
      if previous_exe_id && incoming_exe_id && previous_exe_id != incoming_exe_id do
        incoming_page
      else
        Map.get(socket.assigns, :aggregate_page, incoming_page)
      end

    # Force a complete re-assignment to ensure LiveView recognizes data changes
    socket = assign(socket, assigns)

    # Add a timestamp to force re-rendering if data changed
    socket =
      assign(socket,
        aggregate_page: aggregate_page,
        aggregate_server_paged?: incoming_server_paged?,
        aggregate_page_loading?: false,
        aggregate_requested_page: nil,
        last_update: System.system_time(:microsecond)
      )

    {:ok, socket}
  end

  # Determine the hierarchy level of a ROLLUP result row
  # With COALESCE, NULL values appear as "[NULL]" strings
  # ROLLUP NULLs remain as nil or empty string
  defp rollup_level(row, num_group_by_cols) do
    group_cols = Enum.take(row, num_group_by_cols)

    non_nil_count =
      Enum.count(group_cols, fn col ->
        # Count as filled if:
        # - Not nil
        # - Not empty string (ROLLUP NULL)
        # - Not "[NULL]" string (but this IS a filled value from COALESCE - data NULL)
        not is_nil(col) and col != ""
      end)

    non_nil_count
  end

  # Prepare ROLLUP results with hierarchy metadata
  # With COALESCE, data NULLs show as "[NULL]", ROLLUP NULLs show as nil/empty
  # Filter out redundant [NULL] rows that are identical to their rollup subtotal.
  # Also mark which level-0 row is the true grand total so data NULL groups
  # can still render as clickable [NULL] buckets.
  defp prepare_rollup_rows(results, num_group_by_cols) do
    rows_with_metadata =
      results
      |> Enum.with_index()
      |> Enum.map(fn {row, idx} ->
        level = rollup_level(row, num_group_by_cols)
        group_cols = Enum.take(row, num_group_by_cols)

        # Check if this row has [NULL] at its current level (data NULL from LEFT JOIN)
        # Level 0 = grand total (no [NULL] possible)
        # Level N = first N columns filled, so check position N-1 for [NULL]
        has_null_at_level = level > 0 && Enum.at(group_cols, level - 1) == "[NULL]"

        {level, row, has_null_at_level, idx}
      end)

    # Filter out [NULL] rows if the next row (rollup subtotal) has identical aggregates
    filtered_rows =
      rows_with_metadata
      |> Enum.with_index()
      |> Enum.filter(fn {{level, row, has_null_at_level, _orig_idx}, current_idx} ->
        if has_null_at_level do
          # This is a row ending with [NULL] - check if next row is its rollup with same values
          next_row = Enum.at(rows_with_metadata, current_idx + 1)
          group_cols = Enum.take(row, num_group_by_cols)

          case next_row do
            {next_level, next_row_data, _has_null, _next_idx} when next_level == level - 1 ->
              # Next row is at the right level - verify it's OUR rollup by checking group columns
              current_group_prefix = Enum.take(group_cols, level - 1)
              next_group_cols = Enum.take(next_row_data, num_group_by_cols)
              next_group_prefix = Enum.take(next_group_cols, level - 1)

              if current_group_prefix == next_group_prefix do
                # Same group - this is our rollup subtotal, compare aggregates
                current_aggs = Enum.drop(row, num_group_by_cols)
                next_aggs = Enum.drop(next_row_data, num_group_by_cols)

                # If aggregates are identical, skip this [NULL] row (redundant)
                not (current_aggs == next_aggs)
              else
                # Different group - this must be end of our group, skip [NULL] row (redundant)
                false
              end

            _ ->
              # Next row is not at the right level or doesn't exist - skip [NULL] row
              false
          end
        else
          # Not a [NULL] row, always keep
          true
        end
      end)

    last_level0_idx =
      filtered_rows
      |> Enum.with_index()
      |> Enum.reduce(nil, fn
        {{{0, row, _has_null, _orig_idx}, _current_idx}, idx}, acc ->
          group_cols = Enum.take(row, num_group_by_cols)
          if Enum.all?(group_cols, &(&1 in [nil, ""])), do: idx, else: acc

        _other, acc ->
          acc
      end)

    filtered_rows
    |> Enum.with_index()
    |> Enum.map(fn {{{level, row, _has_null, _orig_idx}, _current_idx}, idx} ->
      grand_total? = idx == last_level0_idx
      {level, row, grand_total?}
    end)
  end

  # Format a value for display
  # With COALESCE, "[NULL]" strings are already in the data and should be displayed as-is
  # ROLLUP NULLs (nil/empty) should also be shown as "[NULL]"
  defp format_value(value) do
    case value do
      nil ->
        "[NULL]"

      # Empty string from ROLLUP NULL
      "" ->
        "[NULL]"

      {display_value, _id} when is_nil(display_value) or display_value == "" ->
        "[NULL]"

      {display_value, _id} ->
        safe_cell_value(display_value)

      tuple when is_tuple(tuple) ->
        elem_val = elem(tuple, 0)
        if is_nil(elem_val) or elem_val == "", do: "[NULL]", else: safe_cell_value(elem_val)

      # Includes "[NULL]" strings from COALESCE
      _ ->
        safe_cell_value(value)
    end
  end

  defp safe_cell_value(value) do
    if Phoenix.HTML.Safe.impl_for(value) do
      value
    else
      case value do
        nil -> ""
        value when is_atom(value) -> Atom.to_string(value)
        _ -> inspect(value)
      end
    end
  end

  # Format an aggregate value, applying format function if present
  defp format_aggregate_value(value, coldef) do
    formatted =
      case coldef do
        %{format: fmt_fun} when is_function(fmt_fun) -> fmt_fun.(value)
        _ -> value
      end

    format_value(formatted)
  end

  # Build filter attributes for drill-down from group column values
  # Now includes special handling for NULL values - uses "__NULL__" marker for IS_EMPTY filter
  # Uses indexed phx-value attributes to support multiple filter levels
  defp build_filter_attrs(group_cols, group_by_defs, level) do
    group_cols
    |> Enum.zip(group_by_defs)
    |> Enum.with_index()
    |> Enum.filter(fn {{_value, _def}, idx} ->
      # Include all values (including nil) up to current level
      idx < level
    end)
    |> Enum.reduce(%{}, fn {{value, {_alias, {:group_by, field, coldef}}}, idx}, acc ->
      # Determine the filter field name
      # Check for special join modes (lookup, star, tag) that use ID-based filtering
      filter_field =
        case coldef do
          %{group_by_filter: filter} when not is_nil(filter) ->
            filter

          %{"group_by_filter" => filter} when not is_nil(filter) ->
            filter

          # Special join modes - use the configured ID field for filtering
          %{join_mode: mode, id_field: id_field}
          when mode in [:lookup, :star, :tag] and not is_nil(id_field) ->
            # colid might be nil, so extract table prefix from the field tuple
            table_prefix =
              case field do
                {:row, [display_field | _], _} ->
                  # ROW selector - extract from display field
                  case display_field do
                    {:coalesce, [inner | _]} -> extract_table_prefix(inner)
                    _ -> extract_table_prefix(display_field)
                  end

                {:field, field_ref, _} ->
                  extract_table_prefix(field_ref)

                _ ->
                  nil
              end

            # Build the filter field as "table.id_field"
            if table_prefix do
              "#{table_prefix}.#{id_field}"
            else
              Atom.to_string(id_field)
            end

          # Try with string keys too
          %{"join_mode" => mode, "id_field" => id_field}
          when mode in ["lookup", "star", "tag"] and not is_nil(id_field) ->
            # colid might be nil, so extract table prefix from the field tuple
            table_prefix =
              case field do
                {:row, [display_field | _], _} ->
                  # ROW selector - extract from display field
                  case display_field do
                    {:coalesce, [inner | _]} -> extract_table_prefix(inner)
                    _ -> extract_table_prefix(display_field)
                  end

                {:field, field_ref, _} ->
                  extract_table_prefix(field_ref)

                _ ->
                  nil
              end

            # Build the filter field as "table.id_field"
            if table_prefix do
              "#{table_prefix}.#{id_field}"
            else
              to_string(id_field)
            end

          _ ->
            # Extract field name from field tuple, handling COALESCE wrapper
            case field do
              {:field, {:coalesce, [inner_field | _]}, _} ->
                # Field is wrapped in COALESCE - extract the inner field
                case inner_field do
                  {:to_char, {field_name, _format}} -> Atom.to_string(field_name)
                  field_id when is_atom(field_id) -> Atom.to_string(field_id)
                  field_id when is_binary(field_id) -> field_id
                  _ -> "id"
                end

              {:field, {:to_char, {field_name, _format}}, _} ->
                Atom.to_string(field_name)

              {:field, field_id, _} when is_atom(field_id) ->
                Atom.to_string(field_id)

              {:field, field_id, _} when is_binary(field_id) ->
                field_id

              _ ->
                "id"
            end
        end

      # Extract the actual value (handle tuples and NULL)
      filter_value =
        case value do
          # Special marker for IS_EMPTY filter (ROLLUP NULL)
          nil -> "__NULL__"
          # Empty string from ROLLUP NULL
          "" -> "__NULL__"
          # COALESCE result for data NULL
          "[NULL]" -> "__NULL__"
          # COALESCE result in tuple
          {_display, "[NULL]"} -> "__NULL__"
          {_display, filter_val} when is_nil(filter_val) or filter_val == "" -> "__NULL__"
          {_display, filter_val} -> filter_val
          _ -> value
        end

      # Use indexed phx-value attributes to support multiple group levels
      # phx-value-field0, phx-value-value0, phx-value-field1, phx-value1, etc.
      acc
      |> Map.put("phx-value-field#{idx}", filter_field)
      |> Map.put("phx-value-value#{idx}", to_string(filter_value))
      |> Map.put("phx-value-gidx#{idx}", to_string(idx))
    end)
  end

  # Extract table prefix from a field reference
  # Examples: "category.category_name" -> "category", :category_name -> nil
  defp extract_table_prefix(field_ref) do
    case field_ref do
      field_str when is_binary(field_str) ->
        case String.split(field_str, ".") do
          [table, _field] -> table
          _ -> nil
        end

      _ ->
        nil
    end
  end

  # Render a single row with hierarchy styling
  defp rollup_row(assigns) do
    continued? = Map.get(assigns, :continued?, false)
    grand_total? = Map.get(assigns, :grand_total?, false)

    display_level =
      if assigns.level == 0 and not grand_total? and assigns.num_group_by > 0 do
        1
      else
        assigns.level
      end

    # Extract group columns and aggregate columns from the row
    group_cols = Enum.take(assigns.row, assigns.num_group_by)
    agg_cols = Enum.drop(assigns.row, assigns.num_group_by)

    # Determine styling based on hierarchy level
    {row_class, font_weight, indent_px} =
      case display_level do
        # Grand total
        0 -> {"bg-blue-50 border-t-2 border-blue-300", "font-bold", 0}
        # Level 1 subtotal
        1 -> {"bg-gray-50", "font-semibold", 16}
        # Level 2 (or detail if only 2 levels)
        2 -> {"", "font-normal", 32}
        # Level 3
        3 -> {"", "font-normal", 48}
        # Deeper levels
        _ -> {"", "font-normal", 64}
      end

    # The maximum level is the number of group-by columns
    # If we're at max level, it's a detail row (not a subtotal)
    is_detail = display_level == assigns.num_group_by

    # For detail rows, use normal styling unless row is a continuation marker
    {row_class, font_weight} =
      cond do
        continued? ->
          {"bg-amber-50 border-t border-amber-200", "font-semibold italic"}

        is_detail ->
          {"", "font-normal"}

        true ->
          {row_class, font_weight}
      end

    # Build filter attributes for drill-down (accumulated from all parent levels)
    filter_attrs = build_filter_attrs(group_cols, assigns.group_by, display_level)

    assigns =
      assign(assigns,
        group_cols: group_cols,
        agg_cols: agg_cols,
        row_class: row_class,
        font_weight: font_weight,
        indent_px: indent_px,
        filter_attrs: filter_attrs,
        continued?: continued?,
        display_level: display_level,
        grand_total?: grand_total?
      )

    ~H"""
    <tr class={@row_class}>
      <%!-- Render group by columns --%>
      <%= for {{value, {_alias, {:group_by, _field, coldef}}}, idx} <- Enum.zip(@group_cols, @group_by) |> Enum.with_index() do %>
        <td class={"px-3 py-2 text-sm text-gray-900 #{@font_weight}"}>
          <div style={"padding-left: #{if idx == 0, do: @indent_px, else: 0}px"}>
            <%= if @grand_total? and @display_level == 0 and idx == 0 do %>
              <%!-- Grand total row --%>
              <span class="text-gray-400 italic">Total</span>
            <% else %>
              <%!-- Show value only for the rightmost filled/unfilled column at this level --%>
              <%!-- For level N, show column at index N-1 (0-indexed) --%>
              <%= if idx == @display_level - 1 do %>
                <%= if @continued? do %>
                  <span class="text-amber-900">
                    {format_group_value(value, coldef)} (continued)
                  </span>
                <% else %>
                  <div
                    phx-click="agg_add_filters"
                    {@filter_attrs}
                    class="cursor-pointer hover:underline"
                  >
                    {format_group_value(value, coldef)}
                  </div>
                <% end %>
              <% end %>
            <% end %>
          </div>
        </td>
      <% end %>

      <%!-- Render aggregate columns --%>
      <%= for {value, {_alias, {:agg, _agg, coldef}}} <- Enum.zip(@agg_cols, @aggregate) do %>
        <td class={"px-3 py-2 text-sm text-gray-900 #{@font_weight}"}>
          <%= if @continued? do %>
            <span class="text-amber-700">-</span>
          <% else %>
            {format_aggregate_value(value, coldef)}
          <% end %>
        </td>
      <% end %>
    </tr>
    """
  end

  defp prepend_continued_group_headers(rows, num_group_by, aggregate_count, row_offset)
       when is_list(rows) do
    rendered_rows =
      Enum.map(rows, fn {level, row, grand_total?} -> {level, row, false, grand_total?} end)

    if row_offset > 0 do
      case rows do
        [{level, row, _grand_total?} | _] when level > 1 ->
          group_cols = Enum.take(row, num_group_by)

          continued_rows =
            1..(level - 1)
            |> Enum.map(fn parent_level ->
              parent_group_cols =
                Enum.take(group_cols, parent_level) ++
                  List.duplicate(nil, max(num_group_by - parent_level, 0))

              parent_agg_cols = List.duplicate(nil, aggregate_count)

              {parent_level, parent_group_cols ++ parent_agg_cols, true, false}
            end)

          continued_rows ++ rendered_rows

        _ ->
          rendered_rows
      end
    else
      rendered_rows
    end
  end

  defp prepend_continued_group_headers(rows, _num_group_by, _aggregate_count, _row_offset),
    do: rows

  @impl true
  def render(assigns) do
    # Check for execution error first
    if Map.get(assigns, :execution_error) do
      # Error is already displayed by the form component wrapper
      # Just show a message that view cannot be rendered
      ~H"""
      <div>
        <div class="text-gray-500 italic p-4">
          View cannot be displayed due to query error. Please check the error message above.
        </div>
      </div>
      """
    else
      # Check if we have valid query results and execution state
      case {assigns[:executed], assigns.query_results} do
        {false, _} ->
          # Query is being executed or hasn't been executed yet
          ~H"""
          <div>
            <div class="text-blue-500 italic p-4">Loading view...</div>
          </div>
          """

        {true, nil} ->
          # Executed but no results - this is an error state
          ~H"""
          <div>
            <div class="text-red-500 p-4">
              <div class="font-semibold">No Results</div>
              <div class="text-sm mt-1">Query executed but returned no results.</div>
            </div>
          </div>
          """

        {true, {results, _fields, aliases}} ->
          # Valid execution with results - proceed with normal rendering

          # Extract the actual selected fields from the selecto configuration
          # Note: assigns.selecto.set.group_by contains ROLLUP config, not actual fields
          # The actual fields are in assigns.selecto.set.selected
          selected_fields = assigns.selecto.set.selected || []

          # Also get the original group_by and aggregates for processing
          rollup_group_by = assigns.selecto.set.group_by || []
          aggregates = assigns.selecto.set.aggregates || []

          # Use the rollup rendering logic instead of simple flat rendering
          render_aggregate_view(
            assigns,
            results,
            aliases,
            selected_fields,
            rollup_group_by,
            aggregates
          )

        _ ->
          # Fallback for unexpected states
          ~H"""
          <div>
            <div class="text-yellow-500 p-4">
              <div class="font-semibold">Unknown State</div>
              <div class="text-sm mt-1">
                Executed: {inspect(assigns[:executed])}<br />
                Query Results: {inspect(assigns.query_results != nil)}
              </div>
            </div>
          </div>
          """
      end
    end
  end

  defp render_aggregate_view(
         assigns,
         results,
         aliases,
         selected_fields,
         rollup_group_by,
         aggregates
       ) do
    # Use the actual selected fields for counting instead of group_by + aggregates
    # because ROLLUP can add extra fields to the query result
    expected_field_count = Enum.count(selected_fields)
    aliases_count = Enum.count(aliases)

    # If still mismatched at render time, check if we should show loading or error state
    if aliases_count != expected_field_count do
      # If we have no query results or they're stale, show loading
      # If executed is false, we're waiting for a new query
      cond do
        not assigns[:executed] ->
          ~H"""
          <div>
            <div class="text-blue-500 italic p-4">Loading view...</div>
          </div>
          """

        assigns.query_results == nil ->
          ~H"""
          <div>
            <div class="text-blue-500 italic p-4">Loading view...</div>
          </div>
          """

        true ->
          # We have results but they don't match - this suggests a configuration issue
          assigns =
            assign(assigns,
              expected_field_count: expected_field_count,
              aliases_count: aliases_count,
              selected_fields_count: Enum.count(selected_fields),
              aggregates_count: Enum.count(aggregates),
              aliases_debug: inspect(aliases)
            )

          ~H"""
          <div>
            <div class="text-red-500 p-4">
              <div class="font-semibold">View Configuration Error</div>
              <div class="text-sm mt-1">
                Expected {@expected_field_count} fields but got {@aliases_count} from query.
                This usually indicates a mismatch between the view configuration and query results.
              </div>
              <details class="mt-2 text-xs">
                <summary class="cursor-pointer">Debug Info</summary>
                <div>Selected Fields: {@selected_fields_count}</div>
                <div>Aggregate Fields: {@aggregates_count}</div>
                <div>Query Aliases: {@aliases_debug}</div>
              </details>
            </div>
          </div>
          """
      end
    else
      render_synchronized_view(
        assigns,
        results,
        aliases,
        selected_fields,
        rollup_group_by,
        aggregates
      )
    end
  end

  defp render_synchronized_view(
         assigns,
         results,
         aliases,
         selected_fields,
         rollup_group_by,
         aggregates
       ) do
    # Process the selected fields to match the aliases
    # The selected fields should match 1:1 with the aliases from the query
    field_mappings = Enum.zip(aliases, selected_fields)

    # Split the mappings back into group_by and aggregate sections
    # We need to determine which selected fields are group by vs aggregates
    # Look at the rollup_group_by to determine how many group by fields we have

    # Count the actual group by fields (not the ROLLUP wrapper)
    num_group_by =
      case rollup_group_by do
        [{:rollup, positions}] when is_list(positions) -> Enum.count(positions)
        _ -> 0
      end

    # num_aggregates = Enum.count(selected_fields) - num_group_by

    group_by_mappings = Enum.take(field_mappings, num_group_by)
    aggregate_mappings = Enum.drop(field_mappings, num_group_by)

    selecto_group_by_config =
      case assigns do
        %{selecto: %{set: set}} when is_map(set) ->
          Map.get(set, :gb_params) || Map.get(set, "gb_params")

        _ ->
          nil
      end

    view_config_group_by =
      case assigns do
        %{view_config: view_config} when is_map(view_config) ->
          Map.get(view_config, :group_by) || Map.get(view_config, "group_by")

        _ ->
          nil
      end

    group_by_config = selecto_group_by_config || view_config_group_by || %{}

    group_by_param_configs =
      group_by_config
      |> Map.values()
      |> Enum.sort(fn a, b ->
        to_index = fn cfg ->
          cfg
          |> Map.get("index", Map.get(cfg, :index, "0"))
          |> to_string()
          |> String.to_integer()
        end

        to_index.(a) <= to_index.(b)
      end)

    group_by_param_fields =
      Enum.map(group_by_param_configs, fn cfg -> Map.get(cfg, "field") || Map.get(cfg, :field) end)

    # Convert to the format expected by the template
    group_by =
      group_by_mappings
      |> Enum.with_index()
      |> Enum.map(fn {{alias, field}, idx} ->
        # Get the proper column definition from selecto
        # Now that Selecto.field returns full definitions, we get all properties
        coldef =
          case field do
            {:field, {:to_char, {field_name, _format}}, _alias} ->
              # Handle formatted date fields
              Selecto.field(assigns.selecto, field_name) || %{name: alias, format: nil}

            {:field, field_id, _alias} when is_binary(field_id) or is_atom(field_id) ->
              # Selecto.field now returns full custom column definitions with group_by_filter
              result = Selecto.field(assigns.selecto, field_id)

              if result == nil do
                # Field not found - use basic definition
                %{name: alias, format: nil}
              else
                result
              end

            {:field, {_extract_type, field_id, _format}, _alias} ->
              # Handle extracted fields (e.g., date parts)
              Selecto.field(assigns.selecto, field_id) || %{name: alias}

            {:row, [display_field | _rest], _alias} ->
              # For row selectors (e.g., join mode columns), look up the actual column definition
              # display_field might be wrapped in COALESCE - extract the original field name
              field_name =
                case display_field do
                  {:coalesce, [inner_field | _]} -> inner_field
                  other -> other
                end

              # Look up metadata from domain.schemas for joined fields
              result =
                if is_binary(field_name) && String.contains?(field_name, ".") do
                  [schema_name, field_only] = String.split(field_name, ".", parts: 2)

                  # Look up from domain.schemas[schema].columns[field]
                  domain = Selecto.domain(assigns.selecto)

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

                  field_atom =
                    try do
                      String.to_existing_atom(field_only)
                    rescue
                      ArgumentError -> nil
                    end

                  if schema_atom && field_atom do
                    get_in(domain, [:schemas, schema_atom, :columns, field_atom])
                  else
                    nil
                  end
                else
                  Selecto.field(assigns.selecto, field_name)
                end

              if result == nil do
                # Field not found - use basic definition
                %{name: alias, format: nil}
              else
                result
              end

            _ ->
              # Fallback to basic definition
              %{name: alias, format: nil}
          end

        coldef = maybe_set_group_by_filter(coldef, Enum.at(group_by_param_fields, idx))
        coldef = maybe_set_group_by_format(coldef, Enum.at(group_by_param_configs, idx))
        {alias, {:group_by, field, coldef}}
      end)

    aggregates_processed =
      Enum.zip(aggregate_mappings, aggregates)
      |> Enum.map(fn {{alias, _field}, agg} ->
        # Get the proper column definition from selecto
        coldef =
          case agg do
            {:field, {_func, field_id}, _alias} when is_atom(field_id) ->
              Selecto.field(assigns.selecto, field_id)

            {:field, field_id, _alias} when is_atom(field_id) ->
              Selecto.field(assigns.selecto, field_id)

            _ ->
              # Fallback to empty map for unknown aggregate types
              %{}
          end

        {alias, {:agg, agg, coldef}}
      end)

    # Prepare rollup rows with hierarchy level metadata
    num_group_by = length(group_by)
    rollup_rows = prepare_rollup_rows(results, num_group_by)

    page_row_count = length(rollup_rows)
    aggregate_meta = Map.get(assigns, :view_meta, %{})
    server_paged? = Map.get(aggregate_meta, :aggregate_server_paged?, false)

    total_rows_before_cap =
      Map.get(aggregate_meta, :aggregate_total_rows_before_cap, page_row_count)

    rows_capped? = Map.get(aggregate_meta, :aggregate_rows_capped?, false)

    max_client_rows =
      Map.get(aggregate_meta, :aggregate_max_client_rows, Options.default_max_client_rows())

    per_page_setting =
      assigns
      |> Map.get(:view_meta, %{})
      |> Map.get(:per_page, Options.default_per_page())
      |> Options.normalize_per_page_param()

    per_page = Options.per_page_to_int(per_page_setting, page_row_count)

    {paged_rollup_rows, aggregate_total_rows, current_page, max_page, page_start, page_end,
     row_offset} =
      if server_paged? do
        total_rows =
          max(Map.get(aggregate_meta, :aggregate_total_rows, page_row_count), page_row_count)

        max_page =
          if total_rows > 0 and per_page > 0 do
            div(total_rows - 1, per_page)
          else
            0
          end

        current_page =
          aggregate_meta
          |> Map.get(:aggregate_page, Map.get(assigns, :aggregate_page, 0))
          |> normalize_page()
          |> min(max_page)

        row_offset = current_page * per_page

        page_start =
          if total_rows > 0 do
            row_offset + 1
          else
            0
          end

        page_end =
          if total_rows > 0 do
            min(row_offset + page_row_count, total_rows)
          else
            0
          end

        {rollup_rows, total_rows, current_page, max_page, page_start, page_end, row_offset}
      else
        max_page =
          if page_row_count > 0 and per_page > 0 do
            div(page_row_count - 1, per_page)
          else
            0
          end

        current_page =
          assigns
          |> Map.get(:aggregate_page, 0)
          |> normalize_page()
          |> min(max_page)

        row_offset = current_page * per_page

        page_start =
          if page_row_count > 0 do
            row_offset + 1
          else
            0
          end

        page_end =
          if page_row_count > 0 do
            min(row_offset + per_page, page_row_count)
          else
            0
          end

        paged_rows =
          if per_page_setting == "all" do
            rollup_rows
          else
            Enum.slice(rollup_rows, row_offset, per_page)
          end

        {paged_rows, page_row_count, current_page, max_page, page_start, page_end, row_offset}
      end

    paged_rollup_rows =
      prepend_continued_group_headers(
        paged_rollup_rows,
        num_group_by,
        length(aggregates_processed),
        row_offset
      )

    total_pages = if aggregate_total_rows > 0, do: max_page + 1, else: 0

    grid_enabled = truthy?(Map.get(aggregate_meta, :grid_enabled, false))
    grid_available? = grid_enabled and num_group_by == 2 and length(aggregates_processed) == 1

    grid_data =
      if grid_available?,
        do: build_grid_data(paged_rollup_rows, num_group_by, group_by),
        else: nil

    assigns =
      assign(assigns,
        rollup_rows: rollup_rows,
        paged_rollup_rows: paged_rollup_rows,
        num_group_by: num_group_by,
        group_by: group_by,
        aggregate: aggregates_processed,
        aggregate_server_paged?: server_paged?,
        aggregate_total_rows: aggregate_total_rows,
        aggregate_total_rows_before_cap: total_rows_before_cap,
        aggregate_rows_capped?: rows_capped?,
        aggregate_max_client_rows: max_client_rows,
        aggregate_page: current_page,
        aggregate_max_page: max_page,
        aggregate_total_pages: total_pages,
        aggregate_page_start: page_start,
        aggregate_page_end: page_end,
        grid_enabled: grid_enabled,
        grid_available?: grid_available?,
        grid_data: grid_data
      )

    ~H"""
    <div>
      <div class="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-white px-3 py-2">
        <div class="inline-flex items-center gap-1 rounded-md border border-gray-200 bg-white p-1 shadow-sm">
          <button
            type="button"
            phx-click="set_aggregate_page"
            phx-value-page={0}
            phx-target={@myself}
            class="inline-flex h-8 w-8 items-center justify-center rounded border border-gray-200 text-gray-600 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white"
            title="First page"
            aria-label="First page"
            disabled={@aggregate_page <= 0 or @aggregate_page_loading?}
          >
            <svg
              class="h-4 w-4"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="2"
              stroke="currentColor"
              aria-hidden="true"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M18 18L12 12l6-6M10 18 4 12l6-6M4 6v12"
              />
            </svg>
          </button>

          <button
            type="button"
            phx-click="set_aggregate_page"
            phx-value-page={@aggregate_page - 1}
            phx-target={@myself}
            class="inline-flex h-8 items-center gap-1 rounded border border-gray-200 px-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white"
            title="Previous page"
            aria-label="Previous page"
            disabled={@aggregate_page <= 0 or @aggregate_page_loading?}
          >
            <svg
              class="h-4 w-4"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="2"
              stroke="currentColor"
              aria-hidden="true"
            >
              <path stroke-linecap="round" stroke-linejoin="round" d="m15 18-6-6 6-6" />
            </svg>
            Prev
          </button>

          <button
            type="button"
            phx-click="set_aggregate_page"
            phx-value-page={@aggregate_page + 1}
            phx-target={@myself}
            class="inline-flex h-8 items-center gap-1 rounded border border-gray-200 px-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white"
            title="Next page"
            aria-label="Next page"
            disabled={@aggregate_page >= @aggregate_max_page or @aggregate_page_loading?}
          >
            Next
            <svg
              class="h-4 w-4"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="2"
              stroke="currentColor"
              aria-hidden="true"
            >
              <path stroke-linecap="round" stroke-linejoin="round" d="m9 6 6 6-6 6" />
            </svg>
          </button>

          <button
            type="button"
            phx-click="set_aggregate_page"
            phx-value-page={@aggregate_max_page}
            phx-target={@myself}
            class="inline-flex h-8 w-8 items-center justify-center rounded border border-gray-200 text-gray-600 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white"
            title="Last page"
            aria-label="Last page"
            disabled={@aggregate_page >= @aggregate_max_page or @aggregate_page_loading?}
          >
            <svg
              class="h-4 w-4"
              fill="none"
              viewBox="0 0 24 24"
              stroke-width="2"
              stroke="currentColor"
              aria-hidden="true"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                d="M6 18l6-6-6-6m8 12 6-6-6-6m6 0v12"
              />
            </svg>
          </button>
        </div>

        <div class="text-sm font-medium text-gray-700">
          <span class="font-semibold tabular-nums">
            {@aggregate_page_start}-{@aggregate_page_end}
          </span>
          of <span class="font-semibold tabular-nums">{@aggregate_total_rows}</span>
          rows
        </div>

        <div class="text-xs text-gray-500 tabular-nums">
          Page
          <span class="font-semibold">
            {if @aggregate_total_pages > 0, do: @aggregate_page + 1, else: 0}
          </span>
          of <span class="font-semibold">{@aggregate_total_pages}</span>
          <span :if={@aggregate_page_loading?} class="ml-2 text-blue-600">Loading...</span>
        </div>
      </div>

      <div
        :if={@aggregate_rows_capped?}
        class="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900"
      >
        Showing the first <span class="font-semibold tabular-nums">{@aggregate_total_rows}</span>
        rows out of <span class="font-semibold tabular-nums">{@aggregate_total_rows_before_cap}</span>
        to keep rendering responsive. Narrow filters/grouping, or increase
        <code class="rounded bg-amber-100 px-1 py-0.5">:aggregate_max_client_rows</code>
        (currently <span class="font-semibold">{inspect(@aggregate_max_client_rows)}</span>)
        if you need to render more rows at once.
      </div>

      <div
        :if={@grid_enabled and not @grid_available?}
        class="mb-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-900"
      >
        Grid view requires exactly 2 Group By fields and 1 Aggregate.
      </div>

      <%= if @grid_available? and @grid_data do %>
        <div class="mb-2 text-sm font-medium text-gray-700">Aggregate Grid</div>
        <table class="min-w-full overflow-hidden divide-y ring-1 ring-gray-200 divide-gray-200 rounded-sm table-auto sm:rounded">
          <thead class="bg-gray-50">
            <tr>
              <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
                {@grid_data.row_alias}
              </th>
              <%= for col_value <- @grid_data.col_headers do %>
                <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
                  {format_group_value(col_value, @grid_data.col_coldef)}
                </th>
              <% end %>
            </tr>
          </thead>
          <tbody class="divide-y divide-gray-200 bg-white">
            <tr :for={row_value <- @grid_data.row_headers}>
              <td class="px-3 py-2 text-sm font-semibold text-gray-800">
                {format_group_value(row_value, @grid_data.row_coldef)}
              </td>
              <td :for={col_value <- @grid_data.col_headers} class="px-3 py-2 text-sm text-gray-900">
                <div
                  phx-click="agg_add_filters"
                  {build_filter_attrs([row_value, col_value], @group_by, 2)}
                  class="cursor-pointer hover:underline"
                >
                  {format_value(Map.get(@grid_data.cells, {row_value, col_value}))}
                </div>
              </td>
            </tr>
          </tbody>
        </table>
      <% else %>
        <table class="min-w-full overflow-hidden divide-y ring-1 ring-gray-200 divide-gray-200 rounded-sm table-auto sm:rounded">
          <thead class="bg-gray-50">
            <tr>
              <%!-- Headers for group by columns --%>
              <%= for {alias, {:group_by, _field, _coldef}} <- @group_by do %>
                <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
                  {alias}
                </th>
              <% end %>

              <%!-- Headers for aggregate columns --%>
              <%= for {alias, {:agg, _agg, _coldef}} <- @aggregate do %>
                <th class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">
                  {alias}
                </th>
              <% end %>
            </tr>
          </thead>
          <tbody class="divide-y divide-gray-200 bg-white">
            <.rollup_row
              :for={{level, row, continued?, grand_total?} <- @paged_rollup_rows}
              level={level}
              row={row}
              continued?={continued?}
              grand_total?={grand_total?}
              num_group_by={@num_group_by}
              group_by={@group_by}
              aggregate={@aggregate}
            />
          </tbody>
        </table>
      <% end %>
    </div>
    """
  end

  defp truthy?(value) when value in [true, "true", "on", "1", 1], do: true
  defp truthy?(_), do: false

  defp build_grid_data(paged_rollup_rows, num_group_by, group_by) do
    detail_rows =
      Enum.reduce(paged_rollup_rows, [], fn
        {level, row, continued?, grand_total?}, acc when level == num_group_by ->
          if continued? or grand_total? do
            acc
          else
            [row | acc]
          end

        _other, acc ->
          acc
      end)
      |> Enum.reverse()

    {row_headers, col_headers, cells} =
      Enum.reduce(detail_rows, {[], [], %{}}, fn row, {row_acc, col_acc, cells_acc} ->
        row_value = Enum.at(row, 0)
        col_value = Enum.at(row, 1)
        agg_value = Enum.at(row, 2)

        row_acc = if row_value in row_acc, do: row_acc, else: row_acc ++ [row_value]
        col_acc = if col_value in col_acc, do: col_acc, else: col_acc ++ [col_value]
        cells_acc = Map.put(cells_acc, {row_value, col_value}, agg_value)

        {row_acc, col_acc, cells_acc}
      end)

    row_coldef = grid_coldef(group_by, 0)
    col_coldef = grid_coldef(group_by, 1)

    row_headers = sort_group_values(row_headers, row_coldef)
    col_headers = sort_group_values(col_headers, col_coldef)

    %{
      row_alias: grid_row_alias(group_by),
      row_headers: row_headers,
      col_headers: col_headers,
      cells: cells,
      row_coldef: row_coldef,
      col_coldef: col_coldef
    }
  end

  defp grid_row_alias([{alias_name, _} | _]) when is_binary(alias_name), do: alias_name
  defp grid_row_alias(_), do: "Group 1"

  defp grid_coldef(group_by, idx) do
    case Enum.at(group_by, idx) do
      {_alias, {:group_by, _field, coldef}} -> coldef
      _ -> %{}
    end
  end

  defp format_group_value(value, coldef) do
    case Map.get(coldef || %{}, :group_format) || Map.get(coldef || %{}, "group_format") do
      "D" -> weekday_name(value)
      _ -> format_value(value)
    end
  end

  defp weekday_name(value) do
    case parse_int(value) do
      1 -> "Sunday"
      2 -> "Monday"
      3 -> "Tuesday"
      4 -> "Wednesday"
      5 -> "Thursday"
      6 -> "Friday"
      7 -> "Saturday"
      _ -> format_value(value)
    end
  end

  defp parse_int(v) when is_integer(v), do: v

  defp parse_int(v) when is_binary(v) do
    case Integer.parse(v) do
      {num, ""} -> num
      _ -> nil
    end
  end

  defp parse_int(_), do: nil

  defp sort_group_values(values, coldef) when is_list(values) do
    format = Map.get(coldef || %{}, :group_format) || Map.get(coldef || %{}, "group_format")

    case format do
      "D" -> Enum.sort_by(values, &weekday_sort_key/1)
      "HH24" -> Enum.sort_by(values, &int_sort_key/1)
      "MM" -> Enum.sort_by(values, &int_sort_key/1)
      "DD" -> Enum.sort_by(values, &int_sort_key/1)
      _ -> values
    end
  end

  defp weekday_sort_key(value) do
    case parse_int(value) do
      int when is_integer(int) and int >= 1 and int <= 7 -> {0, int}
      _ -> {1, to_string(value)}
    end
  end

  defp int_sort_key(value) do
    case parse_int(value) do
      int when is_integer(int) -> {0, int}
      _ -> {1, to_string(value)}
    end
  end

  defp maybe_set_group_by_filter(coldef, field_name)
       when is_map(coldef) and is_binary(field_name) and field_name != "" do
    coldef
    |> Map.put_new(:group_by_filter, field_name)
    |> Map.put_new("group_by_filter", field_name)
  end

  defp maybe_set_group_by_filter(coldef, _), do: coldef

  defp maybe_set_group_by_format(coldef, cfg) when is_map(coldef) and is_map(cfg) do
    format = Map.get(cfg, "format") || Map.get(cfg, :format)

    if is_binary(format) and format != "" do
      coldef
      |> Map.put(:group_format, format)
      |> Map.put("group_format", format)
    else
      coldef
    end
  end

  defp maybe_set_group_by_format(coldef, _), do: coldef

  @impl true
  def handle_event("set_aggregate_page", %{"page" => page_param}, socket) do
    page =
      page_param
      |> parse_page_param()
      |> normalize_page()
      |> clamp_aggregate_page_if_known(socket.assigns)

    if Map.get(socket.assigns, :aggregate_server_paged?, false) do
      send(self(), {:update_aggregate_page, page})

      {:noreply,
       assign(socket,
         aggregate_page_loading?: true,
         aggregate_requested_page: page
       )}
    else
      {:noreply, assign(socket, :aggregate_page, page)}
    end
  end

  defp parse_page_param(page_param) when is_binary(page_param) do
    case Integer.parse(page_param) do
      {page, ""} -> page
      _ -> 0
    end
  end

  defp parse_page_param(page_param) when is_integer(page_param), do: page_param
  defp parse_page_param(_page_param), do: 0

  defp normalize_page(page) when is_integer(page), do: max(page, 0)
  defp normalize_page(_page), do: 0

  defp clamp_aggregate_page_if_known(page, assigns) do
    case Map.get(assigns, :aggregate_max_page) do
      max_page when is_integer(max_page) and max_page >= 0 -> min(page, max_page)
      _ -> page
    end
  end
end