lib/selecto_components/enhanced_table/bulk_actions.ex

defmodule SelectoComponents.EnhancedTable.BulkActions do
  @moduledoc """
  Bulk actions interface for performing operations on multiple selected records.
  """

  use Phoenix.LiveComponent
  alias SelectoComponents.EnhancedTable.RowSelection
  alias Phoenix.LiveView.JS

  @export_icon_svg ~s(<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>)

  @delete_icon_svg ~s(<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>)

  @trusted_icons MapSet.new([@export_icon_svg, @delete_icon_svg])

  @impl true
  def mount(socket) do
    {:ok,
     socket
     |> RowSelection.init_selection()
     |> assign(
       bulk_action: nil,
       processing: false,
       processed_count: 0,
       total_to_process: 0,
       errors: [],
       show_confirmation: false,
       confirmation_message: nil
     )}
  end

  @impl true
  def update(assigns, socket) do
    actions = assigns[:actions] || default_actions()

    socket =
      socket
      |> assign(assigns)
      |> assign(actions: actions)

    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div id={@id} class="bulk-actions-container" phx-hook=".BulkActions" data-selected-count={@selection_count}>
      <%!-- Bulk Actions Toolbar --%>
      <div class="flex items-center justify-between px-4 py-2 bg-gray-50 border-b">
        <div class="flex items-center space-x-4">
          <%!-- Selection Info --%>
          <%= if @selection_count > 0 do %>
            <div class="flex items-center space-x-2">
              <span class="text-sm font-medium text-gray-700">
                {@selection_count} selected
              </span>
              <button
                type="button"
                class="text-sm text-blue-600 hover:text-blue-800"
                phx-click="clear_selection"
                phx-target={@myself}
              >
                Clear
              </button>
            </div>
          <% end %>

          <%!-- Actions Dropdown --%>
          <div class="relative">
            <button
              type="button"
              class={"px-4 py-2 bg-white border rounded-lg flex items-center space-x-2 #{if @selection_count == 0, do: "opacity-50 cursor-not-allowed", else: "hover:bg-gray-50"}"}
              disabled={@selection_count == 0}
              phx-click={toggle_actions_menu()}
            >
              <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
                />
              </svg>
              <span class="text-sm font-medium">Bulk Actions</span>
              <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path
                  stroke-linecap="round"
                  stroke-linejoin="round"
                  stroke-width="2"
                  d="M19 9l-7 7-7-7"
                />
              </svg>
            </button>

            <div
              id="bulk-actions-menu"
              class="hidden absolute left-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-20"
            >
              <%= for action <- @actions do %>
                {render_action_item(assigns, action)}
              <% end %>
            </div>
          </div>
        </div>

        <%!-- Quick Actions --%>
        <div class="flex items-center space-x-2">
          <%= for action <- Enum.filter(@actions, & &1.quick_action) do %>
            <button
              type="button"
              class={"px-3 py-1.5 text-sm rounded-lg flex items-center space-x-1 #{action_button_class(action, @selection_count)}"}
              disabled={@selection_count == 0 || @processing}
              phx-click="execute_action"
              phx-value-action={action.id}
              phx-target={@myself}
            >
              <%= if icon = safe_icon(action.icon) do %>
                {icon}
              <% end %>
              <span>{action.label}</span>
            </button>
          <% end %>
        </div>
      </div>

      <%!-- Progress Bar --%>
      <%= if @processing do %>
        <div class="px-4 py-2 bg-blue-50 border-b border-blue-200">
          <div class="flex items-center justify-between mb-2">
            <span class="text-sm font-medium text-blue-900">
              Processing {@processed_count} of {@total_to_process} items...
            </span>
            <button
              type="button"
              class="text-sm text-blue-600 hover:text-blue-800"
              phx-click="cancel_processing"
              phx-target={@myself}
            >
              Cancel
            </button>
          </div>
          <div class="w-full bg-blue-200 rounded-full h-2">
            <div
              class="bg-blue-600 h-2 rounded-full transition-all duration-300"
              style={"width: #{progress_percentage(@processed_count, @total_to_process)}%"}
            >
            </div>
          </div>
        </div>
      <% end %>

      <%!-- Error Display --%>
      <%= if length(@errors) > 0 do %>
        <div class="px-4 py-2 bg-red-50 border-b border-red-200">
          <div class="flex items-start">
            <svg
              class="w-5 h-5 text-red-600 mr-2 flex-shrink-0"
              fill="currentColor"
              viewBox="0 0 20 20"
            >
              <path
                fill-rule="evenodd"
                d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
                clip-rule="evenodd"
              />
            </svg>
            <div class="flex-1">
              <p class="text-sm font-medium text-red-900">
                {length(@errors)} {if length(@errors) == 1, do: "error", else: "errors"} occurred
              </p>
              <ul class="mt-1 text-sm text-red-700">
                <%= for error <- Enum.take(@errors, 3) do %>
                  <li>{error}</li>
                <% end %>
                <%= if length(@errors) > 3 do %>
                  <li class="text-red-600">...and {length(@errors) - 3} more</li>
                <% end %>
              </ul>
            </div>
            <button
              type="button"
              class="text-red-600 hover:text-red-800"
              phx-click="dismiss_errors"
              phx-target={@myself}
            >
              <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
                <path
                  fill-rule="evenodd"
                  d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
                  clip-rule="evenodd"
                />
              </svg>
            </button>
          </div>
        </div>
      <% end %>

      <%!-- Confirmation Dialog --%>
      <%= if @show_confirmation do %>
        <div
          id="confirmation-dialog"
          class="fixed inset-0 z-50 overflow-y-auto"
          phx-hook=".ConfirmationDialog"
        >
          <div class="flex items-center justify-center min-h-screen px-4">
            <div
              class="fixed inset-0 bg-gray-500 bg-opacity-75"
              phx-click="cancel_action"
              phx-target={@myself}
            >
            </div>

            <div class="relative bg-white rounded-lg shadow-xl max-w-md w-full">
              <div class="px-6 py-4">
                <div class="flex items-start">
                  <div class="flex-shrink-0">
                    <svg
                      class="w-6 h-6 text-yellow-600"
                      fill="none"
                      stroke="currentColor"
                      viewBox="0 0 24 24"
                    >
                      <path
                        stroke-linecap="round"
                        stroke-linejoin="round"
                        stroke-width="2"
                        d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
                      />
                    </svg>
                  </div>
                  <div class="ml-3 flex-1">
                    <h3 class="text-lg font-medium text-gray-900">Confirm Bulk Action</h3>
                    <p class="mt-2 text-sm text-gray-500">
                      {@confirmation_message}
                    </p>
                    <p class="mt-2 text-sm font-medium text-gray-700">
                      This will affect {@selection_count} {if @selection_count == 1,
                        do: "item",
                        else: "items"}.
                    </p>
                  </div>
                </div>
              </div>

              <div class="px-6 py-4 bg-gray-50 flex justify-end space-x-2">
                <button
                  type="button"
                  class="px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-100"
                  phx-click="cancel_action"
                  phx-target={@myself}
                >
                  Cancel
                </button>
                <button
                  type="button"
                  class="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700"
                  phx-click="confirm_action"
                  phx-target={@myself}
                >
                  Confirm
                </button>
              </div>
            </div>
          </div>
        </div>
      <% end %>

      <script :type={Phoenix.LiveView.ColocatedHook} name=".BulkActions">
        export default {
          mounted() {
            this.handleKeydown = (event) => {
              if (event.key === 'Delete' && !event.target.matches('input, textarea')) {
                const selectedCount = parseInt(this.el.dataset.selectedCount || '0', 10);
                if (selectedCount > 0) {
                  event.preventDefault();
                  this.pushEventTo(this.el, 'execute_action', {action: 'delete'});
                }
              }
            };

            document.addEventListener('keydown', this.handleKeydown);
          },

          destroyed() {
            document.removeEventListener('keydown', this.handleKeydown);
          }
        };
      </script>

      <script :type={Phoenix.LiveView.ColocatedHook} name=".ConfirmationDialog">
        export default {
          mounted() {
            const confirmBtn = this.el.querySelector('button[phx-click="confirm_action"]');
            if (confirmBtn) {
              confirmBtn.focus();
            }

            this.handleKeydown = (event) => {
              if (event.key === 'Escape') {
                this.pushEventTo(this.el, 'cancel_action', {});
              }
            };

            document.addEventListener('keydown', this.handleKeydown);
          },

          destroyed() {
            document.removeEventListener('keydown', this.handleKeydown);
          }
        };
      </script>
    </div>
    """
  end

  defp render_action_item(assigns, action) do
    assigns = assign(assigns, :action, action)

    ~H"""
    <div class={if @action.divider, do: "border-t border-gray-200", else: ""}>
      <button
        type="button"
        class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 flex items-center space-x-2"
        phx-click="execute_action"
        phx-value-action={@action.id}
        phx-target={@myself}
      >
        <%= if icon = safe_icon(@action.icon) do %>
          <span class={@action.icon_class || "text-gray-500"}>
            {icon}
          </span>
        <% end %>
        <span class={@action.text_class || "text-gray-700"}>{@action.label}</span>
        <%= if @action.badge do %>
          <span class="ml-auto px-2 py-0.5 text-xs bg-gray-200 rounded-full">
            {@action.badge}
          </span>
        <% end %>
      </button>
      <%= if @action.description do %>
        <p class="px-4 pb-2 text-xs text-gray-500">{@action.description}</p>
      <% end %>
    </div>
    """
  end

  # Event handlers

  @impl true
  def handle_event("execute_action", %{"action" => action_id}, socket) do
    action = Enum.find(socket.assigns.actions, &(&1.id == action_id))

    if action && action.requires_confirmation do
      {:noreply,
       socket
       |> assign(
         show_confirmation: true,
         confirmation_message:
           action.confirmation_message || "Are you sure you want to perform this action?",
         bulk_action: action
       )}
    else
      {:noreply, execute_bulk_action(socket, action)}
    end
  end

  def handle_event("confirm_action", _params, socket) do
    {:noreply,
     socket
     |> assign(show_confirmation: false)
     |> execute_bulk_action(socket.assigns.bulk_action)}
  end

  def handle_event("cancel_action", _params, socket) do
    {:noreply, assign(socket, show_confirmation: false, bulk_action: nil)}
  end

  def handle_event("cancel_processing", _params, socket) do
    send(self(), :cancel_bulk_processing)
    {:noreply, assign(socket, processing: false)}
  end

  def handle_event("dismiss_errors", _params, socket) do
    {:noreply, assign(socket, errors: [])}
  end

  def handle_event("toggle_row_selection", %{"id" => row_id}, socket) do
    {:noreply, RowSelection.toggle_row_selection(socket, row_id)}
  end

  def handle_event("select_all", _params, socket) do
    all_ids = get_all_row_ids(socket)
    {:noreply, RowSelection.select_all_rows(socket, all_ids)}
  end

  def handle_event("select_none", _params, socket) do
    {:noreply, RowSelection.clear_selection(socket)}
  end

  def handle_event("clear_selection", _params, socket) do
    {:noreply, RowSelection.clear_selection(socket)}
  end

  def handle_event("invert_selection", _params, socket) do
    all_ids = get_all_row_ids(socket)
    {:noreply, RowSelection.invert_selection(socket, all_ids)}
  end

  def handle_event("select_range", %{"from" => from_id, "to" => to_id}, socket) do
    all_ids = get_all_row_ids(socket)
    {:noreply, RowSelection.select_range(socket, from_id, to_id, all_ids)}
  end

  # Bulk action execution

  defp execute_bulk_action(socket, nil), do: socket

  defp execute_bulk_action(socket, action) do
    selected_ids = RowSelection.get_selected_ids(socket)

    if length(selected_ids) > 0 do
      socket
      |> assign(
        processing: true,
        processed_count: 0,
        total_to_process: length(selected_ids),
        errors: []
      )
      |> start_batch_processing(action, selected_ids)
    else
      socket
    end
  end

  defp start_batch_processing(socket, action, selected_ids) do
    batch_size = action[:batch_size] || 10
    batches = Enum.chunk_every(selected_ids, batch_size)

    # Send first batch for processing
    send(self(), {:process_batch, action, batches, 0})

    socket
  end

  def handle_info({:process_batch, action, batches, index}, socket) do
    if index < length(batches) do
      batch = Enum.at(batches, index)

      # Process batch
      case process_batch(action, batch) do
        {:ok, _results} ->
          new_count = socket.assigns.processed_count + length(batch)

          socket = assign(socket, processed_count: new_count)

          # Schedule next batch
          if index + 1 < length(batches) do
            Process.send_after(self(), {:process_batch, action, batches, index + 1}, 100)
          else
            # All done
            send(self(), :bulk_processing_complete)
          end

          {:noreply, socket}

        {:error, errors} ->
          {:noreply,
           socket
           |> assign(errors: socket.assigns.errors ++ errors)
           |> assign(processing: false)}
      end
    else
      {:noreply, socket}
    end
  end

  def handle_info(:bulk_processing_complete, socket) do
    send(self(), {:bulk_action_complete, socket.assigns.bulk_action})

    {:noreply,
     socket
     |> assign(processing: false)
     |> RowSelection.clear_selection()}
  end

  def handle_info(:cancel_bulk_processing, socket) do
    {:noreply, assign(socket, processing: false)}
  end

  # Helper functions

  defp process_batch(action, batch_ids) do
    # Call the configured action handler if available, otherwise return success
    case action do
      %{handler: handler} when is_function(handler, 1) ->
        # Call custom handler function
        handler.(batch_ids)

      %{handler: {module, function}} when is_atom(module) and is_atom(function) ->
        # Call module function
        apply(module, function, [batch_ids])

      %{id: action_id} ->
        # Send to parent process for handling
        send(self(), {:bulk_action_process_batch, action_id, batch_ids})
        # Return success - actual processing happens in parent
        {:ok, batch_ids}

      _ ->
        {:ok, batch_ids}
    end
  end

  defp get_all_row_ids(socket) do
    # Get row IDs from various possible sources
    cond do
      # Explicitly provided all_row_ids
      is_list(socket.assigns[:all_row_ids]) and length(socket.assigns[:all_row_ids]) > 0 ->
        socket.assigns[:all_row_ids]

      # Extract from rows data (list of maps with id field)
      is_list(socket.assigns[:rows]) ->
        extract_ids_from_rows(socket.assigns[:rows])

      # Extract from query results (Selecto format)
      is_map(socket.assigns[:query_results]) ->
        extract_ids_from_query_results(socket.assigns[:query_results])

      # Extract from stream data
      is_map(socket.assigns[:streams]) and is_map(socket.assigns[:streams][:rows]) ->
        # Streams store data differently, try to extract
        extract_ids_from_stream(socket.assigns[:streams][:rows])

      # Ask parent component for IDs
      true ->
        send(self(), {:request_all_row_ids, self()})
        # Return empty list, parent will send back the IDs
        []
    end
  end

  defp extract_ids_from_rows(rows) when is_list(rows) do
    rows
    |> Enum.map(fn
      %{id: id} ->
        id

      %{"id" => id} ->
        id

      row when is_map(row) ->
        Map.get(row, :id) || Map.get(row, "id") || Map.get(row, :_id) || Map.get(row, "_id")

      _ ->
        nil
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp extract_ids_from_rows(_), do: []

  defp extract_ids_from_query_results(%{rows: rows, columns: columns}) do
    # Find id column index
    id_index =
      Enum.find_index(columns, fn col ->
        col_str = to_string(col)
        col_str in ["id", "_id", "pk", "primary_key"]
      end) || 0

    rows
    |> Enum.map(fn row ->
      case row do
        row when is_list(row) -> Enum.at(row, id_index)
        row when is_tuple(row) -> elem(row, id_index)
        _ -> nil
      end
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp extract_ids_from_query_results(_), do: []

  defp extract_ids_from_stream(stream_data) when is_map(stream_data) do
    # Phoenix streams store items with DOM IDs as keys
    stream_data
    |> Map.values()
    |> Enum.map(fn
      %{id: id} -> id
      %{"id" => id} -> id
      _ -> nil
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp extract_ids_from_stream(_), do: []

  @doc """
  Set all row IDs for selection operations.

  Call this from the parent component to provide IDs for select all functionality.

  ## Example

      def handle_info({:request_all_row_ids, pid}, socket) do
        ids = Enum.map(socket.assigns.rows, & &1.id)
        send_update(SelectoComponents.EnhancedTable.BulkActions,
          id: "bulk-actions",
          all_row_ids: ids
        )
        {:noreply, socket}
      end
  """
  def set_all_row_ids(component_id, row_ids) do
    Phoenix.LiveView.send_update(__MODULE__, id: component_id, all_row_ids: row_ids)
  end

  defp safe_icon(icon) when is_binary(icon) do
    if MapSet.member?(@trusted_icons, icon) do
      Phoenix.HTML.raw(icon)
    else
      nil
    end
  end

  defp safe_icon(_icon), do: nil

  defp default_actions do
    [
      %{
        id: "export",
        label: "Export",
        icon: @export_icon_svg,
        quick_action: true,
        requires_confirmation: false
      },
      %{
        id: "delete",
        label: "Delete",
        icon: @delete_icon_svg,
        icon_class: "text-red-500",
        text_class: "text-red-700",
        quick_action: true,
        requires_confirmation: true,
        confirmation_message:
          "This will permanently delete the selected items. This action cannot be undone."
      },
      %{
        divider: true,
        id: "archive",
        label: "Archive",
        description: "Move to archive",
        requires_confirmation: true
      },
      %{
        id: "duplicate",
        label: "Duplicate",
        requires_confirmation: false
      },
      %{
        id: "merge",
        label: "Merge",
        badge: "Beta",
        requires_confirmation: true
      }
    ]
  end

  defp action_button_class(action, selection_count) do
    base = "transition-colors"

    disabled =
      if selection_count == 0 do
        "opacity-50 cursor-not-allowed"
      else
        ""
      end

    color =
      case action.id do
        "delete" -> "bg-red-100 text-red-700 hover:bg-red-200"
        "export" -> "bg-green-100 text-green-700 hover:bg-green-200"
        _ -> "bg-gray-100 text-gray-700 hover:bg-gray-200"
      end

    "#{base} #{color} #{disabled}"
  end

  defp progress_percentage(0, 0), do: 0

  defp progress_percentage(processed, total) do
    round(processed / total * 100)
  end

  defp toggle_actions_menu do
    JS.toggle(
      to: "#bulk-actions-menu",
      in: {"ease-out duration-100", "opacity-0 scale-95", "opacity-100 scale-100"},
      out: {"ease-in duration-75", "opacity-100 scale-100", "opacity-0 scale-95"}
    )
  end
end