defmodule Mv.Membership.MemberExport.Build do
  @moduledoc """
  Builds export data structure for member exports (CSV/PDF).

  Extracts common logic for loading, filtering, sorting, and formatting member data
  into a unified structure that can be used by both CSV and PDF exporters.

  Returns a structure:
  ```
  %{
    columns: [%{key: term(), kind: :member_field | :custom_field | :computed, ...}],
    rows: [[cell_string, ...]],
    meta: %{generated_at: String.t(), member_count: integer(), ...}
  }
  ```

  No translations/Gettext in this module - labels come from the web layer via a function.
  """

  require Ash.Query
  import Ash.Expr

  alias Mv.Membership.{CustomField, CustomFieldValueFormatter, Member, MemberExportSort}
  alias MvWeb.MemberLive.Index.MembershipFeeStatus

  @custom_field_prefix Mv.Constants.custom_field_prefix()

  @doc """
  Builds export data structure from parsed parameters.

  - `actor` - Ash actor (e.g. current user)
  - `parsed` - Map with export parameters (from `MemberExport.parse_params/1`)
  - `label_fn` - Function to get labels for columns: `(key) -> String.t()`

  Returns `{:ok, data}` or `{:error, :forbidden}`.

  The `data` map contains:
  - `columns`: List of column specs with `key`, `kind`, and optional `custom_field`
  - `rows`: List of rows, each row is a list of cell strings
  - `meta`: Metadata including `generated_at` and `member_count`
  """
  @spec build(struct(), map(), (term() -> String.t())) ::
          {:ok, map()} | {:error, :forbidden}
  def build(actor, parsed, label_fn) when is_function(label_fn, 1) do
    # Ensure sort custom field is loaded if needed
    parsed = ensure_sort_custom_field_loaded(parsed)

    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
      columns = build_columns(parsed, custom_fields_by_id, label_fn)
      rows = build_rows(members, columns, custom_fields_by_id)
      meta = build_meta(members)

      {:ok, %{columns: columns, rows: rows, meta: meta}}
    end
  end

  defp ensure_sort_custom_field_loaded(%{custom_field_ids: ids, sort_field: sort_field} = parsed) do
    case extract_sort_custom_field_id(sort_field) do
      nil -> parsed
      id -> %{parsed | custom_field_ids: Enum.uniq([id | ids])}
    end
  end

  defp extract_sort_custom_field_id(field) when is_binary(field) do
    if String.starts_with?(field, @custom_field_prefix) do
      String.trim_leading(field, @custom_field_prefix)
    else
      nil
    end
  end

  defp extract_sort_custom_field_id(_), do: nil

  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 load_members(actor, parsed, custom_fields_by_id) do
    {query, sort_after_load} = 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, sort_after_load)

        {: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

    need_groups = "groups" in parsed.member_fields

    need_membership_fee_type =
      "membership_fee_type" in parsed.member_fields or
        parsed.sort_field == "membership_fee_type"

    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)
      |> maybe_load_groups(need_groups)
      |> maybe_load_membership_fee_type(need_membership_fee_type)

    query =
      if parsed.selected_ids != [] do
        Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
      else
        apply_search(query, parsed.query)
      end

    # Apply sorting at query level if possible (not custom fields)
    maybe_sort(query, parsed.sort_field, parsed.sort_order)
  end

  defp process_loaded_members(members, parsed, custom_fields_by_id, sort_after_load) do
    members
    |> apply_post_load_filters(parsed, custom_fields_by_id)
    |> apply_post_load_sorting(parsed, custom_fields_by_id, sort_after_load)
    |> 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, sort_after_load) do
    # Sort after load for custom fields (always, even with selected_ids)
    if sort_after_load do
      sort_members_by_custom_field(
        members,
        parsed.sort_field,
        parsed.sort_order,
        Map.values(custom_fields_by_id)
      )
    else
      # For selected_ids, we may need to apply sorting that wasn't done at query level
      if (parsed.selected_ids != [] and parsed.sort_field) && parsed.sort_order do
        # Re-sort in memory to ensure consistent ordering
        sort_members_in_memory(members, parsed.sort_field, parsed.sort_order)
      else
        members
      end
    end
  end

  defp sort_members_in_memory(members, field, order) when is_binary(field) do
    field_atom = String.to_existing_atom(field)

    if field_atom in Mv.Constants.member_fields() or field_atom == :membership_fee_type do
      key_fn = sort_key_fn_for_field(field_atom)
      compare_fn = build_compare_fn(order)
      Enum.sort_by(members, key_fn, compare_fn)
    else
      members
    end
  rescue
    ArgumentError -> members
  end

  defp sort_members_in_memory(members, _field, _order), do: members

  defp sort_key_fn_for_field(:membership_fee_type) do
    fn member ->
      case Map.get(member, :membership_fee_type) do
        nil -> nil
        rel -> Map.get(rel, :name)
      end
    end
  end

  defp sort_key_fn_for_field(field_atom), do: fn member -> Map.get(member, field_atom) end

  defp build_compare_fn("asc"), do: fn a, b -> a <= b end
  defp build_compare_fn("desc"), do: fn a, b -> b <= a end
  defp build_compare_fn(_), do: fn _a, _b -> true 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
    cond do
      field == "groups" -> {query, true}
      field == "membership_fee_type" -> apply_fee_type_sort(query, order)
      custom_field_sort?(field) -> {query, true}
      true -> apply_standard_member_sort(query, field, order)
    end
  rescue
    ArgumentError -> {query, false}
  end

  defp apply_fee_type_sort(query, order) do
    order_atom = if order == "desc", do: :desc, else: :asc
    {Ash.Query.sort(query, [{"membership_fee_type.name", order_atom}]), false}
  end

  defp apply_standard_member_sort(query, field, order) do
    field_atom = String.to_existing_atom(field)

    sortable =
      field_atom in (Mv.Constants.member_fields() -- [:notes]) or
        field_atom == :membership_fee_type

    if sortable do
      order_atom = if order == "desc", do: :desc, else: :asc

      sort_field =
        if field_atom == :membership_fee_type,
          do: {"membership_fee_type.name", order_atom},
          else: {field_atom, order_atom}

      {Ash.Query.sort(query, [sort_field]), false}
    else
      {query, false}
    end
  end

  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
    if field == "groups" do
      sort_members_by_groups_export(members, order)
    else
      sort_by_custom_field_value(members, field, order, custom_fields)
    end
  end

  defp sort_by_custom_field_value(members, field, order, custom_fields) 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
    else
      sort_members_with_custom_field(members, custom_field, order)
    end
  end

  defp sort_members_with_custom_field(members, custom_field, order) do
    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 sort_members_by_groups_export(members, order) do
    # Members with groups first, then by first group name alphabetically (min = first by sort order)
    # Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
    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 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 maybe_load_groups(query, false), do: query

  defp maybe_load_groups(query, true) do
    # Load groups with id and name only (for export formatting)
    Ash.Query.load(query, groups: [:id, :name])
  end

  defp maybe_load_membership_fee_type(query, false), do: query

  defp maybe_load_membership_fee_type(query, true) do
    Ash.Query.load(query, membership_fee_type: [:id, :name])
  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)
        # Format as string for export (controller will handle translation)
        status_string = format_membership_fee_status(status)
        Map.put(member, :membership_fee_status, status_string)
      end)
    else
      members
    end
  end

  defp format_membership_fee_status(:paid), do: "paid"
  defp format_membership_fee_status(:unpaid), do: "unpaid"
  defp format_membership_fee_status(:suspended), do: "suspended"
  defp format_membership_fee_status(nil), do: ""

  defp build_columns(parsed, custom_fields_by_id, label_fn) do
    member_cols =
      Enum.map(parsed.selectable_member_fields, fn field ->
        %{
          key: field,
          kind: :member_field,
          label: label_fn.(field)
        }
      end)

    computed_cols =
      Enum.map(parsed.computed_fields, fn key ->
        atom_key = String.to_existing_atom(key)

        %{
          key: atom_key,
          kind: :computed,
          label: label_fn.(atom_key)
        }
      end)

    membership_fee_type_col =
      if "membership_fee_type" in parsed.member_fields do
        [
          %{
            key: :membership_fee_type,
            kind: :membership_fee_type,
            label: label_fn.(:membership_fee_type)
          }
        ]
      else
        []
      end

    groups_col =
      if "groups" in parsed.member_fields do
        [
          %{
            key: :groups,
            kind: :groups,
            label: label_fn.(:groups)
          }
        ]
      else
        []
      end

    custom_cols =
      parsed.custom_field_ids
      |> Enum.map(fn id ->
        cf = Map.get(custom_fields_by_id, id) || Map.get(custom_fields_by_id, to_string(id))

        if cf do
          %{
            key: to_string(id),
            kind: :custom_field,
            label: cf.name,
            custom_field: cf
          }
        else
          nil
        end
      end)
      |> Enum.reject(&is_nil/1)

    # Table order: ... membership_fee_start_date, membership_fee_type, membership_fee_status, groups, custom
    member_cols ++ membership_fee_type_col ++ computed_cols ++ groups_col ++ custom_cols
  end

  defp build_rows(members, columns, custom_fields_by_id) do
    Enum.map(members, fn member ->
      Enum.map(columns, fn col -> cell_value(member, col, custom_fields_by_id) end)
    end)
  end

  defp cell_value(member, %{kind: :member_field, key: key}, _custom_fields_by_id) do
    key_atom = key_to_atom(key)
    value = Map.get(member, key_atom)
    format_member_value(value)
  end

  defp cell_value(member, %{kind: :custom_field, key: id, custom_field: cf}, _custom_fields_by_id) do
    cfv = get_cfv_by_id(member, id)

    if cfv do
      CustomFieldValueFormatter.format_custom_field_value(cfv.value, cf)
    else
      ""
    end
  end

  defp cell_value(member, %{kind: :computed, key: key}, _custom_fields_by_id) do
    value = Map.get(member, key)
    if is_binary(value), do: value, else: ""
  end

  defp cell_value(
         member,
         %{kind: :membership_fee_type, key: :membership_fee_type},
         _custom_fields_by_id
       ) do
    case Map.get(member, :membership_fee_type) do
      %{name: name} when is_binary(name) -> name
      _ -> ""
    end
  end

  defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
    groups = Map.get(member, :groups) || []
    format_groups(groups)
  end

  defp key_to_atom(k) when is_atom(k), do: k

  defp key_to_atom(k) when is_binary(k) do
    try do
      String.to_existing_atom(k)
    rescue
      ArgumentError -> k
    end
  end

  defp get_cfv_by_id(member, id) do
    values =
      case Map.get(member, :custom_field_values) do
        v when is_list(v) -> v
        _ -> []
      end

    id_str = to_string(id)

    Enum.find(values, fn cfv ->
      to_string(cfv.custom_field_id) == id_str or
        (Map.get(cfv, :custom_field) && to_string(cfv.custom_field.id) == id_str)
    end)
  end

  defp format_member_value(nil), do: ""
  defp format_member_value(true), do: "true"
  defp format_member_value(false), do: "false"
  defp format_member_value(%Date{} = d), do: Date.to_iso8601(d)
  defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
  defp format_member_value(value), do: to_string(value)

  defp format_groups([]), do: ""

  defp format_groups(groups) when is_list(groups) do
    groups
    |> Enum.map(fn group -> Map.get(group, :name) || "" end)
    |> Enum.reject(&(&1 == ""))
    |> Enum.join(", ")
  end

  defp build_meta(members) do
    %{
      generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
      member_count: length(members)
    }
  end
end
