defmodule MvWeb.MemberLive.Index do
  @moduledoc """
  LiveView for displaying and managing the member list.

  ## Features
  - Full-text search across member profiles using PostgreSQL tsvector
  - Sortable columns (name, email, address fields)
  - Bulk selection for future batch operations
  - Real-time updates via LiveView
  - Bookmarkable URLs with query parameters

  ## URL Parameters
  - `query` - Search query string for full-text search
  - `sort_field` - Field to sort by (e.g., :first_name, :email, :join_date)
  - `sort_order` - Sort direction (:asc or :desc)

  ## Events
  - `select_member` - Toggle individual member selection
  - `select_all` - Toggle selection of all visible members
  - `copy_emails` - Copy email addresses of selected members to clipboard

  ## Implementation Notes
  - Search uses PostgreSQL full-text search (plainto_tsquery)
  - Sort state is synced with URL for bookmarkability
  - Components communicate via `handle_info` for decoupling
  """
  use MvWeb, :live_view

  require Ash.Query
  require Logger
  import Ash.Expr
  import MvWeb.LiveHelpers, only: [current_actor: 1]

  alias Mv.Membership
  alias MvWeb.Helpers.DateFormatter
  alias MvWeb.MemberLive.Index.FieldSelection
  alias MvWeb.MemberLive.Index.FieldVisibility
  alias MvWeb.MemberLive.Index.Formatter
  alias MvWeb.MemberLive.Index.MembershipFeeStatus

  @custom_field_prefix Mv.Constants.custom_field_prefix()
  @boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
  @group_filter_prefix "group_"

  # Maximum number of boolean custom field filters allowed per request (DoS protection)
  @max_boolean_filters Mv.Constants.max_boolean_filters()

  # Maximum length of UUID string (36 characters including hyphens)
  @max_uuid_length Mv.Constants.max_uuid_length()

  # Member fields that are loaded for the overview
  # Uses constants from Mv.Constants to ensure consistency
  # Note: :id is always included for member identification
  # All member fields are loaded, but visibility is controlled via settings
  @overview_fields [:id | Mv.Constants.member_fields()]

  @doc """
  Initializes the LiveView state.

  Sets up initial assigns for page title, search query, sort configuration,
  payment filter, and member selection. Actual data loading happens in `handle_params/3`.
  """
  @impl true
  def mount(_params, session, socket) do
    # Load custom fields that should be shown in overview (for display)
    # Errors in mount are handled by Phoenix LiveView and result in a 500 error page.
    # This is appropriate for initialization errors that should be visible to the user.
    actor = current_actor(socket)

    all_custom_fields =
      Mv.Membership.CustomField
      |> Ash.Query.sort(name: :asc)
      |> Ash.read!(actor: actor)

    custom_fields_visible =
      all_custom_fields
      |> Enum.filter(& &1.show_in_overview)

    # Load boolean custom fields (filtered and sorted from all_custom_fields)
    boolean_custom_fields =
      all_custom_fields
      |> Enum.filter(&(&1.value_type == :boolean))
      |> Enum.sort_by(& &1.name, :asc)

    # Load groups for filter dropdown (sorted by name)
    groups =
      Mv.Membership.Group
      |> Ash.Query.sort(name: :asc)
      |> Ash.read!(actor: actor)

    # Load settings once to avoid N+1 queries
    settings =
      case Membership.get_settings() do
        {:ok, s} -> s
        # Fallback if settings can't be loaded
        {:error, _} -> %{member_field_visibility: %{}}
      end

    # Load user field selection from session
    session_selection = FieldSelection.get_from_session(session)

    # FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice)
    all_available_fields =
      all_custom_fields
      |> FieldVisibility.get_all_available_fields()

    initial_selection =
      FieldVisibility.merge_with_global_settings(
        session_selection,
        settings,
        all_custom_fields
      )

    socket =
      socket
      |> assign(:page_title, gettext("Members"))
      |> assign(:query, "")
      |> assign_new(:sort_field, fn -> :first_name end)
      |> assign_new(:sort_order, fn -> :asc end)
      |> assign(:cycle_status_filter, nil)
      |> assign(:group_filters, %{})
      |> assign(:groups, groups)
      |> assign(:boolean_custom_field_filters, %{})
      |> assign(:selected_members, MapSet.new())
      |> assign(:selected_member_id, nil)
      |> assign(:settings, settings)
      |> assign(:custom_fields_visible, custom_fields_visible)
      |> assign(:all_custom_fields, all_custom_fields)
      |> assign(:boolean_custom_fields, boolean_custom_fields)
      |> assign(:all_available_fields, all_available_fields)
      |> assign(:user_field_selection, initial_selection)
      |> assign(:fields_in_url?, false)
      |> assign(
        :member_fields_visible,
        FieldVisibility.get_visible_member_fields(initial_selection)
      )
      |> assign(
        :member_fields_visible_db,
        FieldVisibility.get_visible_member_fields_db(initial_selection)
      )
      |> assign(
        :member_fields_visible_computed,
        FieldVisibility.get_visible_member_fields_computed(initial_selection)
      )
      |> assign(:show_current_cycle, false)
      |> assign(:membership_fee_status_filter, nil)
      |> assign_export_payload()

    {:ok, socket}
  end

  # -----------------------------------------------------------------
  # Handle Events
  # -----------------------------------------------------------------

  @doc """
  Handles member-related UI events.

  ## Supported events:
  - `"select_member"` - Toggles individual member selection
  - `"select_all"` - Toggles selection of all visible members
  - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
  """
  @impl true
  def handle_event("select_row_and_navigate", %{"id" => id}, socket) do
    # Navigate to member show. Back button on show page uses ?highlight=id so returning to index shows row as selected.
    {:noreply, push_navigate(socket, to: ~p"/members/#{id}")}
  end

  @impl true
  def handle_event("select_member", %{"id" => id}, socket) do
    selected =
      if MapSet.member?(socket.assigns.selected_members, id) do
        MapSet.delete(socket.assigns.selected_members, id)
      else
        MapSet.put(socket.assigns.selected_members, id)
      end

    {:noreply,
     socket
     |> assign(:selected_members, selected)
     |> update_selection_assigns()}
  end

  @impl true
  def handle_event("select_all", _params, socket) do
    all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()

    selected =
      if MapSet.equal?(socket.assigns.selected_members, all_ids) do
        MapSet.new()
      else
        all_ids
      end

    {:noreply,
     socket
     |> assign(:selected_members, selected)
     |> update_selection_assigns()}
  end

  @impl true
  def handle_event("toggle_cycle_view", _params, socket) do
    new_show_current = !socket.assigns.show_current_cycle

    socket =
      socket
      |> assign(:show_current_cycle, new_show_current)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        socket.assigns.cycle_status_filter,
        socket.assigns[:group_filters],
        new_show_current,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"

    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_event("copy_emails", _params, socket) do
    selected_ids = socket.assigns.selected_members

    # Filter members that are in the selection and have email addresses
    formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
    email_count = length(formatted_emails)

    cond do
      MapSet.size(selected_ids) == 0 ->
        {:noreply, put_flash(socket, :error, gettext("No members selected"))}

      email_count == 0 ->
        {:noreply, put_flash(socket, :error, gettext("No email addresses found"))}

      true ->
        # RFC 5322 uses comma as separator for email address lists
        email_string = Enum.join(formatted_emails, ", ")

        socket =
          socket
          |> push_event("copy_to_clipboard", %{text: email_string})
          |> put_flash(
            :success,
            ngettext(
              "Copied %{count} email address to clipboard",
              "Copied %{count} email addresses to clipboard",
              email_count,
              count: email_count
            )
          )
          |> put_flash(
            :warning,
            gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
          )

        {:noreply, socket}
    end
  end

  @impl true
  def handle_event("sort", %{"field" => field_str}, socket) do
    # Handle both atom and string field names (for custom fields)
    field =
      try do
        String.to_existing_atom(field_str)
      rescue
        ArgumentError -> field_str
      end

    {new_field, new_order} = determine_new_sort(field, socket)
    old_field = socket.assigns.sort_field

    socket =
      socket
      |> assign(:sort_field, new_field)
      |> assign(:sort_order, new_order)
      |> update_sort_components(old_field, new_field, new_order)
      |> load_members()
      |> update_selection_assigns()

    # URL sync - push_patch happens synchronously in the event handler
    query_params =
      build_query_params(
        socket.assigns.query,
        export_sort_field(socket.assigns.sort_field),
        export_sort_order(socket.assigns.sort_order),
        socket.assigns.cycle_status_filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
  end

  # -----------------------------------------------------------------
  # Handle Infos from Child Components
  # -----------------------------------------------------------------

  @doc """
  Handles messages from child components.

  ## Supported messages:
  - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
  - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
  - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
  """

  @impl true
  def handle_info({:search_changed, q}, socket) do
    socket =
      socket
      |> assign(:query, q)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        q,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        socket.assigns.cycle_status_filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"

    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_info({:payment_filter_changed, filter}, socket) do
    socket =
      socket
      |> assign(:cycle_status_filter, filter)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"
    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_info({:boolean_filter_changed, custom_field_id_str, filter_value}, socket) do
    updated_filters =
      if filter_value == nil do
        Map.delete(socket.assigns.boolean_custom_field_filters, custom_field_id_str)
      else
        Map.put(socket.assigns.boolean_custom_field_filters, custom_field_id_str, filter_value)
      end

    socket =
      socket
      |> assign(:boolean_custom_field_filters, updated_filters)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        socket.assigns.cycle_status_filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        updated_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"
    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do
    normalized_id = normalize_uuid_string(group_id_str) || group_id_str

    group_filters =
      if filter_value == nil do
        Map.delete(socket.assigns.group_filters, normalized_id)
      else
        Map.put(socket.assigns.group_filters, normalized_id, filter_value)
      end

    socket =
      socket
      |> assign(:group_filters, group_filters)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        socket.assigns.cycle_status_filter,
        group_filters,
        socket.assigns.show_current_cycle,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"
    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
    handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket)
  end

  def handle_info(
        {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
        socket
      ) do
    socket =
      socket
      |> assign(:cycle_status_filter, cycle_status_filter)
      |> assign(:group_filters, group_filters)
      |> assign(:boolean_custom_field_filters, boolean_filters)
      |> load_members()
      |> update_selection_assigns()

    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        cycle_status_filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        boolean_filters
      )
      |> maybe_add_field_selection(
        socket.assigns[:user_field_selection],
        socket.assigns[:fields_in_url?] || false
      )

    new_path = ~p"/members?#{query_params}"
    {:noreply, push_patch(socket, to: new_path, replace: true)}
  end

  @impl true
  def handle_info({:field_toggled, field_string, visible}, socket) do
    new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
    socket = update_session_field_selection(socket, new_selection)

    final_selection =
      FieldVisibility.merge_with_global_settings(
        new_selection,
        socket.assigns.settings,
        socket.assigns.all_custom_fields
      )

    visible_member_fields =
      final_selection
      |> FieldVisibility.get_visible_member_fields()
      |> Enum.uniq()

    visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)

    visible_member_fields_computed =
      FieldVisibility.get_visible_member_fields_computed(final_selection)

    visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)

    socket =
      socket
      |> assign(:user_field_selection, final_selection)
      |> assign(:member_fields_visible, visible_member_fields)
      |> assign(:member_fields_visible_db, visible_member_fields_db)
      |> assign(:member_fields_visible_computed, visible_member_fields_computed)
      |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
      |> load_members()
      |> prepare_dynamic_cols()
      |> update_selection_assigns()
      |> push_field_selection_url()

    {:noreply, socket}
  end

  @impl true
  def handle_info({:fields_selected, selection}, socket) do
    socket = update_session_field_selection(socket, selection)

    final_selection =
      FieldVisibility.merge_with_global_settings(
        selection,
        socket.assigns.settings,
        socket.assigns.all_custom_fields
      )

    visible_member_fields =
      final_selection
      |> FieldVisibility.get_visible_member_fields()
      |> Enum.uniq()

    visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)

    visible_member_fields_computed =
      FieldVisibility.get_visible_member_fields_computed(final_selection)

    visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)

    socket =
      socket
      |> assign(:user_field_selection, final_selection)
      |> assign(:member_fields_visible, visible_member_fields)
      |> assign(:member_fields_visible_db, visible_member_fields_db)
      |> assign(:member_fields_visible_computed, visible_member_fields_computed)
      |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
      |> load_members()
      |> prepare_dynamic_cols()
      |> update_selection_assigns()
      |> push_field_selection_url()

    {:noreply, socket}
  end

  # -----------------------------------------------------------------
  # Handle Params from the URL
  # -----------------------------------------------------------------

  @impl true
  def handle_params(params, url, socket) do
    url = url || request_url_from_socket(socket)
    params = merge_fields_param_from_uri(params, url)
    prev_sig = build_signature(socket)

    fields_in_url? =
      case Map.get(params, "fields") do
        v when is_binary(v) and v != "" -> true
        _ -> false
      end

    url_selection = FieldSelection.parse_from_url(params)
    final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)

    visible_member_fields =
      final_selection
      |> FieldVisibility.get_visible_member_fields()
      |> Enum.uniq()

    visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)

    visible_member_fields_computed =
      FieldVisibility.get_visible_member_fields_computed(final_selection)

    visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)

    socket =
      socket
      |> maybe_update_search(params)
      |> maybe_update_sort(params)
      |> maybe_update_cycle_status_filter(params)
      |> maybe_update_group_filters(params)
      |> maybe_update_boolean_filters(params)
      |> maybe_update_show_current_cycle(params)
      |> assign(:fields_in_url?, fields_in_url?)
      |> assign(:query, params["query"])
      |> assign(:user_field_selection, final_selection)
      |> assign(:member_fields_visible, visible_member_fields)
      |> assign(:member_fields_visible_db, visible_member_fields_db)
      |> assign(:member_fields_visible_computed, visible_member_fields_computed)
      |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
      |> assign(:selected_member_id, parse_highlight_param(params["highlight"]))

    next_sig = build_signature(socket)

    socket =
      if prev_sig == next_sig && Map.has_key?(socket.assigns, :members) do
        socket
        |> prepare_dynamic_cols()
        |> update_selection_assigns()
      else
        socket
        |> load_members()
        |> prepare_dynamic_cols()
        |> update_selection_assigns()
      end

    # Update sort components after rendering
    socket =
      if socket.assigns[:sort_needs_update] do
        old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field

        socket
        |> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
        |> assign(:sort_needs_update, false)
        |> assign(:previous_sort_field, nil)
      else
        socket
      end

    {:noreply, socket}
  end

  defp build_signature(socket) do
    {
      socket.assigns.query,
      socket.assigns.sort_field,
      socket.assigns.sort_order,
      socket.assigns.cycle_status_filter,
      socket.assigns[:group_filters],
      socket.assigns.show_current_cycle,
      socket.assigns.boolean_custom_field_filters,
      socket.assigns.user_field_selection,
      socket.assigns[:visible_custom_field_ids] || []
    }
  end

  defp prepare_dynamic_cols(socket) do
    visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
    visible_set = MapSet.new(visible_custom_field_ids)

    dynamic_cols =
      socket.assigns.all_custom_fields
      |> Enum.filter(fn custom_field ->
        MapSet.member?(visible_set, to_string(custom_field.id))
      end)
      |> Enum.map(fn custom_field ->
        %{
          custom_field: custom_field,
          render: fn member ->
            case get_custom_field_value(member, custom_field) do
              nil -> ""
              cfv -> Formatter.format_custom_field_value(cfv.value, custom_field)
            end
          end
        }
      end)

    assign(socket, :dynamic_cols, dynamic_cols)
  end

  # -------------------------------------------------------------
  # Sorting
  # -------------------------------------------------------------

  defp determine_new_sort(field, socket) do
    if socket.assigns.sort_field == field do
      {field, toggle_order(socket.assigns.sort_order)}
    else
      {field, :asc}
    end
  end

  defp update_sort_components(socket, old_field, new_field, new_order) do
    active_id = to_sort_id(new_field)
    old_id = to_sort_id(old_field)

    send_update(MvWeb.Components.SortHeaderComponent,
      id: active_id,
      sort_field: new_field,
      sort_order: new_order
    )

    send_update(MvWeb.Components.SortHeaderComponent,
      id: old_id,
      sort_field: new_field,
      sort_order: new_order
    )

    socket
  end

  defp to_sort_id(field) when is_binary(field) do
    try do
      String.to_existing_atom("sort_#{field}")
    rescue
      ArgumentError -> :"sort_#{field}"
    end
  end

  defp to_sort_id(field) when is_atom(field), do: :"sort_#{field}"

  # Only keep `fields` in the URL when it was already present (bookmark/share),
  # OR when we intentionally push it via push_field_selection_url/1.
  defp maybe_add_field_selection(params, selection, true) when is_map(selection) do
    fields_param = FieldSelection.to_url_param(selection)

    if fields_param == "" do
      Map.delete(params, "fields")
    else
      Map.put(params, "fields", fields_param)
    end
  end

  defp maybe_add_field_selection(params, _selection, _include?), do: params

  defp push_field_selection_url(socket) do
    query_params =
      build_query_params(
        socket.assigns.query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        socket.assigns.cycle_status_filter,
        socket.assigns[:group_filters],
        socket.assigns.show_current_cycle,
        socket.assigns.boolean_custom_field_filters
      )
      |> maybe_add_field_selection(socket.assigns[:user_field_selection], true)

    new_path = ~p"/members?#{query_params}"
    push_patch(socket, to: new_path, replace: true)
  end

  defp update_session_field_selection(socket, selection) do
    assign(socket, :user_field_selection, selection)
  end

  defp build_query_params(
         query,
         sort_field,
         sort_order,
         cycle_status_filter,
         group_filters,
         show_current_cycle,
         boolean_filters
       ) do
    base_params = build_base_params(query, sort_field, sort_order)
    base_params = add_cycle_status_filter(base_params, cycle_status_filter)
    base_params = add_group_filters(base_params, group_filters)
    base_params = add_show_current_cycle(base_params, show_current_cycle)
    add_boolean_filters(base_params, boolean_filters)
  end

  defp compute_final_field_selection(true, url_selection, socket) do
    only_url =
      FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)

    visible_members = FieldVisibility.get_visible_member_fields(only_url)
    visible_custom = FieldVisibility.get_visible_custom_fields(only_url)

    if visible_members == [] and visible_custom == [] do
      # URL had only invalid field names; fall back to session + global.
      compute_final_field_selection(false, url_selection, socket)
    else
      only_url
    end
  end

  defp compute_final_field_selection(false, url_selection, socket) do
    merged =
      FieldSelection.merge_sources(
        url_selection,
        socket.assigns.user_field_selection,
        %{}
      )

    FieldVisibility.merge_with_global_settings(
      merged,
      socket.assigns.settings,
      socket.assigns.all_custom_fields
    )
  end

  # On full page load conn.params has no query string; read "fields" from URI so column visibility is restored.
  defp request_url_from_socket(socket) do
    case socket.private[:connect_info] do
      %Plug.Conn{} = conn -> Plug.Conn.request_url(conn)
      _ -> nil
    end
  end

  # Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
  defp parse_highlight_param(nil), do: nil
  defp parse_highlight_param(""), do: nil

  defp parse_highlight_param(id) when is_binary(id) do
    if String.length(id) <= @max_uuid_length and match?({:ok, _}, Ecto.UUID.cast(id)),
      do: id,
      else: nil
  end

  defp parse_highlight_param(_), do: nil

  defp merge_fields_param_from_uri(params, nil), do: params

  defp merge_fields_param_from_uri(params, %URI{query: query}) when is_binary(query) do
    case URI.decode_query(query)["fields"] do
      nil -> params
      value -> Map.put(params, "fields", value)
    end
  end

  defp merge_fields_param_from_uri(params, %URI{}), do: params

  defp merge_fields_param_from_uri(params, url) when is_binary(url) do
    case URI.parse(url).query do
      nil ->
        params

      q ->
        case URI.decode_query(q)["fields"] do
          nil -> params
          value -> Map.put(params, "fields", value)
        end
    end
  end

  defp merge_fields_param_from_uri(params, _), do: params

  defp build_base_params(query, sort_field, sort_order) do
    %{
      "query" => query || "",
      "sort_field" => normalize_sort_field(sort_field),
      "sort_order" => normalize_sort_order(sort_order)
    }
  end

  defp normalize_sort_field(nil), do: ""
  defp normalize_sort_field(field) when is_atom(field), do: Atom.to_string(field)
  defp normalize_sort_field(field) when is_binary(field), do: field
  defp normalize_sort_field(_), do: ""

  defp normalize_sort_order(nil), do: ""
  defp normalize_sort_order(order) when is_atom(order), do: Atom.to_string(order)
  defp normalize_sort_order(order) when is_binary(order), do: order
  defp normalize_sort_order(_), do: ""

  defp add_group_filters(params, group_filters) do
    Enum.reduce(group_filters, params, fn {group_id_str, value}, acc ->
      param_value = if value == :in, do: "in", else: "not_in"
      Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
    end)
  end

  defp add_cycle_status_filter(params, nil), do: params
  defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid")

  defp add_cycle_status_filter(params, :unpaid),
    do: Map.put(params, "cycle_status_filter", "unpaid")

  defp add_cycle_status_filter(params, _), do: params

  defp add_show_current_cycle(params, true), do: Map.put(params, "show_current_cycle", "true")
  defp add_show_current_cycle(params, _), do: params

  defp add_boolean_filters(params, boolean_filters) do
    Enum.reduce(boolean_filters, params, &add_boolean_filter/2)
  end

  defp add_boolean_filter({custom_field_id, filter_value}, acc) do
    param_key = "#{@boolean_filter_prefix}#{custom_field_id}"
    param_value = if filter_value == true, do: "true", else: "false"
    Map.put(acc, param_key, param_value)
  end

  # -------------------------------------------------------------
  # Loading members
  # -------------------------------------------------------------

  defp load_members(socket) do
    search_query = socket.assigns.query

    query =
      Mv.Membership.Member
      |> Ash.Query.new()
      |> Ash.Query.select(@overview_fields)

    visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []

    boolean_custom_fields_map =
      socket.assigns.boolean_custom_fields
      |> Map.new(fn cf -> {to_string(cf.id), cf} end)

    active_boolean_filter_ids =
      socket.assigns.boolean_custom_field_filters
      |> Map.keys()
      |> Enum.filter(fn id_str ->
        String.length(id_str) <= @max_uuid_length &&
          match?({:ok, _}, Ecto.UUID.cast(id_str)) &&
          Map.has_key?(boolean_custom_fields_map, id_str)
      end)

    ids_to_load =
      (visible_custom_field_ids ++ active_boolean_filter_ids)
      |> Enum.uniq()

    query = load_custom_field_values(query, ids_to_load)

    query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle)

    # Load groups for each member (id, name, slug only)
    query =
      Ash.Query.load(query, groups: [:id, :name, :slug])

    # Load membership_fee_type when the column is visible or when sorting by it
    query =
      if :membership_fee_type in socket.assigns.member_fields_visible or
           socket.assigns.sort_field in [:membership_fee_type, "membership_fee_type"] do
        Ash.Query.load(query, membership_fee_type: [:id, :name])
      else
        query
      end

    query = apply_search_filter(query, search_query)

    query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])

    # Use ALL custom fields for sorting (not just show_in_overview subset)
    custom_fields_for_sort = socket.assigns.all_custom_fields

    {query, sort_after_load} =
      maybe_sort(
        query,
        socket.assigns.sort_field,
        socket.assigns.sort_order,
        custom_fields_for_sort
      )

    # Errors in handle_params are handled by Phoenix LiveView
    actor = current_actor(socket)
    {time_microseconds, members} = :timer.tc(fn -> Ash.read!(query, actor: actor) end)
    Logger.info("Ash.read! in load_members/1 took #{time_microseconds / 1000} ms")

    # Custom field values are already filtered at the database level in load_custom_field_values/2
    # No need for in-memory filtering anymore

    # Apply cycle status filter if set
    members =
      apply_cycle_status_filter(
        members,
        socket.assigns.cycle_status_filter,
        socket.assigns.show_current_cycle
      )

    # Apply boolean custom field filters if set
    members =
      apply_boolean_custom_field_filters(
        members,
        socket.assigns.boolean_custom_field_filters,
        socket.assigns.all_custom_fields
      )

    # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
    # Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
    members =
      if sort_after_load and
           socket.assigns.sort_field != :membership_fee_status do
        sort_members_in_memory(
          members,
          socket.assigns.sort_field,
          socket.assigns.sort_order,
          custom_fields_for_sort
        )
      else
        members
      end

    assign(socket, :members, members)
  end

  defp load_custom_field_values(query, []), do: query

  defp load_custom_field_values(query, custom_field_ids) do
    custom_field_values_query =
      Mv.Membership.CustomFieldValue
      |> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
      |> Ash.Query.load(custom_field: [:id, :name, :value_type])

    query
    |> Ash.Query.load(custom_field_values: custom_field_values_query)
  end

  # -------------------------------------------------------------
  # Helper Functions
  # -------------------------------------------------------------

  defp apply_search_filter(query, search_query) do
    if search_query && String.trim(search_query) != "" do
      query
      |> Mv.Membership.Member.fuzzy_search(%{query: search_query})
    else
      query
    end
  end

  # Multiple group filters combine with AND: member must match all selected group conditions.
  defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query

  defp apply_group_filters(query, group_filters, groups) do
    valid_ids =
      groups
      |> Enum.map(&normalize_uuid_string(to_string(&1.id)))
      |> Enum.reject(&is_nil/1)
      |> MapSet.new()

    Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
      member? = MapSet.member?(valid_ids, group_id_str)

      if member? do
        apply_one_group_filter(q, group_id_str, value)
      else
        q
      end
    end)
  end

  defp apply_one_group_filter(query, _group_id_str, nil), do: query

  defp apply_one_group_filter(query, group_id_str, :in) do
    case Ecto.UUID.cast(group_id_str) do
      {:ok, group_uuid} ->
        Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid)))

      _ ->
        query
    end
  end

  defp apply_one_group_filter(query, group_id_str, :not_in) do
    case Ecto.UUID.cast(group_id_str) do
      {:ok, group_uuid} ->
        Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid)))

      _ ->
        query
    end
  end

  defp apply_one_group_filter(query, _, _), do: query

  defp apply_cycle_status_filter(members, nil, _show_current), do: members

  defp apply_cycle_status_filter(members, status, show_current)
       when status in [:paid, :unpaid] do
    MembershipFeeStatus.filter_members_by_cycle_status(members, status, show_current)
  end

  defp toggle_order(:asc), do: :desc
  defp toggle_order(:desc), do: :asc
  defp toggle_order(nil), do: :asc

  # Function to sort the column if needed.
  # Only DB member fields and custom fields; computed fields (e.g. membership_fee_status) are never passed to Ash.
  # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory.
  defp maybe_sort(query, nil, _order, _custom_fields), do: {query, false}
  defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}

  defp maybe_sort(query, field, order, _custom_fields) do
    # :groups is in computed_member_fields() but can be sorted in-memory
    # Only :membership_fee_status should be blocked from sorting
    if field == :membership_fee_status or field == "membership_fee_status" do
      {query, false}
    else
      apply_sort_to_query(query, field, order)
    end
  end

  defp apply_sort_to_query(query, field, order) do
    cond do
      # Groups sort -> after load (in memory)
      field in [:groups, "groups"] ->
        {query, true}

      # Membership fee type sort -> by related name at DB
      field in [:membership_fee_type, "membership_fee_type"] ->
        {Ash.Query.sort(query, [{"membership_fee_type.name", order}]), false}

      # Custom field sort -> after load
      custom_field_sort?(field) ->
        {query, true}

      # DB field sort (atom)
      is_atom(field) ->
        {Ash.Query.sort(query, [{field, order}]), false}

      # DB field sort (string) -> convert only if allowed
      is_binary(field) ->
        case safe_member_field_atom_only(field) do
          nil -> {query, false}
          atom -> {Ash.Query.sort(query, [{atom, order}]), false}
        end

      true ->
        {query, false}
    end
  end

  defp valid_sort_field?(field) when is_atom(field) do
    # :groups is in computed_member_fields() but can be sorted
    # Only :membership_fee_status should be blocked
    if field == :membership_fee_status do
      false
    else
      valid_sort_field_db_or_custom?(field)
    end
  end

  defp valid_sort_field?(field) when is_binary(field) do
    # "groups" is in computed_member_fields() but can be sorted
    # Only "membership_fee_status" should be blocked
    if field == "membership_fee_status" do
      false
    else
      valid_sort_field_db_or_custom?(field)
    end
  end

  defp valid_sort_field?(_), do: false

  defp valid_sort_field_db_or_custom?(field) when is_atom(field) do
    non_sortable_fields = [:notes]
    valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
    field in valid_fields or custom_field_sort?(field) or field in [:groups, :membership_fee_type]
  end

  defp valid_sort_field_db_or_custom?(field) when is_binary(field) do
    normalized =
      cond do
        field == "groups" -> :groups
        field == "membership_fee_type" -> :membership_fee_type
        true -> safe_member_field_atom_only(field)
      end

    (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or
      custom_field_sort?(field)
  end

  defp safe_member_field_atom_only(str) do
    allowed = MapSet.new(Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1))
    if MapSet.member?(allowed, str), do: String.to_existing_atom(str), else: nil
  end

  defp custom_field_sort?(field) when is_atom(field) do
    field_str = Atom.to_string(field)
    String.starts_with?(field_str, @custom_field_prefix)
  end

  defp custom_field_sort?(field) when is_binary(field) do
    String.starts_with?(field, @custom_field_prefix)
  end

  defp custom_field_sort?(_), do: false

  defp extract_custom_field_id(field) when is_atom(field) do
    field |> Atom.to_string() |> extract_custom_field_id()
  end

  defp extract_custom_field_id(field) when is_binary(field) do
    case String.split(field, @custom_field_prefix) do
      ["", id_str] -> id_str
      _ -> nil
    end
  end

  defp extract_custom_field_id(_), do: nil

  defp extract_custom_field_ids(visible_custom_fields) do
    Enum.map(visible_custom_fields, fn field_string ->
      case String.split(field_string, @custom_field_prefix) do
        ["", id] -> id
        _ -> nil
      end
    end)
    |> Enum.filter(&(&1 != nil))
  end

  defp sort_members_in_memory(members, field, order, custom_fields) do
    if field in [:groups, "groups"] do
      sort_members_by_groups(members, order)
    else
      custom_field_id_str = extract_custom_field_id(field)

      case custom_field_id_str do
        nil -> members
        id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
      end
    end
  end

  defp sort_members_by_groups(members, order) do
    # Members with groups first, then by first group name alphabetically (min = first by sort order)
    first_group_name = fn member ->
      (member.groups || [])
      |> Enum.map(& &1.name)
      |> Enum.min(fn -> nil end)
    end

    members
    |> Enum.sort_by(fn member ->
      name = first_group_name.(member)
      # Nil (no groups) sorts last in asc, first in desc
      {name == nil, name || ""}
    end)
    |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end)
  end

  defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
    custom_field = find_custom_field_by_id(custom_fields, id_str)

    case custom_field do
      nil -> members
      cf -> sort_members_with_custom_field(members, cf, order)
    end
  end

  defp find_custom_field_by_id(custom_fields, id_str) do
    Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
  end

  defp sort_members_with_custom_field(members, custom_field, order) do
    {members_with_values, members_without_values} =
      split_members_by_value_presence(members, custom_field)

    sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
    sorted_with_values ++ members_without_values
  end

  defp split_members_by_value_presence(members, custom_field) do
    Enum.split_with(members, fn member -> has_non_empty_value?(member, custom_field) end)
  end

  defp has_non_empty_value?(member, custom_field) do
    case get_custom_field_value(member, custom_field) do
      nil ->
        false

      cfv ->
        extracted = extract_sort_value(cfv.value, custom_field.value_type)
        not empty_value?(extracted, custom_field.value_type)
    end
  end

  defp sort_members_with_values(members_with_values, custom_field, order) do
    sorted =
      Enum.sort_by(members_with_values, fn member ->
        cfv = get_custom_field_value(member, custom_field)
        extracted = extract_sort_value(cfv.value, custom_field.value_type)
        normalize_sort_value(extracted, order)
      end)

    if order == :desc, do: Enum.reverse(sorted), else: sorted
  end

  defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type),
    do: extract_sort_value(value, type)

  defp extract_sort_value(value, :string) when is_binary(value), do: value
  defp extract_sort_value(value, :integer) when is_integer(value), do: value
  defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
  defp extract_sort_value(%Date{} = date, :date), do: date
  defp extract_sort_value(value, :email) when is_binary(value), do: value
  defp extract_sort_value(value, _type), do: to_string(value)

  defp empty_value?(value, :string) when is_binary(value), do: String.trim(value) == ""
  defp empty_value?(value, :email) when is_binary(value), do: String.trim(value) == ""
  defp empty_value?(_value, _type), do: false

  defp normalize_sort_value(value, _order), do: value

  defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
    field = determine_field(socket.assigns.sort_field, sf)
    order = determine_order(socket.assigns.sort_order, so)
    old_field = socket.assigns.sort_field

    socket
    |> assign(:sort_field, field)
    |> assign(:sort_order, order)
    |> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
    |> assign(:previous_sort_field, old_field)
  end

  defp maybe_update_sort(socket, _), do: socket

  defp determine_field(default, ""), do: default
  defp determine_field(default, nil), do: default

  defp determine_field(default, sf) when is_binary(sf) do
    # Handle "groups" specially - it's in computed_member_fields() but can be sorted
    if sf == "groups" do
      :groups
    else
      computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)

      if sf in computed_strings,
        do: default,
        else: determine_field_after_computed_check(default, sf)
    end
  end

  defp determine_field(default, sf) when is_atom(sf) do
    # Handle :groups specially - it's in computed_member_fields() but can be sorted
    if sf == :groups do
      :groups
    else
      if sf in FieldVisibility.computed_member_fields(),
        do: default,
        else: determine_field_after_computed_check(default, sf)
    end
  end

  defp determine_field(default, _), do: default

  defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
    cond do
      sf == "groups" ->
        :groups

      custom_field_sort?(sf) ->
        if valid_sort_field?(sf), do: sf, else: default

      true ->
        atom = safe_member_field_atom_only(sf)
        if atom != nil and valid_sort_field?(atom), do: atom, else: default
    end
  end

  defp determine_field_after_computed_check(default, sf) when is_atom(sf) do
    if valid_sort_field?(sf), do: sf, else: default
  end

  defp determine_order(default, so) do
    case so do
      "" -> default
      nil -> default
      so when so in ["asc", "desc"] -> String.to_atom(so)
      _ -> default
    end
  end

  defp maybe_update_search(socket, %{"query" => query}) when query != "",
    do: assign(socket, :query, query)

  defp maybe_update_search(socket, _params), do: socket

  defp maybe_update_cycle_status_filter(socket, %{"cycle_status_filter" => filter_str}) do
    filter = determine_cycle_status_filter(filter_str)
    assign(socket, :cycle_status_filter, filter)
  end

  defp maybe_update_cycle_status_filter(socket, _params),
    do: assign(socket, :cycle_status_filter, nil)

  defp maybe_update_group_filters(socket, params) when is_map(params) do
    prefix = @group_filter_prefix
    prefix_len = String.length(prefix)

    group_param_entries =
      params
      |> Enum.filter(fn {key, _} ->
        key_str = to_string(key)
        String.starts_with?(key_str, prefix)
      end)

    filters =
      Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc ->
        add_group_filter_entry(acc, key, value_str, prefix_len)
      end)

    assign(socket, :group_filters, filters)
  end

  defp maybe_update_group_filters(socket, _), do: socket

  defp add_group_filter_entry(acc, key, value_str, prefix_len) do
    key_str = to_string(key)
    raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
    group_id_str = normalize_uuid_string(raw_id)
    valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length

    if valid_id? do
      case parse_group_filter_value(value_str) do
        nil -> acc
        value -> Map.put(acc, group_id_str, value)
      end
    else
      acc
    end
  end

  # Normalize UUID string so URL params match valid_ids (lowercase, canonical format)
  defp normalize_uuid_string(raw) when is_binary(raw) do
    case Ecto.UUID.cast(String.trim(raw)) do
      {:ok, uuid} -> to_string(uuid)
      _ -> raw
    end
  end

  defp normalize_uuid_string(_), do: nil

  defp parse_group_filter_value("in"), do: :in
  defp parse_group_filter_value("not_in"), do: :not_in

  defp parse_group_filter_value(val) when is_binary(val) do
    parse_group_filter_value(String.trim(val))
  end

  defp parse_group_filter_value(_), do: nil

  defp determine_cycle_status_filter("paid"), do: :paid
  defp determine_cycle_status_filter("unpaid"), do: :unpaid
  defp determine_cycle_status_filter(_), do: nil

  defp maybe_update_boolean_filters(socket, params) do
    boolean_custom_fields =
      socket.assigns.all_custom_fields
      |> Enum.filter(&(&1.value_type == :boolean))
      |> Map.new(fn cf -> {to_string(cf.id), cf} end)

    prefix_length = String.length(@boolean_filter_prefix)

    {filters, total_processed} =
      params
      |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end)
      |> Enum.reduce_while({%{}, 0}, fn {key, value_str}, {acc, count} ->
        if count >= @max_boolean_filters do
          {:halt, {acc, count}}
        else
          new_acc =
            process_boolean_filter_param(
              key,
              value_str,
              prefix_length,
              boolean_custom_fields,
              acc
            )

          {:cont, {new_acc, count + 1}}
        end
      end)

    if total_processed >= @max_boolean_filters do
      Logger.warning(
        "Boolean filter limit reached: processed #{total_processed} parameters, accepted #{map_size(filters)} valid filters (max: #{@max_boolean_filters})"
      )
    end

    assign(socket, :boolean_custom_field_filters, filters)
  end

  defp process_boolean_filter_param(key, value_str, prefix_length, boolean_custom_fields, acc) do
    custom_field_id_str = String.slice(key, prefix_length, String.length(key) - prefix_length)

    if String.length(custom_field_id_str) > @max_uuid_length do
      acc
    else
      validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc)
    end
  end

  defp validate_and_add_boolean_filter(custom_field_id_str, value_str, boolean_custom_fields, acc) do
    case Ecto.UUID.cast(custom_field_id_str) do
      {:ok, _custom_field_id} ->
        add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc)

      :error ->
        acc
    end
  end

  defp add_boolean_filter_if_valid(custom_field_id_str, value_str, boolean_custom_fields, acc) do
    if Map.has_key?(boolean_custom_fields, custom_field_id_str) do
      case determine_boolean_filter(value_str) do
        nil -> acc
        filter_value -> Map.put(acc, custom_field_id_str, filter_value)
      end
    else
      acc
    end
  end

  defp determine_boolean_filter("true"), do: true
  defp determine_boolean_filter("false"), do: false
  defp determine_boolean_filter(_), do: nil

  defp maybe_update_show_current_cycle(socket, %{"show_current_cycle" => "true"}),
    do: assign(socket, :show_current_cycle, true)

  defp maybe_update_show_current_cycle(socket, _params), do: socket

  # -------------------------------------------------------------
  # Custom Field Value Helpers
  # -------------------------------------------------------------

  def get_custom_field_value(member, custom_field) do
    case member.custom_field_values do
      nil ->
        nil

      values when is_list(values) ->
        Enum.find(values, fn cfv ->
          cfv.custom_field_id == custom_field.id or
            (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id)
        end)

      _ ->
        nil
    end
  end

  def get_boolean_custom_field_value(member, custom_field) do
    case get_custom_field_value(member, custom_field) do
      nil -> nil
      cfv -> extract_boolean_value(cfv.value)
    end
  end

  defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}),
    do: extract_boolean_value(value)

  defp extract_boolean_value(value) when is_map(value) do
    type = Map.get(value, "type") || Map.get(value, "_union_type")
    val = Map.get(value, "value") || Map.get(value, "_union_value")

    if type == "boolean" or type == :boolean do
      extract_boolean_value(val)
    else
      nil
    end
  end

  defp extract_boolean_value(value) when is_boolean(value), do: value
  defp extract_boolean_value(nil), do: nil
  defp extract_boolean_value(_), do: nil

  def apply_boolean_custom_field_filters(members, filters, _all_custom_fields)
      when map_size(filters) == 0 do
    members
  end

  def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do
    valid_custom_field_ids =
      all_custom_fields
      |> Enum.filter(&(&1.value_type == :boolean))
      |> MapSet.new(fn cf -> to_string(cf.id) end)

    valid_filters =
      Enum.filter(filters, fn {custom_field_id_str, _value} ->
        MapSet.member?(valid_custom_field_ids, custom_field_id_str)
      end)
      |> Enum.into(%{})

    if map_size(valid_filters) == 0 do
      members
    else
      Enum.filter(members, fn member -> matches_all_filters?(member, valid_filters) end)
    end
  end

  defp matches_all_filters?(member, filters) do
    Enum.all?(filters, fn {custom_field_id_str, filter_value} ->
      matches_filter?(member, custom_field_id_str, filter_value)
    end)
  end

  defp matches_filter?(member, custom_field_id_str, filter_value) do
    case find_custom_field_value_by_id(member, custom_field_id_str) do
      nil -> false
      cfv -> extract_boolean_value(cfv.value) == filter_value
    end
  end

  defp find_custom_field_value_by_id(member, custom_field_id_str) do
    case member.custom_field_values do
      nil ->
        nil

      values when is_list(values) ->
        Enum.find(values, fn cfv ->
          to_string(cfv.custom_field_id) == custom_field_id_str or
            (match?(%{custom_field: %{id: _}}, cfv) &&
               to_string(cfv.custom_field.id) == custom_field_id_str)
        end)

      _ ->
        nil
    end
  end

  def format_selected_member_emails(members, selected_members) do
    members
    |> Enum.filter(fn member ->
      MapSet.member?(selected_members, member.id) && member.email && member.email != ""
    end)
    |> Enum.map(&format_member_email/1)
  end

  def checkbox_column_click(member), do: JS.push("select_member", value: %{id: member.id})

  def format_member_email(member) do
    first_name = member.first_name || ""
    last_name = member.last_name || ""

    name =
      [first_name, last_name]
      |> Enum.filter(&(&1 != ""))
      |> Enum.join(" ")

    if name == "", do: member.email, else: "#{name} <#{member.email}>"
  end

  def format_date(date), do: DateFormatter.format_date(date)

  defp update_selection_assigns(socket) do
    members = socket.assigns[:members] || []
    selected_members = socket.assigns.selected_members

    selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
    any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))

    mailto_bcc =
      if any_selected? do
        format_selected_member_emails(members, selected_members)
        |> Enum.join(", ")
        |> URI.encode_www_form()
      else
        ""
      end

    socket
    |> assign(:selected_count, selected_count)
    |> assign(:any_selected?, any_selected?)
    |> assign(:mailto_bcc, mailto_bcc)
    |> assign_export_payload()
  end

  defp assign_export_payload(socket) do
    payload = build_export_payload(socket)
    assign(socket, :export_payload_json, Jason.encode!(payload))
  end

  defp build_export_payload(socket) do
    visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []

    member_fields_db = socket.assigns[:member_fields_visible_db] || []
    member_fields_computed = socket.assigns[:member_fields_visible_computed] || []

    # Order DB member fields exactly like the table/constants
    ordered_member_fields_db =
      Mv.Constants.member_fields()
      |> Enum.filter(&(&1 in member_fields_db))

    # Order computed fields in canonical order
    ordered_computed_fields =
      FieldVisibility.computed_member_fields()
      |> Enum.filter(&(&1 in member_fields_computed))

    member_fields_with_groups =
      build_export_member_fields_list(
        ordered_member_fields_db,
        socket.assigns[:member_fields_visible]
      )

    # Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
    ordered_custom_field_ids =
      socket.assigns.all_custom_fields
      |> Enum.map(&to_string(&1.id))
      |> Enum.filter(&(&1 in visible_custom_field_ids))

    %{
      selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
      member_fields:
        Enum.map(member_fields_with_groups, fn
          f when is_atom(f) -> Atom.to_string(f)
          f when is_binary(f) -> f
        end),
      computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
      custom_field_ids: ordered_custom_field_ids,
      column_order:
        export_column_order(
          ordered_member_fields_db,
          ordered_computed_fields,
          ordered_custom_field_ids,
          :membership_fee_type in socket.assigns[:member_fields_visible],
          :groups in socket.assigns[:member_fields_visible]
        ),
      query: socket.assigns[:query] || nil,
      sort_field: export_sort_field(socket.assigns[:sort_field]),
      sort_order: export_sort_order(socket.assigns[:sort_order]),
      show_current_cycle: socket.assigns[:show_current_cycle] || false,
      cycle_status_filter: export_cycle_status_filter(socket.assigns[:cycle_status_filter]),
      boolean_filters: socket.assigns[:boolean_custom_field_filters] || %{}
    }
  end

  defp expand_db_string_for_export(f, membership_fee_type_visible, computed_strings) do
    if f == "membership_fee_start_date" do
      extra =
        if(membership_fee_type_visible, do: ["membership_fee_type"], else: []) ++
          if "membership_fee_status" in computed_strings, do: ["membership_fee_status"], else: []

      [f] ++ extra
    else
      [f]
    end
  end

  defp build_export_member_fields_list(ordered_db, member_fields_visible) do
    with_extras =
      Enum.flat_map(ordered_db, fn f ->
        if f == :membership_fee_start_date and
             :membership_fee_type in (member_fields_visible || []) do
          [f, :membership_fee_type]
        else
          [f]
        end
      end)

    # If fee type is visible but start_date was not in the list, append it
    with_extras =
      if :membership_fee_type in (member_fields_visible || []) and
           :membership_fee_type not in with_extras do
        with_extras ++ [:membership_fee_type]
      else
        with_extras
      end

    if :groups in (member_fields_visible || []), do: with_extras ++ [:groups], else: with_extras
  end

  defp export_cycle_status_filter(nil), do: nil
  defp export_cycle_status_filter(:paid), do: "paid"
  defp export_cycle_status_filter(:unpaid), do: "unpaid"
  defp export_cycle_status_filter(_), do: nil

  defp export_sort_field(nil), do: nil
  defp export_sort_field(f) when is_atom(f), do: Atom.to_string(f)
  defp export_sort_field(f) when is_binary(f), do: f

  defp export_sort_order(nil), do: nil
  defp export_sort_order(:asc), do: "asc"
  defp export_sort_order(:desc), do: "desc"
  defp export_sort_order(o) when is_binary(o), do: o
  # Build a single ordered list that matches the table order:
  # - DB fields in Mv.Constants.member_fields() order (already pre-filtered as ordered_member_fields_db)
  # - membership_fee_type and membership_fee_status inserted after membership_fee_start_date when visible
  # - groups appended before custom fields when visible
  # - custom fields appended in the same order as table (already ordered_custom_field_ids)
  defp export_column_order(
         ordered_member_fields_db,
         ordered_computed_fields,
         ordered_custom_field_ids,
         membership_fee_type_visible,
         groups_visible
       ) do
    db_strings = Enum.map(ordered_member_fields_db, &Atom.to_string/1)
    computed_strings = Enum.map(ordered_computed_fields, &Atom.to_string/1)

    # Place membership_fee_type and membership_fee_status after membership_fee_start_date when present
    db_with_extras =
      Enum.flat_map(
        db_strings,
        &expand_db_string_for_export(&1, membership_fee_type_visible, computed_strings)
      )

    # If fee type is visible but start_date was not in the list, append it before computed/groups
    db_with_extras =
      if membership_fee_type_visible and "membership_fee_type" not in db_with_extras do
        db_with_extras ++ ["membership_fee_type"]
      else
        db_with_extras
      end

    # Any remaining computed fields not inserted above (future-proof)
    remaining_computed =
      computed_strings
      |> Enum.reject(&(&1 in db_with_extras))

    result = db_with_extras ++ remaining_computed
    result = if groups_visible, do: result ++ ["groups"], else: result
    result ++ ordered_custom_field_ids
  end
end
