defmodule Frugality do
  alias Frugality.Core.EntityTagRange
  alias Frugality.Core.Metadata
  alias Frugality.Core.Utils
  alias Frugality.Encoder
  alias Frugality.Generator
  alias Plug.Conn
  @headers [
    "if-match",
    "if-unmodified-since",
    "if-none-match",
    "if-modified-since"
  ]
  defguardp is_safe(method) when method in ["GET", "HEAD"]
  @doc """
  Derives metadata and puts it in the response headers.
  ## Examples
      iex> conn = put_metadata(conn, order: order)
      iex> Plug.Conn.get_resp_header(conn, "etag")
      "W/\\"asd123\\""
  """
  @spec put_metadata(Conn.t(), map() | list()) :: Conn.t() | no_return()
  def put_metadata(conn, data) do
    conn
    |> derive_metadata(data)
    |> then(&Utils.put_metadata(conn, &1))
  end
  @doc """
  Stores the [metadata generator](`Frugality.Generator`) in the
  connection.
  ## Examples
  Can be invoked as a function:
      # Let's imagine you have an HTTP document with orders
      iex> put_generator(conn, OrderMetadata)
  or used as a plug:
      plug :put_generator, OrderMetadata
  """
  @spec put_generator(Conn.t(), Generator.t()) :: Conn.t()
  def put_generator(conn, generator) when is_atom(generator) do
    Conn.put_private(conn, :frugality_generator, generator)
  end
  @doc """
  Stores the [entity-tag encoder](`Frugality.Encoder`) in the
  connection.
  ## Examples
  Can be invoked as a function:
      iex> put_encoder(conn, Frugality.Encoder.MD5)
  or used as a plug:
      plug :put_encoder, Frugality.Encoder.MD5
  """
  @spec put_encoder(Conn.t(), Encoder.t()) :: Conn.t()
  def put_encoder(conn, encoder) when is_atom(encoder) do
    Conn.put_private(conn, :frugality_encoder, encoder)
  end
  @doc """
  Short-circuits the connection when the preconditions are satisfied.
  To short-circuit the connection means to send it and halt the Plug
  pipeline.
  ## Examples
      iex> short_circuit(conn, order: order)
  """
  @spec short_circuit(Conn.t(), map() | list(), (Conn.t() -> Conn.t())) ::
          Conn.t()
  def short_circuit(conn, data, cb \\ fn conn -> conn end) do
    case evaluate_preconditions(conn, data) do
      {:ok, conn} ->
        cb.(conn)
      {:error, conn} ->
        conn
        |> Conn.send_resp()
        |> Conn.halt()
    end
  end
  @doc """
  Evaluates the request preconditions against user-provided data.
  Returns `{:ok, t:Plug.Conn.t/0}` if the preconditions are satisfied
  and the action execution can continue, or `{:error,
  t:Plug.Conn.t/0}` otherwise.
  ## Examples
      iex> evaluate_preconditions(conn, etag: ~s(W/"asd123"))
      {:ok, %Plug.Conn{...}}
      iex> evaluate_preconditions(conn, order: order)
      {:error, %Plug.Conn{status: 412, ...}}
  """
  @spec evaluate_preconditions(Conn.t(), map() | list()) :: {:ok | :error, Conn.t()}
  def evaluate_preconditions(%Conn{method: method} = conn, data)
      when is_map(data) or is_list(data) do
    metadata = derive_metadata(conn, data)
    conn =
      if is_safe(method) do
        Utils.put_metadata(conn, metadata)
      else
        conn
      end
    @headers
    |> Stream.map(&pair_with_values(conn, &1))
    |> Stream.reject(&empty_values?/1)
    |> Stream.transform([], &drop_by_precedence/2)
    |> Stream.map(&parse_values/1)
    |> Stream.map(&evaluate(&1, metadata))
    |> Enum.reduce_while({:ok, conn}, &reducer/2)
  end
  defp to_map(data) when is_map(data), do: data
  defp to_map(data) when is_list(data), do: :maps.from_list(data)
  defp pair_with_values(conn, header) do
    {header, Conn.get_req_header(conn, header)}
  end
  defp empty_values?({_, []}), do: true
  defp empty_values?({_, _}), do: false
  defp drop_by_precedence({"if-unmodified-since", _}, [_] = acc), do: {[], acc}
  defp drop_by_precedence({"if-modified-since", _}, [{"if-none-match", _} | _] = acc),
    do: {[], acc}
  defp drop_by_precedence(precond, acc), do: {[precond], [precond | acc]}
  defp reducer({"if-none-match", false}, {_, %{method: method} = conn}) when is_safe(method),
    do: {:halt, {:error, Utils.not_modified(conn)}}
  defp reducer({"if-modified-since", false}, {_, %{method: method} = conn}) when is_safe(method),
    do: {:halt, {:error, Utils.not_modified(conn)}}
  defp reducer({"if-modified-since", _}, acc), do: {:cont, acc}
  defp reducer({_, false}, {_, conn}), do: {:halt, {:error, Utils.precondition_failed(conn)}}
  defp reducer(_, acc), do: {:cont, acc}
  defp parse_values({header, values})
       when header in ["if-none-match", "if-match"] do
    {header, Utils.parse_entity_tag_ranges(values)}
  end
  defp parse_values({header, values})
       when header in ["if-modified-since", "if-unmodified-since"] do
    datetime =
      values
      |> Enum.map(&Utils.parse_httpdate/1)
      |> Enum.max_by(fn d -> d end, DateTime)
    {header, datetime}
  end
  defp evaluate({"if-match" = h, _}, %Metadata{etag: nil}), do: {h, false}
  defp evaluate({"if-match" = h, range}, %Metadata{etag: etag}),
    do: {h, EntityTagRange.matches_strong?(range, etag)}
  defp evaluate({"if-none-match" = h, _}, %Metadata{etag: nil}), do: {h, true}
  defp evaluate({"if-none-match" = h, range}, %Metadata{etag: etag}) do
    {h, !EntityTagRange.matches_weak?(range, etag)}
  end
  defp evaluate({"if-unmodified-since" = h, _}, %Metadata{last_modified: nil}), do: {h, false}
  defp evaluate({"if-unmodified-since" = h, iums}, %Metadata{last_modified: lm}),
    do: {h, DateTime.compare(lm, iums) in [:lt, :eq]}
  defp evaluate({"if-modified-since" = h, _}, %Metadata{last_modified: nil}), do: {h, false}
  defp evaluate({"if-modified-since" = h, ims}, %Metadata{last_modified: lm}),
    do: {h, DateTime.compare(lm, ims) == :gt}
  defp generator(conn) do
    Map.get(conn.private, :frugality_generator)
  end
  defp encoder(conn) do
    Map.get(conn.private, :frugality_encoder)
  end
  defp derive_metadata(conn, data) do
    generator = generator(conn) || raise "no generator is set"
    encoder = encoder(conn)
    data =
      data
      |> to_map()
      |> Map.put(:conn, conn)
    Generator.generate(
      generator,
      data,
      encoder: encoder
    )
  end
end