defmodule Mv.Membership.MembersCSV do
  @moduledoc """
  Exports members to CSV (RFC 4180) as iodata.

  Uses a column-based API: `export(members, columns)` where each column has
  `header` (display string, e.g. from Web layer), `kind` (:member_field | :custom_field | :computed),
  and `key` (member attribute name, custom_field id, or computed key). Custom field columns
  include a `custom_field` struct for value formatting. Domain code does not use Gettext;
  headers and computed values come from the caller (e.g. controller).
  """
  alias Mv.Membership.CustomFieldValueFormatter
  alias NimbleCSV.RFC4180

  @doc """
  Exports a list of members to CSV iodata.

  - `members` - List of member structs or maps (with optional `custom_field_values` loaded)
  - `columns` - List of column specs: `%{header: String.t(), kind: :member_field | :custom_field | :computed, key: term()}`
    For `:custom_field`, also pass `custom_field: %CustomField{}`. Header is used as-is (localized by caller).

  Returns iodata suitable for `IO.iodata_to_binary/1` or sending as response body.
  RFC 4180 escaping and formula-injection safe_cell are applied.
  """
  @spec export([struct() | map()], [map()]) :: iodata()
  def export(members, columns) when is_list(members) do
    header = build_header(columns)
    rows = Enum.map(members, fn member -> build_row(member, columns) end)
    RFC4180.dump_to_iodata([header | rows])
  end

  defp build_header(columns) do
    columns
    |> Enum.map(fn col -> col.header end)
    |> Enum.map(&safe_cell/1)
  end

  defp build_row(member, columns) do
    columns
    |> Enum.map(fn col -> cell_value(member, col) end)
    |> Enum.map(&safe_cell/1)
  end

  defp cell_value(member, %{kind: :member_field, key: key}) 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}) do
    cfv = get_cfv_by_id(member, id)

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

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

  defp cell_value(member, %{kind: :membership_fee_type, key: :membership_fee_type}) 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}) 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

  @doc false
  @spec safe_cell(String.t()) :: String.t()
  def safe_cell(s) when is_binary(s) do
    if String.starts_with?(s, ["=", "+", "-", "@", "\t"]), do: "'" <> s, else: s
  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
end
