defmodule Mv.Vereinfacht.Client do
  @moduledoc """
  HTTP client for the Vereinfacht accounting software JSON:API.

  Creates and updates finance contacts. Uses Bearer token authentication and
  requires club ID for multi-tenancy. Configuration via ENV or Settings
  (see Mv.Config).
  """
  require Logger

  @content_type "application/vnd.api+json"

  @doc """
  Creates a finance contact in Vereinfacht for the given member.

  Returns the contact ID on success. Does not update the member record;
  the caller (e.g. SyncContact change) must persist `vereinfacht_contact_id`.

  ## Options
  - None; URL, API key, and club ID are read from Mv.Config.

  ## Examples

      iex> create_contact(member)
      {:ok, "242"}

      iex> create_contact(member)
      {:error, {:http, 401, "Unauthenticated."}}
  """
  @spec create_contact(struct()) :: {:ok, String.t()} | {:error, term()}
  def create_contact(member) do
    base_url = base_url()
    api_key = api_key()
    club_id = club_id()

    if is_nil(base_url) or is_nil(api_key) or is_nil(club_id) do
      {:error, :not_configured}
    else
      body = build_create_body(member, club_id)
      url = base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts")
      post_and_parse_contact(url, body, api_key)
    end
  end

  @sync_timeout_ms 5_000

  # In test, skip retries so sync fails fast when no API is running (avoids log spam and long waits).
  defp req_http_options do
    opts = [receive_timeout: @sync_timeout_ms]
    if Mix.env() == :test, do: [retry: false] ++ opts, else: opts
  end

  defp post_and_parse_contact(url, body, api_key) do
    encoded_body = Jason.encode!(body)

    case Req.post(url, [body: encoded_body, headers: headers(api_key)] ++ req_http_options()) do
      {:ok, %{status: 201, body: resp_body}} ->
        case get_contact_id_from_response(resp_body) do
          nil -> {:error, {:invalid_response, resp_body}}
          id -> {:ok, id}
        end

      {:ok, %{status: status, body: resp_body}} ->
        {:error, {:http, status, extract_error_message(resp_body)}}

      {:error, reason} ->
        {:error, {:request_failed, reason}}
    end
  end

  @doc """
  Updates an existing finance contact in Vereinfacht.

  Only sends attributes that are typically synced from the member (name, email,
  address fields). Returns the same contact_id on success.

  ## Examples

      iex> update_contact("242", member)
      {:ok, "242"}

      iex> update_contact("242", member)
      {:error, {:http, 404, "Not Found"}}
  """
  @spec update_contact(String.t(), struct()) :: {:ok, String.t()} | {:error, term()}
  def update_contact(contact_id, member) when is_binary(contact_id) do
    base_url = base_url()
    api_key = api_key()

    if is_nil(base_url) or is_nil(api_key) do
      {:error, :not_configured}
    else
      body = build_update_body(contact_id, member)
      encoded_body = Jason.encode!(body)

      url =
        base_url
        |> String.trim_trailing("/")
        |> then(&"#{&1}/finance-contacts/#{contact_id}")

      case Req.patch(
             url,
             [
               body: encoded_body,
               headers: headers(api_key)
             ] ++ req_http_options()
           ) do
        {:ok, %{status: 200, body: _resp_body}} ->
          {:ok, contact_id}

        {:ok, %{status: status, body: body}} ->
          {:error, {:http, status, extract_error_message(body)}}

        {:error, reason} ->
          {:error, {:request_failed, reason}}
      end
    end
  end

  @doc """
  Finds a finance contact by email (GET /finance-contacts, then match in response).

  The Vereinfacht API does not allow filter by email on this endpoint, so we
  fetch the first page and find the contact client-side. Returns {:ok, contact_id}
  if a contact with that email exists, {:error, :not_found} if none, or
  {:error, reason} on API/network failure. Used before create for idempotency.
  """
  @spec find_contact_by_email(String.t()) :: {:ok, String.t()} | {:error, :not_found | term()}
  def find_contact_by_email(email) when is_binary(email) do
    if is_nil(base_url()) or is_nil(api_key()) or is_nil(club_id()) do
      {:error, :not_configured}
    else
      do_find_contact_by_email(email)
    end
  end

  defp do_find_contact_by_email(email) do
    url =
      base_url()
      |> String.trim_trailing("/")
      |> then(&"#{&1}/finance-contacts")

    case Req.get(url, [headers: headers(api_key())] ++ req_http_options()) do
      {:ok, %{status: 200, body: body}} when is_map(body) ->
        parse_find_by_email_response(body, email)

      {:ok, %{status: status, body: body}} ->
        {:error, {:http, status, extract_error_message(body)}}

      {:error, reason} ->
        {:error, {:request_failed, reason}}
    end
  end

  defp parse_find_by_email_response(body, email) do
    normalized = String.trim(email) |> String.downcase()

    case find_contact_id_by_email_in_list(body, normalized) do
      nil -> {:error, :not_found}
      id -> {:ok, id}
    end
  end

  defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
    Enum.find_value(list, fn
      %{"id" => id, "attributes" => %{"email" => att_email}} when is_binary(att_email) ->
        if att_email |> String.trim() |> String.downcase() == normalized do
          normalize_contact_id(id)
        else
          nil
        end

      %{"id" => _id, "attributes" => _} ->
        nil

      _ ->
        nil
    end)
  end

  defp find_contact_id_by_email_in_list(_, _), do: nil

  defp normalize_contact_id(id) when is_binary(id), do: id
  defp normalize_contact_id(id) when is_integer(id), do: to_string(id)
  defp normalize_contact_id(_), do: nil

  @doc """
  Fetches a single finance contact from Vereinfacht (GET /finance-contacts/:id).

  Returns the full response body (decoded JSON) for debugging/display.
  """
  @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
  def get_contact(contact_id) when is_binary(contact_id) do
    base_url = base_url()
    api_key = api_key()

    if is_nil(base_url) or is_nil(api_key) do
      {:error, :not_configured}
    else
      url =
        base_url
        |> String.trim_trailing("/")
        |> then(&"#{&1}/finance-contacts/#{contact_id}")

      case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
        {:ok, %{status: 200, body: body}} when is_map(body) ->
          {:ok, body}

        {:ok, %{status: status, body: body}} ->
          {:error, {:http, status, extract_error_message(body)}}

        {:error, reason} ->
          {:error, {:request_failed, reason}}
      end
    end
  end

  defp base_url, do: Mv.Config.vereinfacht_api_url()
  defp api_key, do: Mv.Config.vereinfacht_api_key()
  defp club_id, do: Mv.Config.vereinfacht_club_id()

  defp headers(api_key) do
    [
      {"Accept", @content_type},
      {"Content-Type", @content_type},
      {"Authorization", "Bearer #{api_key}"}
    ]
  end

  defp build_create_body(member, club_id) do
    attributes = member_to_attributes(member)

    %{
      "data" => %{
        "type" => "finance-contacts",
        "attributes" => attributes,
        "relationships" => %{
          "club" => %{
            "data" => %{"type" => "clubs", "id" => club_id}
          }
        }
      }
    }
  end

  defp build_update_body(contact_id, member) do
    attributes = member_to_attributes(member)

    %{
      "data" => %{
        "type" => "finance-contacts",
        "id" => contact_id,
        "attributes" => attributes
      }
    }
  end

  defp member_to_attributes(member) do
    address =
      [member |> Map.get(:street), member |> Map.get(:house_number)]
      |> Enum.reject(&is_nil/1)
      |> Enum.map_join(" ", &to_string/1)
      |> then(fn s -> if s == "", do: nil, else: s end)

    %{}
    |> put_attr("lastName", member |> Map.get(:last_name))
    |> put_attr("firstName", member |> Map.get(:first_name))
    |> put_attr("email", member |> Map.get(:email))
    |> put_attr("address", address)
    |> put_attr("zipCode", member |> Map.get(:postal_code))
    |> put_attr("city", member |> Map.get(:city))
    |> Map.put("contactType", "person")
    |> Enum.reject(fn {_k, v} -> is_nil(v) end)
    |> Map.new()
  end

  defp put_attr(acc, _key, nil), do: acc
  defp put_attr(acc, key, value), do: Map.put(acc, key, to_string(value))

  defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_binary(id), do: id

  defp get_contact_id_from_response(%{"data" => %{"id" => id}}) when is_integer(id),
    do: to_string(id)

  defp get_contact_id_from_response(_), do: nil

  defp extract_error_message(%{"errors" => [%{"detail" => d} | _]}) when is_binary(d), do: d
  defp extract_error_message(%{"errors" => [%{"title" => t} | _]}) when is_binary(t), do: t
  defp extract_error_message(body) when is_map(body), do: inspect(body)
  defp extract_error_message(other), do: inspect(other)
end
