defmodule Mv.Membership.MemberExport do
  @moduledoc """
  Builds member list and column specs for CSV export.

  Used by `MvWeb.MemberExportController`. Does not perform translations;
  the controller applies headers (e.g. via `MemberFields.label` / gettext)
  and sends the download.
  """

  require Ash.Query
  import Ash.Expr

  alias Mv.Membership.CustomField
  alias Mv.Membership.Member
  alias Mv.Membership.MemberExportSort
  alias MvWeb.MemberLive.Index.MembershipFeeStatus

  @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
                             ["membership_fee_type", "membership_fee_status", "groups"]
  @computed_export_fields ["membership_fee_status"]
  @computed_insert_after "membership_fee_start_date"
  @custom_field_prefix Mv.Constants.custom_field_prefix()
  @domain_member_field_strings Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)

  @doc """
  Fetches members and column specs for export.

  - `actor` - Ash actor (e.g. current user)
  - `parsed` - Map from controller's parse_and_validate (selected_ids, member_fields, etc.)

  Returns `{:ok, members, column_specs}` or `{:error, :forbidden}`.
  Column specs have `:kind`, `:key`, and for custom fields `:custom_field`;
  the controller adds `:header` and optional computed columns to members before CSV export.
  """
  @spec fetch(struct(), map()) ::
          {:ok, [struct()], [map()]} | {:error, :forbidden}
  def fetch(actor, parsed) do
    custom_field_ids_union =
      (parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})) |> Enum.uniq()

    with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(custom_field_ids_union, actor),
         {:ok, members} <- load_members(actor, parsed, custom_fields_by_id) do
      column_specs = build_column_specs(parsed, custom_fields_by_id)
      {:ok, members, column_specs}
    end
  end

  defp load_custom_fields_by_id([], _actor), do: {:ok, %{}}

  defp load_custom_fields_by_id(custom_field_ids, actor) do
    query =
      CustomField
      |> Ash.Query.filter(expr(id in ^custom_field_ids))
      |> Ash.Query.select([:id, :name, :value_type])

    case Ash.read(query, actor: actor) do
      {:ok, custom_fields} ->
        by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
        {:ok, by_id}

      {:error, %Ash.Error.Forbidden{}} ->
        {:error, :forbidden}
    end
  end

  defp build_custom_fields_by_id(custom_field_ids, custom_fields) do
    Enum.reduce(custom_field_ids, %{}, fn id, acc ->
      find_and_add_custom_field(acc, id, custom_fields)
    end)
  end

  defp find_and_add_custom_field(acc, id, custom_fields) do
    case Enum.find(custom_fields, fn cf -> to_string(cf.id) == to_string(id) end) do
      nil -> acc
      cf -> Map.put(acc, id, cf)
    end
  end

  defp build_column_specs(parsed, custom_fields_by_id) do
    member_specs = build_member_column_specs(parsed)
    custom_specs = build_custom_column_specs(parsed, custom_fields_by_id)

    member_specs ++ custom_specs
  end

  defp build_member_column_specs(parsed) do
    Enum.map(parsed.member_fields, fn f ->
      build_single_member_spec(f, parsed.selectable_member_fields)
    end)
    |> Enum.reject(&is_nil/1)
  end

  defp build_single_member_spec(field, selectable_member_fields) do
    if field in selectable_member_fields do
      %{kind: :member_field, key: field}
    else
      build_computed_spec(field)
    end
  end

  defp build_computed_spec(field) do
    # only allow known computed export fields to avoid crashing on unknown atoms
    if field in @computed_export_fields do
      %{kind: :computed, key: String.to_existing_atom(field)}
    else
      # ignore unknown non-selectable fields defensively
      nil
    end
  end

  defp build_custom_column_specs(parsed, custom_fields_by_id) do
    parsed.custom_field_ids
    |> Enum.map(fn id -> Map.get(custom_fields_by_id, id) end)
    |> Enum.reject(&is_nil/1)
    |> Enum.map(fn cf -> %{kind: :custom_field, key: cf.id, custom_field: cf} end)
  end

  defp load_members(actor, parsed, custom_fields_by_id) do
    query = build_members_query(parsed, custom_fields_by_id)

    case Ash.read(query, actor: actor) do
      {:ok, members} ->
        processed_members = process_loaded_members(members, parsed, custom_fields_by_id)
        {:ok, processed_members}

      {:error, %Ash.Error.Forbidden{}} ->
        {:error, :forbidden}
    end
  end

  defp build_members_query(parsed, _custom_fields_by_id) do
    select_fields =
      [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)

    custom_field_ids_union = parsed.custom_field_ids ++ Map.keys(parsed.boolean_filters || %{})

    need_cycles =
      parsed.show_current_cycle or parsed.cycle_status_filter != nil or
        parsed.computed_fields != [] or
        "membership_fee_status" in parsed.member_fields

    query =
      Member
      |> Ash.Query.new()
      |> Ash.Query.select(select_fields)
      |> load_custom_field_values_query(custom_field_ids_union)
      |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)

    if parsed.selected_ids != [] do
      Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
    else
      query
      |> apply_search(parsed.query)
      |> then(fn q ->
        {q, _sort_after_load} = maybe_sort(q, parsed.sort_field, parsed.sort_order)
        q
      end)
    end
  end

  defp process_loaded_members(members, parsed, custom_fields_by_id) do
    members
    |> apply_post_load_filters(parsed, custom_fields_by_id)
    |> apply_post_load_sorting(parsed, custom_fields_by_id)
    |> add_computed_fields(parsed.computed_fields, parsed.show_current_cycle)
  end

  defp apply_post_load_filters(members, parsed, custom_fields_by_id) do
    if parsed.selected_ids == [] do
      members
      |> apply_cycle_status_filter(parsed.cycle_status_filter, parsed.show_current_cycle)
      |> MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
        parsed.boolean_filters || %{},
        Map.values(custom_fields_by_id)
      )
    else
      members
    end
  end

  defp apply_post_load_sorting(members, parsed, custom_fields_by_id) do
    if parsed.selected_ids == [] and sort_after_load?(parsed.sort_field) do
      sort_members_by_custom_field(
        members,
        parsed.sort_field,
        parsed.sort_order,
        Map.values(custom_fields_by_id)
      )
    else
      members
    end
  end

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

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

    Ash.Query.load(query, custom_field_values: cfv_query)
  end

  defp apply_search(query, nil), do: query
  defp apply_search(query, ""), do: query

  defp apply_search(query, q) when is_binary(q) do
    if String.trim(q) != "" do
      Member.fuzzy_search(query, %{query: q})
    else
      query
    end
  end

  defp maybe_sort(query, nil, _order), do: {query, false}
  defp maybe_sort(query, _field, nil), do: {query, false}

  defp maybe_sort(query, field, order) when is_binary(field) do
    if custom_field_sort?(field) do
      {query, true}
    else
      field_atom = String.to_existing_atom(field)

      if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
        {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
      else
        {query, false}
      end
    end
  rescue
    ArgumentError -> {query, false}
  end

  defp sort_after_load?(field) when is_binary(field),
    do: String.starts_with?(field, @custom_field_prefix)

  defp sort_after_load?(_), do: false

  defp sort_members_by_custom_field(members, _field, _order, _custom_fields) when members == [],
    do: []

  defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
    id_str = String.trim_leading(field, @custom_field_prefix)
    custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
    if is_nil(custom_field), do: members

    key_fn = fn member ->
      cfv = find_cfv(member, custom_field)
      raw = if cfv, do: cfv.value, else: nil
      MemberExportSort.custom_field_sort_key(custom_field.value_type, raw)
    end

    members
    |> Enum.map(fn m -> {m, key_fn.(m)} end)
    |> Enum.sort(fn {_, ka}, {_, kb} -> MemberExportSort.key_lt(ka, kb, order) end)
    |> Enum.map(fn {m, _} -> m end)
  end

  defp find_cfv(member, custom_field) do
    (member.custom_field_values || [])
    |> Enum.find(fn cfv ->
      to_string(cfv.custom_field_id) == to_string(custom_field.id) or
        (Map.get(cfv, :custom_field) &&
           to_string(cfv.custom_field.id) == to_string(custom_field.id))
    end)
  end

  defp custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)

  defp maybe_load_cycles(query, false, _show_current), do: query

  defp maybe_load_cycles(query, true, show_current) do
    MembershipFeeStatus.load_cycles_for_members(query, show_current)
  end

  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 apply_cycle_status_filter(members, _status, _show_current), do: members

  defp add_computed_fields(members, computed_fields, show_current_cycle) do
    computed_fields = computed_fields || []

    if "membership_fee_status" in computed_fields do
      Enum.map(members, fn member ->
        status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
        # <= Atom rein
        Map.put(member, :membership_fee_status, status)
      end)
    else
      members
    end
  end

  # Called by controller to build parsed map from raw params (kept here so controller stays thin)
  @doc """
  Parses and validates export params (from JSON payload).

  Returns a map with :selected_ids, :member_fields, :selectable_member_fields,
  :computed_fields, :custom_field_ids, :query, :sort_field, :sort_order,
  :show_current_cycle, :cycle_status_filter, :boolean_filters.
  """
  @spec parse_params(map()) :: map()
  def parse_params(params) do
    # DB fields come from "member_fields"
    raw_member_fields = extract_list(params, "member_fields")
    member_fields = filter_allowed_member_fields(raw_member_fields)

    # computed fields can come from "computed_fields" (new payload) OR legacy inclusion in member_fields
    computed_fields =
      (extract_list(params, "computed_fields") ++ member_fields)
      |> normalize_computed_fields()
      |> Enum.filter(&(&1 in @computed_export_fields))
      |> Enum.uniq()

    # selectable DB fields: only real domain member fields, ordered like the table
    selectable_member_fields =
      member_fields
      |> Enum.filter(&(&1 in @domain_member_field_strings))
      |> order_member_fields_like_table()

    # Separate groups from other fields (groups is handled as a special field, not a member field)
    groups_field = if "groups" in member_fields, do: ["groups"], else: []

    # final member_fields list (used for column specs order): table order + fee type + computed + groups
    ordered_member_fields =
      selectable_member_fields
      |> insert_fee_type_and_computed_fields_like_table(computed_fields, member_fields)
      |> then(fn fields -> fields ++ groups_field end)

    %{
      selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
      member_fields: ordered_member_fields,
      selectable_member_fields: selectable_member_fields,
      computed_fields: computed_fields,
      custom_field_ids: filter_valid_uuids(extract_list(params, "custom_field_ids")),
      query: extract_string(params, "query"),
      sort_field: extract_string(params, "sort_field"),
      sort_order: extract_sort_order(params),
      show_current_cycle: extract_boolean(params, "show_current_cycle"),
      cycle_status_filter: extract_cycle_status_filter(params),
      boolean_filters: extract_boolean_filters(params)
    }
  end

  defp extract_boolean(params, key) do
    case Map.get(params, key) do
      true -> true
      "true" -> true
      _ -> false
    end
  end

  defp extract_cycle_status_filter(params) do
    case Map.get(params, "cycle_status_filter") do
      "paid" -> :paid
      "unpaid" -> :unpaid
      _ -> nil
    end
  end

  defp extract_boolean_filters(params) do
    case Map.get(params, "boolean_filters") do
      map when is_map(map) ->
        map
        |> Enum.filter(fn {k, v} ->
          is_binary(k) and is_boolean(v) and match?({:ok, _}, Ecto.UUID.cast(k))
        end)
        |> Enum.into(%{})

      _ ->
        %{}
    end
  end

  defp extract_list(params, key) do
    case Map.get(params, key) do
      list when is_list(list) -> list
      _ -> []
    end
  end

  defp extract_string(params, key) do
    case Map.get(params, key) do
      s when is_binary(s) -> s
      _ -> nil
    end
  end

  defp extract_sort_order(params) do
    case Map.get(params, "sort_order") do
      "asc" -> "asc"
      "desc" -> "desc"
      _ -> nil
    end
  end

  defp filter_allowed_member_fields(field_list) do
    allowlist = MapSet.new(@member_fields_allowlist)

    field_list
    |> Enum.filter(fn field -> is_binary(field) and MapSet.member?(allowlist, field) end)
    |> Enum.uniq()
  end

  defp filter_valid_uuids(id_list) when is_list(id_list) do
    id_list
    |> Enum.filter(fn id ->
      is_binary(id) and match?({:ok, _}, Ecto.UUID.cast(id))
    end)
    |> Enum.uniq()
  end

  defp order_member_fields_like_table(fields) when is_list(fields) do
    table_order = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
    table_order |> Enum.filter(&(&1 in fields))
  end

  defp insert_fee_type_and_computed_fields_like_table(
         db_fields_ordered,
         computed_fields,
         member_fields
       ) do
    computed_fields = computed_fields || []
    member_fields = member_fields || []

    db_with_insert =
      Enum.flat_map(db_fields_ordered, fn f ->
        expand_field_with_computed(f, member_fields, computed_fields)
      end)

    # If fee type is visible but start_date was not in the list, it won't be in db_with_insert
    db_with_insert =
      if "membership_fee_type" in member_fields and "membership_fee_type" not in db_with_insert do
        db_with_insert ++ ["membership_fee_type"]
      else
        db_with_insert
      end

    remaining = Enum.reject(computed_fields, &(&1 in db_with_insert))
    db_with_insert ++ remaining
  end

  # Insert membership_fee_type and membership_fee_status after membership_fee_start_date (table order).
  defp expand_field_with_computed(f, member_fields, computed_fields) do
    if f == @computed_insert_after do
      extra = []

      extra =
        if "membership_fee_type" in member_fields,
          do: extra ++ ["membership_fee_type"],
          else: extra

      extra =
        if "membership_fee_status" in computed_fields,
          do: extra ++ ["membership_fee_status"],
          else: extra

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

  defp normalize_computed_fields(fields) when is_list(fields) do
    fields
    |> Enum.filter(&is_binary/1)
    |> Enum.map(fn
      "payment_status" -> "membership_fee_status"
      other -> other
    end)
  end

  defp normalize_computed_fields(_), do: []
end
