defmodule MvWeb.MemberExportController do
  @moduledoc """
  Controller for CSV export of members.

  POST /members/export.csv with form param "payload" (JSON string).
  Same permission and actor context as the member overview; 403 if unauthorized.
  """
  use MvWeb, :controller

  require Ash.Query
  import Ash.Expr

  alias Mv.Authorization.Actor
  alias Mv.Membership.CustomField
  alias Mv.Membership.Member
  alias Mv.Membership.MembersCSV
  alias MvWeb.Translations.MemberFields
  alias MvWeb.MemberLive.Index.MembershipFeeStatus
  use Gettext, backend: MvWeb.Gettext

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

  def export(conn, params) do
    actor = current_actor(conn)
    if is_nil(actor), do: return_forbidden(conn)

    case params["payload"] do
      nil ->
        conn
        |> put_status(400)
        |> put_resp_content_type("application/json")
        |> json(%{error: "payload required"})

      payload when is_binary(payload) ->
        case Jason.decode(payload) do
          {:ok, decoded} when is_map(decoded) ->
            parsed = parse_and_validate(decoded)
            run_export(conn, actor, parsed)

          _ ->
            conn
            |> put_status(400)
            |> put_resp_content_type("application/json")
            |> json(%{error: "invalid JSON"})
        end
    end
  end

  defp current_actor(conn) do
    conn.assigns[:current_user]
    |> Actor.ensure_loaded()
  end

  defp return_forbidden(conn) do
    conn
    |> put_status(403)
    |> put_resp_content_type("application/json")
    |> json(%{error: "Forbidden"})
    |> halt()
  end

  defp parse_and_validate(params) do
    member_fields = filter_allowed_member_fields(extract_list(params, "member_fields"))
    {selectable_member_fields, computed_fields} = split_member_fields(member_fields)

    %{
      selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),
      member_fields: member_fields,
      selectable_member_fields: selectable_member_fields,
      computed_fields:
        computed_fields ++ filter_existing_atoms(extract_list(params, "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")
    }
  end

  defp split_member_fields(member_fields) do
    domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
    selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
    computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
    # "groups" is neither a domain field nor a computed field, it's handled separately
    {selectable, computed}
  end

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

  defp filter_existing_atoms(list) when is_list(list) do
    list
    |> Enum.filter(fn name ->
      is_binary(name) and atom_exists?(name)
    end)
    |> Enum.uniq()
  end

  defp atom_exists?(name) do
    try do
      _ = String.to_existing_atom(name)
      true
    rescue
      ArgumentError -> false
    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 run_export(conn, actor, parsed) do
    # FIX: Wenn nach einem Custom Field sortiert wird, muss dieses Feld geladen werden,
    # auch wenn es nicht exportiert wird (sonst kann Export nicht korrekt sortieren).
    parsed =
      parsed
      |> ensure_sort_custom_field_loaded()

    with {:ok, custom_fields_by_id} <- load_custom_fields_by_id(parsed.custom_field_ids, actor),
         {:ok, members} <- load_members_for_export(actor, parsed, custom_fields_by_id) do
      columns = build_columns(conn, parsed, custom_fields_by_id)
      csv_iodata = MembersCSV.export(members, columns)
      filename = "members-#{Date.utc_today()}.csv"

      send_download(
        conn,
        {:binary, IO.iodata_to_binary(csv_iodata)},
        filename: filename,
        content_type: "text/csv; charset=utf-8"
      )
    else
      {:error, :forbidden} ->
        return_forbidden(conn)
    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])

    query
    |> Ash.read(actor: actor)
    |> handle_custom_fields_read_result(custom_field_ids)
  end

  defp handle_custom_fields_read_result({:ok, custom_fields}, custom_field_ids) do
    by_id = build_custom_fields_by_id(custom_field_ids, custom_fields)
    {:ok, by_id}
  end

  defp handle_custom_fields_read_result({:error, %Ash.Error.Forbidden{}}, _custom_field_ids) do
    {:error, :forbidden}
  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_for_export(actor, parsed, custom_fields_by_id) do
    select_fields = [:id] ++ Enum.map(parsed.selectable_member_fields, &String.to_existing_atom/1)

    need_cycles =
      parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields

    need_groups = "groups" in parsed.member_fields

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

    query =
      if parsed.selected_ids != [] do
        # selected export: filtert die Menge, aber die Sortierung muss trotzdem wie in der Tabelle angewandt werden
        Ash.Query.filter(query, expr(id in ^parsed.selected_ids))
      else
        query
        |> apply_search_export(parsed.query)
      end

    # FIX: Sortierung IMMER anwenden (auch bei selected_ids)
    {query, sort_after_load} = maybe_sort_export(query, parsed.sort_field, parsed.sort_order)

    case Ash.read(query, actor: actor) do
      {:ok, members} ->
        members =
          if sort_after_load do
            sort_members_by_custom_field_export(
              members,
              parsed.sort_field,
              parsed.sort_order,
              Map.values(custom_fields_by_id)
            )
          else
            members
          end

        # Calculate membership_fee_status for computed fields
        members = add_computed_fields(members, parsed.computed_fields, parsed.show_current_cycle)

        {:ok, members}

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

  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

  # Adds computed field values to members (e.g. membership_fee_status)
  defp add_computed_fields(members, computed_fields, show_current_cycle) do
    if "membership_fee_status" in computed_fields do
      Enum.map(members, fn member ->
        status = MembershipFeeStatus.get_cycle_status_for_member(member, show_current_cycle)
        status_string = format_membership_fee_status(status)
        Map.put(member, :membership_fee_status, status_string)
      end)
    else
      members
    end
  end

  # Formats membership fee status as German string
  defp format_membership_fee_status(:paid), do: gettext("paid")
  defp format_membership_fee_status(:unpaid), do: gettext("unpaid")
  defp format_membership_fee_status(:suspended), do: gettext("suspended")
  defp format_membership_fee_status(nil), do: ""

  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_export(query, nil), do: query
  defp apply_search_export(query, ""), do: query

  defp apply_search_export(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_export(query, nil, _order), do: {query, false}
  defp maybe_sort_export(query, _field, nil), do: {query, false}

  defp maybe_sort_export(query, field, order) when is_binary(field) do
    cond do
      field == "groups" ->
        # Groups sort → in-memory nach dem Read (wie Tabelle)
        {query, true}

      custom_field_sort?(field) ->
        # Custom field sort → in-memory nach dem Read (wie Tabelle)
        {query, true}

      true ->
        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 custom_field_sort?(field), do: String.starts_with?(field, @custom_field_prefix)

  # ------------------------------------------------------------------
  # Custom field sorting (match member table behavior)
  # ------------------------------------------------------------------

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

  defp sort_members_by_custom_field_export(members, field, order, custom_fields)
       when is_binary(field) do
    order = order || "asc"

    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
      # Match table:
      # 1) values first, empty last
      # 2) sort only values
      # 3) for desc, reverse only the values-part
      {with_values, without_values} =
        Enum.split_with(members, fn member ->
          has_non_empty_custom_field_value?(member, custom_field)
        end)

      sorted_with_values =
        Enum.sort_by(with_values, fn member ->
          extract_member_sort_value(member, custom_field)
        end)

      sorted_with_values =
        if order == "desc", do: Enum.reverse(sorted_with_values), else: sorted_with_values

      sorted_with_values ++ without_values
    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 has_non_empty_custom_field_value?(member, custom_field) do
    case find_cfv(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 empty_value?(nil, _type), do: true

  defp empty_value?(value, type) when type in [:string, :email] and is_binary(value) do
    String.trim(value) == ""
  end

  defp empty_value?(_value, _type), do: false

  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 extract_member_sort_value(member, custom_field) do
    case find_cfv(member, custom_field) do
      nil -> nil
      cfv -> extract_sort_value(cfv.value, custom_field.value_type)
    end
  end

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

    computed_cols =
      Enum.map(parsed.computed_fields, fn key ->
        %{
          header: computed_field_header(conn, key),
          kind: :computed,
          key: String.to_existing_atom(key)
        }
      end)

    groups_col =
      if "groups" in parsed.member_fields do
        [
          %{
            header: groups_field_header(conn),
            kind: :groups,
            key: :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
          %{
            header: custom_field_header(conn, cf),
            kind: :custom_field,
            key: to_string(id),
            custom_field: cf
          }
        else
          nil
        end
      end)
      |> Enum.reject(&is_nil/1)

    member_cols ++ computed_cols ++ groups_col ++ custom_cols
  end

  # --- headers: use MemberFields.label for translations ---
  defp member_field_header(_conn, field) when is_binary(field) do
    field
    |> String.to_existing_atom()
    |> MemberFields.label()
  rescue
    ArgumentError ->
      # Fallback for unknown fields
      humanize_field(field)
  end

  defp computed_field_header(_conn, key) when is_atom(key) do
    # Map export-only alias to canonical UI key for translation
    atom_key = if key == :payment_status, do: :membership_fee_status, else: key
    MemberFields.label(atom_key)
  end

  defp computed_field_header(_conn, key) when is_binary(key) do
    # Map export-only alias to canonical UI key for translation
    atom_key =
      case key do
        "payment_status" -> :membership_fee_status
        _ -> String.to_existing_atom(key)
      end

    MemberFields.label(atom_key)
  rescue
    ArgumentError ->
      # Fallback for unknown computed fields
      humanize_field(key)
  end

  defp custom_field_header(_conn, cf) do
    # Custom fields: meist ist cf.name bereits der Display Name
    cf.name
  end

  defp groups_field_header(_conn) do
    MemberFields.label(:groups)
  end

  defp humanize_field(str) do
    str
    |> String.replace("_", " ")
    |> String.split()
    |> Enum.map_join(" ", &String.capitalize/1)
  end

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

  defp extract_sort_value(nil, _), do: nil
  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{} = d, :date), do: d
  defp extract_sort_value(value, :email) when is_binary(value), do: value
  defp extract_sort_value(value, _), do: to_string(value)
end
