defmodule Req.Test do
  @moduledoc """
  Req testing conveniences.

  Req is composed of:

    * `Req` - the high-level API

    * `Req.Request` - the low-level API and the request struct

    * `Req.Steps` - the collection of built-in steps

    * `Req.Test` - the testing conveniences (you're here!)

  Req already has built-in support for different variants of stubs via `:plug`, `:adapter`,
  and (indirectly) `:base_url` options. With this module you can:

    * Create request stubs using [`Req.Test.stub(name, plug)`](`stub/2`) and mocks
      using [`Req.Test.expect(name, count, plug)`](`expect/3`). Both can be used in concurrent
      tests.

    * Configure Req to run requests through mocks/stubs by setting `plug: {Req.Test, name}`.
      This works because `Req.Test` itself is a plug whose job is to fetch the mocks/stubs under
      `name`.

    * Easily create JSON responses with [`Req.Test.json(conn, body)`](`json/2`),
      HTML responses with [`Req.Test.html(conn, body)`](`html/2`), and
      text responses with [`Req.Test.text(conn, body)`](`text/2`).

    * Simulate network errors with [`Req.Test.transport_error(conn, reason)`](`transport_error/2`).

  Mocks and stubs are using the same ownership model of
  [nimble_ownership](https://hex.pm/packages/nimble_ownership), also used by
  [Mox](https://hex.pm/packages/mox). This allows `Req.Test` to be used in concurrent tests.

  ## Example

  Imagine we're building an app that displays weather for a given location using an HTTP weather
  service:

      defmodule MyApp.Weather do
        def get_rating(location) do
          case get_temperature(location) do
            {:ok, %{status: 200, body: %{"celsius" => celsius}}} ->
              cond do
                celsius < 18.0 -> {:ok, :too_cold}
                celsius < 30.0 -> {:ok, :nice}
                true -> {:ok, :too_hot}
              end

            _ ->
              :error
          end
        end

        def get_temperature(location) do
          [
            base_url: "https://weather-service",
            params: [location: location]
          ]
          |> Keyword.merge(Application.get_env(:myapp, :weather_req_options, []))
          |> Req.request()
        end
      end

  We configure it for production:

      # config/runtime.exs
      config :myapp, weather_req_options: [
        auth: {:bearer, System.fetch_env!("MYAPP_WEATHER_API_KEY")}
      ]

  In tests, instead of hitting the network, we make the request against
  a [plug](`Req.Steps.run_plug/1`) _stub_ named `MyApp.Weather`:

      # config/test.exs
      config :myapp, weather_req_options: [
        plug: {Req.Test, MyApp.Weather}
      ]

  Now we can control our stubs **in concurrent tests**:

      use ExUnit.Case, async: true

      test "nice weather" do
        Req.Test.stub(MyApp.Weather, fn conn ->
          Req.Test.json(conn, %{"celsius" => 25.0})
        end)

        assert MyApp.Weather.get_rating("Krakow, Poland") == {:ok, :nice}
      end

  ## Concurrency and Allowances

  The example above works in concurrent tests because `MyApp.Weather.get_rating/1` calls
  directly to `Req.request/1` *in the same process*. It also works in many cases where the
  request happens in a spawned process, such as a `Task`, `GenServer`, and more.

  However, if you are encountering issues with stubs not being available in spawned processes,
  it's likely that you'll need **explicit allowances**. For example, if
  `MyApp.Weather.get_rating/1` was calling `Req.request/1` in a process spawned with `spawn/1`,
  the stub would not be available in the spawned process:

      # With code like this, the stub would not be available in the spawned task:
      def get_rating_async(location) do
        spawn(fn -> get_rating(location) end)
      end

  To make stubs defined in the test process available in other processes, you can use
  `allow/3`. For example, imagine that the call to `MyApp.Weather.get_rating/1`
  was happening in a spawned GenServer:

      test "nice weather" do
        {:ok, pid} = start_gen_server(...)

        Req.Test.stub(MyApp.Weather, fn conn ->
          Req.Test.json(conn, %{"celsius" => 25.0})
        end)

        Req.Test.allow(MyApp.Weather, self(), pid)

        assert get_weather(pid, "Krakow, Poland") == {:ok, :nice}
      end

  ## Broadway

  If you're using `Req.Test` with [Broadway](https://hex.pm/broadway), you may need to use
  `allow/3` to make stubs available in the Broadway processors. A great way to do that is
  to hook into the [Telemetry](https://hex.pm/telemetry) events that Broadway publishes to
  manually allow the processors and batch processors to access the stubs. This approach is
  similar to what is [documented in Broadway
  itself](https://hexdocs.pm/broadway/Broadway.html#module-testing-with-ecto).

  First, you should add the test PID (which is allowed to use the Req stub) to the metadata
  for the test events you're publishing:

      Broadway.test_message(MyApp.Pipeline, message, metadata: %{req_stub_owner: self()})

  Then, you'll need to define a test helper to hook into the Telemetry events. For example,
  in your `test/test_helper.exs` file:

      defmodule BroadwayReqStubs do
        def attach(stub) do
          events = [
            [:broadway, :processor, :start],
            [:broadway, :batch_processor, :start],
          ]

          :telemetry.attach_many({__MODULE__, stub}, events, &__MODULE__.handle_event/4, %{stub: stub})
        end

        def handle_event(_event_name, _event_measurement, %{messages: messages}, %{stub: stub}) do
          with [%Broadway.Message{metadata: %{req_stub_owner: pid}} | _] <- messages do
            :ok = Req.Test.allow(stub, pid, self())
          end

          :ok
        end
      end

  Last but not least, attach the helper in your `test/test_helper.exs`:

      BroadwayReqStubs.attach(MyStub)

  """

  @typep name() :: term()

  if Code.ensure_loaded?(Plug.Conn) do
    @typep plug() ::
             module()
             | {module(), term()}
             | (Plug.Conn.t() -> Plug.Conn.t())
             | (Plug.Conn.t(), term() -> Plug.Conn.t())
  else
    @typep plug() ::
             module()
             | {module, term()}
             | (conn :: term() -> term())
             | (conn :: term(), term() -> term())
  end

  @ownership Req.Test.Ownership

  @doc """
  Sends JSON response.

  ## Examples

      iex> plug = fn conn ->
      ...>   Req.Test.json(conn, %{celsius: 25.0})
      ...> end
      iex>
      iex> resp = Req.get!(plug: plug)
      iex> resp.headers["content-type"]
      ["application/json; charset=utf-8"]
      iex> resp.body
      %{"celsius" => 25.0}

  """
  if Code.ensure_loaded?(Plug.Test) do
    @spec json(Plug.Conn.t(), term()) :: Plug.Conn.t()
    def json(%Plug.Conn{} = conn, data) do
      send_resp(conn, conn.status || 200, "application/json", Jason.encode_to_iodata!(data))
    end

    defp send_resp(conn, default_status, default_content_type, body) do
      conn
      |> ensure_resp_content_type(default_content_type)
      |> Plug.Conn.send_resp(conn.status || default_status, body)
    end

    defp ensure_resp_content_type(%Plug.Conn{resp_headers: resp_headers} = conn, content_type) do
      if List.keyfind(resp_headers, "content-type", 0) do
        conn
      else
        content_type = content_type <> "; charset=utf-8"
        %{conn | resp_headers: [{"content-type", content_type} | resp_headers]}
      end
    end
  else
    def json(_conn, _data) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end

  @doc """
  Sends HTML response.

  ## Examples

      iex> plug = fn conn ->
      ...>   Req.Test.html(conn, "<h1>Hello, World!</h1>")
      ...> end
      iex>
      iex> resp = Req.get!(plug: plug)
      iex> resp.headers["content-type"]
      ["text/html; charset=utf-8"]
      iex> resp.body
      "<h1>Hello, World!</h1>"

  """
  if Code.ensure_loaded?(Plug.Test) do
    @spec html(Plug.Conn.t(), iodata()) :: Plug.Conn.t()
    def html(%Plug.Conn{} = conn, data) do
      send_resp(conn, conn.status || 200, "text/html", data)
    end
  else
    def html(_conn, _data) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end

  @doc """
  Sends text response.

  ## Examples

      iex> plug = fn conn ->
      ...>   Req.Test.text(conn, "Hello, World!")
      ...> end
      iex>
      iex> resp = Req.get!(plug: plug)
      iex> resp.headers["content-type"]
      ["text/plain; charset=utf-8"]
      iex> resp.body
      "Hello, World!"

  """
  if Code.ensure_loaded?(Plug.Test) do
    @spec text(Plug.Conn.t(), iodata()) :: Plug.Conn.t()
    def text(%Plug.Conn{} = conn, data) do
      send_resp(conn, conn.status || 200, "text/plain", data)
    end
  else
    def text(_conn, _data) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end

  @doc """
  Sends redirect response to the given url.

  This function is adapted from [`Phoenix.Controller.redirect/2`](https://hexdocs.pm/phoenix/Phoenix.Controller.html#redirect/2).

  For security, `:to` only accepts paths. Use the `:external`
  option to redirect to any URL.

  The response will be sent with the status code defined within
  the connection, via `Plug.Conn.put_status/2`. If no status
  code is set, a 302 response is sent.

  ## Examples


      iex> plug = fn
      ...>   conn when conn.request_path == nil ->
      ...>     Req.Test.redirect(conn, to: "/hello")
      ...>
      ...>   conn when conn.request_path == "/hello" ->
      ...>     Req.Test.text(conn, "Hello, World!")
      ...>   conn -> dbg(conn)
      ...> end
      iex>
      iex> resp = Req.get!(plug: plug, url: "http://example.com")
      # 14:53:06.101 [debug] redirecting to /hello
      iex> resp.body
      "Hello, World!"

  """
  def redirect(conn, opts)

  if Code.ensure_loaded?(Plug.Conn) do
    def redirect(conn, opts) when is_list(opts) do
      url = url(opts)
      html = Plug.HTML.html_escape(url)
      body = "<html><body>You are being <a href=\"#{html}\">redirected</a>.</body></html>"

      conn =
        if List.keyfind(conn.resp_headers, "content-type", 0) do
          conn
        else
          content_type = "text/html; charset=utf-8"
          update_in(conn.resp_headers, &[{"content-type", content_type} | &1])
        end

      conn
      |> Plug.Conn.put_resp_header("location", url)
      |> Plug.Conn.send_resp(conn.status || 302, body)
    end

    defp url(opts) do
      cond do
        to = opts[:to] -> validate_local_url(to)
        external = opts[:external] -> external
        true -> raise ArgumentError, "expected :to or :external option in redirect/2"
      end
    end

    @invalid_local_url_chars ["\\", "/%09", "/\t"]
    defp validate_local_url("//" <> _ = to), do: raise_invalid_url(to)

    defp validate_local_url("/" <> _ = to) do
      if String.contains?(to, @invalid_local_url_chars) do
        raise ArgumentError, "unsafe characters detected for local redirect in URL #{inspect(to)}"
      else
        to
      end
    end

    defp validate_local_url(to), do: raise_invalid_url(to)

    defp raise_invalid_url(url) do
      raise ArgumentError, "the :to option in redirect expects a path but was #{inspect(url)}"
    end
  else
    def redirect(_conn, _opts) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end

  @doc """
  Simulates a network transport error.

  ## Examples

      iex> plug = fn conn ->
      ...>   Req.Test.transport_error(conn, :timeout)
      ...> end
      iex>
      iex> Req.get(plug: plug, retry: false)
      {:error, %Req.TransportError{reason: :timeout}}

  """
  @doc since: "0.5.0"
  def transport_error(conn, reason)

  if Code.ensure_loaded?(Plug.Conn) do
    @spec transport_error(Plug.Conn.t(), reason :: atom()) :: Plug.Conn.t()
    def transport_error(%Plug.Conn{} = conn, reason) do
      validate_transport_error!(reason)
      exception = Req.TransportError.exception(reason: reason)
      put_in(conn.private[:req_test_exception], exception)
    end

    defp validate_transport_error!(:protocol_not_negotiated), do: :ok
    defp validate_transport_error!({:bad_alpn_protocol, _}), do: :ok
    defp validate_transport_error!(:closed), do: :ok
    defp validate_transport_error!(:timeout), do: :ok

    defp validate_transport_error!(reason) do
      case :ssl.format_error(reason) do
        ~c"Unexpected error:" ++ _ ->
          raise ArgumentError, """
          unexpected Req.TransportError reason: #{inspect(reason)}

          This function only accepts error reasons that can happen
          in production, for example: `:closed`, `:timeout`,
          `:econnrefused`, etc.
          """

        _ ->
          :ok
      end
    end
  else
    def transport_error(_conn, _reason) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end

  @doc false
  @deprecated "Don't manually fetch stubs. See the documentation for Req.Test instead."
  def stub(name) do
    __fetch_plug__(name)
  end

  def __fetch_plug__(name) do
    case Req.Test.Ownership.fetch_owner(@ownership, callers(), name) do
      {tag, owner} when is_pid(owner) and tag in [:ok, :shared_owner] ->
        result =
          Req.Test.Ownership.get_and_update(@ownership, owner, name, fn
            %{expectations: [value | rest]} = map ->
              {{:ok, value}, put_in(map[:expectations], rest)}

            %{stub: value} = map ->
              {{:ok, value}, map}

            %{expectations: []} = map ->
              {{:error, :no_expectations_and_no_stub}, map}
          end)

        case result do
          {:ok, {:ok, value}} ->
            value

          {:ok, {:error, :no_expectations_and_no_stub}} ->
            raise "no mock or stub for #{inspect(name)}"
        end

      :error ->
        raise "cannot find mock/stub #{inspect(name)} in process #{inspect(self())}"
    end
  end

  defguardp is_plug(value)
            when is_function(value, 1) or
                   is_function(value, 2) or
                   is_atom(value) or
                   (is_tuple(value) and tuple_size(value) == 2 and is_atom(elem(value, 0)))

  @doc """
  Creates a request stub with the given `name` and `plug`.

  Req allows running requests against _plugs_ (instead of over the network) using the
  [`:plug`](`Req.Steps.run_plug/1`) option. However, passing the `:plug` value throughout the
  system can be cumbersome. Instead, you can tell Req to find plugs by `name` by setting
  `plug: {Req.Test, name}`, and register plug stubs for that `name` by calling
  `Req.Test.stub(name, plug)`. In other words, multiple concurrent tests can register test stubs
  under the same `name`, and when Req makes the request, it will find the appropriate
  implementation, even when invoked from different processes than the test process.

  The `name` can be any term.

  The `plug` can be one of:

    * A _function_ plug: a `fun(conn)` or `fun(conn, options)` function that takes a
      `Plug.Conn` and returns a `Plug.Conn`.

    * A _module_ plug: a `module` name or a `{module, options}` tuple.

  ## Examples

      iex> Req.Test.stub(MyStub, fn conn ->
      ...>   send(self(), :req_happened)
      ...>   Req.Test.json(conn, %{})
      ...> end)
      :ok
      iex> Req.get!(plug: {Req.Test, MyStub}).body
      %{}
      iex> receive do
      ...>   :req_happened -> :ok
      ...> end
      :ok

  """
  @doc type: :mock
  @spec stub(name(), plug()) :: :ok
  def stub(name, plug) when is_plug(plug) do
    {:ok, :ok} =
      Req.Test.Ownership.get_and_update(@ownership, self(), name, fn map_or_nil ->
        {:ok, put_in(map_or_nil || %{}, [:stub], plug)}
      end)

    :ok
  end

  @doc """
  Creates a request expectation with the given `name` and `plug`, expected to be fetched at
  most `n` times, **in order**.

  This function allows you to expect a `n` number of request and handle them **in order** via the
  given `plug`. It is safe to use in concurrent tests. If you fetch the value under `name` more
  than `n` times, this function raises a `RuntimeError`.

  The `name` can be any term.

  The `plug` can be one of:

    * A _function_ plug: a `fun(conn)` or `fun(conn, options)` function that takes a
      `Plug.Conn` and returns a `Plug.Conn`.

    * A _module_ plug: a `module` name or a `{module, options}` tuple.

  See `stub/2` and module documentation for more information.

  `verify_on_exit!/1` can be used to ensure that all defined expectations have been called.

  ## Examples

  Let's simulate a server that is having issues: on the first request it is not responding
  and on the following two requests it returns an HTTP 500. Only on the third request it returns
  an HTTP 200. Req by default automatically retries transient errors (using `Req.Steps.retry/1`)
  so it will make multiple requests exercising all of our request expectations:

      iex> Req.Test.expect(MyStub, &Req.Test.transport_error(&1, :econnrefused))
      iex> Req.Test.expect(MyStub, 2, &Plug.Conn.send_resp(&1, 500, "internal server error"))
      iex> Req.Test.expect(MyStub, &Plug.Conn.send_resp(&1, 200, "ok"))
      iex> Req.get!(plug: {Req.Test, MyStub}).body
      # 15:57:06.309 [warning] retry: got exception, will retry in 1000ms, 3 attempts left
      # 15:57:06.309 [warning] ** (Req.TransportError) connection refused
      # 15:57:07.310 [warning] retry: got response with status 500, will retry in 2000ms, 2 attempts left
      # 15:57:09.311 [warning] retry: got response with status 500, will retry in 4000ms, 1 attempt left
      "ok"

      iex> Req.request!(plug: {Req.Test, MyStub})
      ** (RuntimeError) no mock or stub for MyStub

  """
  @doc since: "0.4.15"
  @doc type: :mock
  @spec expect(name(), pos_integer(), plug()) :: name()
  def expect(name, n \\ 1, plug) when is_integer(n) and n > 0 do
    plugs = List.duplicate(plug, n)

    {:ok, :ok} =
      Req.Test.Ownership.get_and_update(@ownership, self(), name, fn map_or_nil ->
        {:ok, Map.update(map_or_nil || %{}, :expectations, plugs, &(&1 ++ plugs))}
      end)

    name
  end

  @doc """
  Allows `pid_to_allow` to access `name` provided that `owner` is already allowed.
  """
  @doc type: :mock
  @spec allow(name(), pid(), pid() | (-> pid())) :: :ok | {:error, Exception.t()}
  def allow(name, owner, pid_to_allow) when is_pid(owner) do
    Req.Test.Ownership.allow(@ownership, owner, pid_to_allow, name)
  end

  @doc """
  Sets the `Req.Test` mode to "global", meaning that the stubs are shared across all tests
  and cannot be used concurrently.
  """
  @doc since: "0.5.0"
  @doc type: :mock
  @spec set_req_test_to_shared(ex_unit_context :: term()) :: :ok
  def set_req_test_to_shared(_context \\ %{}) do
    Req.Test.Ownership.set_mode_to_shared(@ownership, self())
  end

  @doc """
  Sets the `Req.Test` mode to "private", meaning that stubs can be shared across
  tests concurrently.
  """
  @doc type: :mock
  @doc since: "0.5.0"
  @spec set_req_test_to_private(ex_unit_context :: term()) :: :ok
  def set_req_test_to_private(_context \\ %{}) do
    Req.Test.Ownership.set_mode_to_private(@ownership)
  end

  @doc """
  Sets the `Req.Test` mode based on the given `ExUnit` context.

  This works as a ExUnit callback:

      setup :set_req_test_from_context

  """
  @doc since: "0.5.0"
  @doc type: :mock
  @spec set_req_test_from_context(ex_unit_context :: term()) :: :ok
  def set_req_test_from_context(_context \\ %{})

  def set_req_test_from_context(%{async: true} = context), do: set_req_test_to_private(context)
  def set_req_test_from_context(context), do: set_req_test_to_shared(context)

  @doc """
  Sets a ExUnit callback to verify the expectations on exit.

  Similar to calling `verify!/0` at the end of your test.

  This works as a ExUnit callback:

      setup {Req.Test, :verify_on_exit!}

  """
  @doc since: "0.5.0"
  @doc type: :mock
  @spec verify_on_exit!(term()) :: :ok
  def verify_on_exit!(_context \\ %{}) do
    pid = self()
    Req.Test.Ownership.set_owner_to_manual_cleanup(@ownership, pid)

    ExUnit.Callbacks.on_exit(Req.Test, fn ->
      verify(pid, :all)
      Req.Test.Ownership.cleanup_owner(@ownership, pid)
    end)
  end

  @doc """
  Verifies that all the plugs expected to be executed within any scope have been executed.
  """
  @doc since: "0.5.0"
  @doc type: :mock
  @spec verify!() :: :ok
  def verify! do
    verify(self(), :all)
  end

  @doc """
  Verifies that all the plugs expected to be executed within the scope of `name` have been
  executed.
  """
  @doc type: :mock
  @doc since: "0.5.0"
  @spec verify!(name()) :: :ok
  def verify!(name) do
    verify(self(), name)
  end

  defp verify(owner_pid, mock_or_all) do
    messages =
      for {name, stubs_and_expecs} <-
            Req.Test.Ownership.get_owned(@ownership, owner_pid, _default = %{}, 5000),
          name == mock_or_all or mock_or_all == :all,
          pending_count = stubs_and_expecs |> Map.get(:expectations, []) |> length(),
          pending_count > 0 do
        "  * expected #{inspect(name)} to be still used #{pending_count} more times"
      end

    if messages != [] do
      raise "error while verifying Req.Test expectations for #{inspect(owner_pid)}:\n\n" <>
              Enum.join(messages, "\n")
    end

    :ok
  end

  ## Helpers

  defp callers do
    [self() | Process.get(:"$callers") || []]
  end

  ## Plug callbacks

  if Code.ensure_loaded?(Plug) do
    @behaviour Plug
  end

  @doc false
  def init(name) do
    name
  end

  @doc false
  def call(conn, name) do
    case __fetch_plug__(name) do
      fun when is_function(fun, 1) ->
        fun.(conn)

      fun when is_function(fun, 2) ->
        fun.(conn, [])

      module when is_atom(module) ->
        module.call(conn, module.init([]))

      {module, options} when is_atom(module) ->
        module.call(conn, module.init(options))

      other ->
        raise """
        expected plug to be one of:

          * fun(conn)
          * fun(conn, options)
          * module
          * {module, options}

        got: #{inspect(other)}\
        """
    end
  end

  @doc """
  Reads the raw request body from a plug request.

  ## Examples

      iex> echo = fn conn ->
      ...>   body = Req.Test.raw_body(conn)
      ...>   Plug.Conn.send_resp(conn, 200, body)
      ...> end
      iex>
      iex> resp = Req.post!(plug: echo, json: %{hello: "world"})
      iex> resp.body
      "{\\"hello\\":\\"world\\"}"

  """
  if Code.ensure_loaded?(Plug.Test) do
    @spec raw_body(Plug.Conn.t()) :: iodata()
    def raw_body(%Plug.Conn{} = conn) do
      {Req.Test.Adapter, %{raw_body: raw_body}} = conn.adapter
      raw_body
    end
  else
    def raw_body(_conn) do
      require Logger

      Logger.error("""
      Could not find plug dependency.

      Please add :plug to your dependencies:

          {:plug, "~> 1.0"}
      """)

      raise "missing plug dependency"
    end
  end
end
