defmodule Parrhesia.Protocol do
  @moduledoc """
  Nostr protocol message decode/encode helpers.

  This module is transport-oriented: it turns websocket payloads into structured tuples and
  back again.

  For programmatic API calls inside the application, prefer the `Parrhesia.API.*` modules.
  In particular:

  - `validate_event/1` returns user-facing error strings
  - `Parrhesia.API.Auth.validate_event/1` returns machine-friendly validator atoms
  """

  alias Parrhesia.Protocol.EventValidator

  @type event :: map()
  @type filter :: map()

  @type client_message ::
          {:event, event()}
          | {:req, String.t(), [filter()]}
          | {:close, String.t()}
          | {:auth, event()}
          | {:count, String.t(), [filter()], map()}
          | {:neg_open, String.t(), filter(), binary()}
          | {:neg_msg, String.t(), binary()}
          | {:neg_close, String.t()}

  @type relay_message ::
          {:notice, String.t()}
          | {:ok, String.t(), boolean(), String.t()}
          | {:closed, String.t(), String.t()}
          | {:eose, String.t()}
          | {:event, String.t(), event()}
          | {:auth, String.t()}
          | {:count, String.t(), map()}
          | {:neg_msg, String.t(), String.t()}
          | {:neg_err, String.t(), String.t()}

  @type decode_error ::
          :invalid_json
          | :invalid_message
          | :invalid_event
          | :invalid_subscription_id
          | :invalid_filters
          | :invalid_auth
          | :invalid_count
          | :invalid_negentropy

  @count_options_keys MapSet.new(["hll", "approximate"])

  @doc """
  Decodes a client websocket payload into a structured protocol tuple.
  """
  @spec decode_client(binary()) :: {:ok, client_message()} | {:error, decode_error()}
  def decode_client(payload) when is_binary(payload) do
    with {:ok, decoded} <- decode_json(payload) do
      decode_message(decoded)
    end
  end

  @doc """
  Validates an event and returns relay-facing error strings.
  """
  @spec validate_event(event()) :: :ok | {:error, String.t()}
  def validate_event(event) do
    case EventValidator.validate(event) do
      :ok -> :ok
      {:error, reason} -> {:error, EventValidator.error_message(reason)}
    end
  end

  @doc """
  Encodes a relay message tuple into the JSON frame sent to clients.
  """
  @spec encode_relay(relay_message()) :: binary()
  def encode_relay(message) do
    message
    |> relay_frame()
    |> JSON.encode!()
  end

  @doc """
  Converts a decode error into the relay notice string that should be sent to a client.
  """
  @spec decode_error_notice(decode_error()) :: String.t()
  def decode_error_notice(reason) do
    case reason do
      :invalid_json -> "invalid: malformed JSON"
      :invalid_message -> "invalid: unsupported message shape"
      :invalid_event -> "invalid: invalid EVENT shape"
      :invalid_subscription_id -> "invalid: invalid subscription id"
      :invalid_filters -> "invalid: invalid filters"
      :invalid_auth -> "invalid: invalid AUTH message"
      :invalid_count -> "invalid: invalid COUNT message"
      :invalid_negentropy -> "invalid: invalid NEG message"
    end
  end

  defp decode_json(payload) do
    case JSON.decode(payload) do
      {:ok, decoded} -> {:ok, decoded}
      {:error, _reason} -> {:error, :invalid_json}
    end
  end

  defp decode_message(["EVENT", event]) when is_map(event), do: {:ok, {:event, event}}
  defp decode_message(["EVENT", _event]), do: {:error, :invalid_event}

  defp decode_message(["REQ", subscription_id | filters]) when is_binary(subscription_id) do
    decode_req_like_message(:req, subscription_id, filters)
  end

  defp decode_message(["REQ", _subscription_id | _filters]),
    do: {:error, :invalid_subscription_id}

  defp decode_message(["COUNT", subscription_id | filters_or_options])
       when is_binary(subscription_id) do
    with {:ok, filters, options} <- split_count_parts(filters_or_options),
         {:ok, {:req, ^subscription_id, parsed_filters}} <-
           decode_req_like_message(:req, subscription_id, filters) do
      {:ok, {:count, subscription_id, parsed_filters, options}}
    else
      _error -> {:error, :invalid_count}
    end
  end

  defp decode_message(["COUNT", _subscription_id | _filters_or_options]),
    do: {:error, :invalid_count}

  defp decode_message(["CLOSE", subscription_id]) when is_binary(subscription_id) do
    if valid_subscription_id?(subscription_id) do
      {:ok, {:close, subscription_id}}
    else
      {:error, :invalid_subscription_id}
    end
  end

  defp decode_message(["CLOSE", _subscription_id]), do: {:error, :invalid_subscription_id}

  defp decode_message(["AUTH", auth_event]) when is_map(auth_event),
    do: {:ok, {:auth, auth_event}}

  defp decode_message(["AUTH", _invalid]), do: {:error, :invalid_auth}

  defp decode_message(["NEG-OPEN", subscription_id, filter, initial_message])
       when is_binary(subscription_id) and is_map(filter) and is_binary(initial_message) do
    with true <- valid_subscription_id?(subscription_id),
         {:ok, decoded_message} <- decode_negentropy_hex(initial_message) do
      {:ok, {:neg_open, subscription_id, filter, decoded_message}}
    else
      false -> {:error, :invalid_subscription_id}
      {:error, _reason} -> {:error, :invalid_negentropy}
    end
  end

  defp decode_message(["NEG-MSG", subscription_id, payload])
       when is_binary(subscription_id) and is_binary(payload) do
    with true <- valid_subscription_id?(subscription_id),
         {:ok, decoded_payload} <- decode_negentropy_hex(payload) do
      {:ok, {:neg_msg, subscription_id, decoded_payload}}
    else
      false -> {:error, :invalid_subscription_id}
      {:error, _reason} -> {:error, :invalid_negentropy}
    end
  end

  defp decode_message(["NEG-CLOSE", subscription_id]) when is_binary(subscription_id) do
    if valid_subscription_id?(subscription_id) do
      {:ok, {:neg_close, subscription_id}}
    else
      {:error, :invalid_subscription_id}
    end
  end

  defp decode_message([type | _rest]) when type in ["NEG-OPEN", "NEG-MSG", "NEG-CLOSE"],
    do: {:error, :invalid_negentropy}

  defp decode_message(_other), do: {:error, :invalid_message}

  defp decode_req_like_message(_kind, subscription_id, filters) do
    cond do
      not valid_subscription_id?(subscription_id) ->
        {:error, :invalid_subscription_id}

      filters == [] ->
        {:error, :invalid_filters}

      Enum.all?(filters, &is_map/1) ->
        {:ok, {:req, subscription_id, filters}}

      true ->
        {:error, :invalid_filters}
    end
  end

  defp split_count_parts(parts) when is_list(parts) do
    if parts == [] do
      {:error, :missing_filters}
    else
      split_count_parts_with_optional_options(parts)
    end
  end

  defp split_count_parts(_parts), do: {:error, :invalid_parts}

  defp split_count_parts_with_optional_options(parts) do
    case List.last(parts) do
      options when is_map(options) ->
        maybe_extract_count_options(parts, options)

      _other ->
        {:ok, parts, %{}}
    end
  end

  defp maybe_extract_count_options(parts, options) do
    if count_options_map?(options) and length(parts) > 1 do
      filters = Enum.drop(parts, -1)
      {:ok, filters, options}
    else
      {:ok, parts, %{}}
    end
  end

  defp count_options_map?(map) do
    map
    |> Map.keys()
    |> Enum.all?(&MapSet.member?(@count_options_keys, &1))
  end

  defp relay_frame({:notice, message}), do: ["NOTICE", message]
  defp relay_frame({:ok, event_id, accepted, message}), do: ["OK", event_id, accepted, message]
  defp relay_frame({:closed, subscription_id, message}), do: ["CLOSED", subscription_id, message]
  defp relay_frame({:eose, subscription_id}), do: ["EOSE", subscription_id]
  defp relay_frame({:event, subscription_id, event}), do: ["EVENT", subscription_id, event]
  defp relay_frame({:auth, challenge}), do: ["AUTH", challenge]
  defp relay_frame({:count, subscription_id, payload}), do: ["COUNT", subscription_id, payload]

  defp relay_frame({:neg_msg, subscription_id, payload}),
    do: ["NEG-MSG", subscription_id, payload]

  defp relay_frame({:neg_err, subscription_id, reason}),
    do: ["NEG-ERR", subscription_id, reason]

  defp valid_subscription_id?(subscription_id) do
    subscription_id != "" and String.length(subscription_id) <= 64
  end

  defp decode_negentropy_hex(payload) when is_binary(payload) and payload != "" do
    case Base.decode16(payload, case: :mixed) do
      {:ok, decoded} when decoded != <<>> -> {:ok, decoded}
      _other -> {:error, :invalid_negentropy}
    end
  end

  defp decode_negentropy_hex(_payload), do: {:error, :invalid_negentropy}
end
