defmodule NSQ.Producer do
  @moduledoc """
  A producer is a process that connects to one or many NSQDs and publishes
  messages.

  ## Interface

  To initialize:

      {:ok, producer} = NSQ.Producer.Supervisor.start_link("the-default-topic", %NSQ.Config{
        nsqds: ["127.0.0.1:6750"]
      })

  The default `topic` argument is _required_, even if you plan on explicitly
  publishing to a different topic. If you don't plan on using, you can set it
  to something like `_default_topic_`.

  If you provide more than one nsqd, each `pub`/`mpub` will choose one
  randomly.

  Note that, unlike consumers, producers _cannot_ be configured to use
  discovery with nsqlookupd. This is because discovery requires a topic and
  channel, and an nsqd will only appear in nsqlookupd if it has already
  published messages on that topic. So there's a chicken-and-egg problem. The
  recommended solution is to run NSQD on the same box where you're publishing,
  so your address is always 127.0.0.1 with a static port.

  ### pub

  Publish a single message to NSQD.

      NSQ.Producer.pub(producer, "a message")
      NSQ.Producer.pub(producer, "different-topic", "a message")

  ### mpub

  Publish a bunch of messages to NSQD atomically.

      NSQ.Producer.mpub(producer, ["one", "two"])
      NSQ.Producer.mpub(producer, "different-topic", ["one", "two"])
  """

  # ------------------------------------------------------- #
  # Directives                                              #
  # ------------------------------------------------------- #
  import NSQ.Protocol
  use GenServer

  # ------------------------------------------------------- #
  # Module Attributes                                       #
  # ------------------------------------------------------- #
  @initial_state %{
    topic: nil,
    channel: nil,
    conn_sup_pid: nil,
    event_manager_pid: nil,
    config: nil,
    conn_info_pid: nil
  }

  # ------------------------------------------------------- #
  # Type Definitions                                        #
  # ------------------------------------------------------- #
  @typedoc """
  A tuple with a host and a port.
  """
  @type host_with_port :: {String.t(), integer}

  @typedoc """
  A tuple with a string ID (used to target the connection in
  NSQ.Connection.Supervisor) and a PID of the connection.
  """
  @type connection :: {String.t(), pid}

  @typedoc """
  A map, but we can be more specific by asserting some entries that should be
  set for a connection's state map.
  """
  @type pro_state :: %{conn_sup_pid: pid, config: NSQ.Config.t()}

  # ------------------------------------------------------- #
  # Behaviour Implementation                                #
  # ------------------------------------------------------- #
  @spec init(pro_state) :: {:ok, pro_state}
  def init(pro_state) do
    {:ok, conn_sup_pid} = NSQ.Connection.Supervisor.start_link()
    pro_state = %{pro_state | conn_sup_pid: conn_sup_pid}

    {:ok, conn_info_pid} = Agent.start_link(fn -> %{} end)
    pro_state = %{pro_state | conn_info_pid: conn_info_pid}

    manager =
      if pro_state.config.event_manager do
        pro_state.config.event_manager
      else
        {:ok, manager} = :gen_event.start_link()
        manager
      end

    pro_state = %{pro_state | event_manager_pid: manager}

    {:ok, _pro_state} = connect_to_nsqds(pro_state.config.nsqds, self(), pro_state)
  end

  @spec handle_call({:pub, binary}, any, pro_state) ::
          {:reply, {:ok, binary}, pro_state}
  def handle_call({:pub, data}, _from, pro_state) do
    do_pub(pro_state.topic, data, pro_state)
  end

  @spec handle_call({:pub, binary, binary}, any, pro_state) ::
          {:reply, {:ok, binary}, pro_state}
  def handle_call({:pub, topic, data}, _from, pro_state) do
    do_pub(topic, data, pro_state)
  end

  @spec handle_call({:mpub, binary}, any, pro_state) ::
          {:reply, {:ok, binary}, pro_state}
  def handle_call({:mpub, data}, _from, pro_state) do
    do_mpub(pro_state.topic, data, pro_state)
  end

  @spec handle_call({:mpub, binary, binary}, any, pro_state) ::
          {:reply, {:ok, binary}, pro_state}
  def handle_call({:mpub, topic, data}, _from, pro_state) do
    do_mpub(topic, data, pro_state)
  end

  @spec handle_call(:state, any, pro_state) :: {:reply, pro_state, pro_state}
  def handle_call(:state, _from, state) do
    {:reply, state, state}
  end

  # ------------------------------------------------------- #
  # API Definitions                                         #
  # ------------------------------------------------------- #
  @spec start_link({binary(), NSQ.Config.t()}) :: {:ok, pid}
  def start_link({topic, config}), do: start_link({topic, config, []})

  @spec start_link({binary, NSQ.Config.t(), GenServer.options()}) :: {:ok, pid}
  def start_link({topic, config, genserver_options}) do
    {:ok, config} = NSQ.Config.validate(config || %NSQ.Config{})
    {:ok, config} = NSQ.Config.normalize(config)
    unless is_valid_topic_name?(topic), do: raise("Invalid topic name #{topic}")
    state = %{@initial_state | topic: topic, config: config}
    GenServer.start_link(__MODULE__, state, genserver_options)
  end

  @spec get_connections(pro_state) :: [connection]
  def get_connections(pro_state) when is_map(pro_state) do
    children = Supervisor.which_children(pro_state.conn_sup_pid)
    Enum.map(children, fn {child_id, pid, _, _} -> {child_id, pid} end)
  end

  @spec get_connections(pid, pro_state) :: [connection]
  def get_connections(pro, pro_state \\ nil) when is_pid(pro) do
    pro_state = pro_state || get_state(pro)
    get_connections(pro_state)
  end

  @spec random_connection_pid(pro_state) :: pid
  def random_connection_pid(pro_state) do
    {_child_id, pid} = Enum.random(get_connections(pro_state))
    pid
  end

  @doc """
  Create supervised connections to NSQD.
  """
  @spec connect_to_nsqds([host_with_port], pid, pro_state) :: {:ok, pro_state}
  def connect_to_nsqds(nsqds, pro, pro_state) do
    Enum.map(nsqds, fn nsqd ->
      {:ok, _conn} =
        NSQ.Connection.Supervisor.start_child(
          pro,
          nsqd,
          pro_state,
          restart: :permanent
        )
    end)

    {:ok, pro_state}
  end

  @doc """
  Get the current state of a producer. Used in tests. Not for external use.
  """
  @spec get_state(pid) :: pro_state
  def get_state(producer) do
    GenServer.call(producer, :state)
  end

  @doc """
  Publish data to whatever topic is the default.
  """
  @spec pub(pid, binary) :: {:ok, binary}
  def pub(sup_pid, data) do
    {:ok, _resp} = GenServer.call(get(sup_pid), {:pub, data})
  end

  @doc """
  Publish data to a specific topic.
  """
  @spec pub(pid, binary, binary) :: {:ok, binary}
  def pub(sup_pid, topic, data) do
    {:ok, _resp} = GenServer.call(get(sup_pid), {:pub, topic, data})
  end

  @doc """
  Publish data to whatever topic is the default.
  """
  @spec mpub(pid, binary) :: {:ok, binary}
  def mpub(sup_pid, data) do
    GenServer.call(get(sup_pid), {:mpub, data})
  end

  @doc """
  Publish data to a specific topic.
  """
  @spec mpub(pid, binary, binary) :: {:ok, binary}
  def mpub(sup_pid, topic, data) do
    {:ok, _resp} = GenServer.call(get(sup_pid), {:mpub, topic, data})
  end

  @doc """
  The end-user will be targeting the supervisor, but it's the producer that
  can actually handle the command.
  """
  @spec get(pid) :: pid
  def get(sup_pid) do
    child =
      Supervisor.which_children(sup_pid)
      |> Enum.find(fn {kind, _, _, _} -> kind == NSQ.Producer end)

    {_, pid, _, _} = child
    pid
  end

  # ------------------------------------------------------- #
  # Private Functions                                       #
  # ------------------------------------------------------- #
  # Used to DRY up handle_call({:pub, ...).
  @spec do_pub(binary, binary, pro_state) :: {:reply, {:ok, binary}, pro_state}
  defp do_pub(topic, data, pro_state) do
    {:ok, resp} =
      random_connection_pid(pro_state)
      |> NSQ.Connection.cmd({:pub, topic, data})

    {:reply, {:ok, resp}, pro_state}
  end

  # Used to DRY up handle_call({:mpub, ...).
  @spec do_mpub(binary, binary, pro_state) :: {:reply, {:ok, binary}, pro_state}
  defp do_mpub(topic, data, pro_state) do
    {:ok, resp} =
      random_connection_pid(pro_state)
      |> NSQ.Connection.cmd({:mpub, topic, data})

    {:reply, {:ok, resp}, pro_state}
  end
end
