defmodule ExAws.Operation.S3 do
  @moduledoc """
  Holds data necessary for an operation on the S3 service.
  """

  defstruct stream_builder: nil,
            parser: &Function.identity/1,
            bucket: "",
            path: "/",
            http_method: nil,
            body: "",
            resource: "",
            params: %{},
            headers: %{},
            service: :s3

  @type t :: %__MODULE__{}

  defimpl ExAws.Operation do
    def perform(operation, config) do
      body = operation.body
      headers = operation.headers
      http_method = operation.http_method

      {operation, config} = add_bucket_to_path(operation, config)

      url =
        operation
        |> add_resource_to_params()
        |> ExAws.Request.Url.build(config)

      hashed_payload = ExAws.Auth.Utils.hash_sha256(body)

      headers =
        headers
        |> Map.put("x-amz-content-sha256", hashed_payload)
        |> put_content_length_header(body, http_method)
        |> Map.to_list()

      ExAws.Request.request(http_method, url, body, headers, config, operation.service)
      |> ExAws.Request.default_aws_error()
      |> operation.parser.()
    end

    def stream!(%{stream_builder: fun}, config), do: fun.(config)

    defp put_content_length_header(headers, "", :get), do: headers

    defp put_content_length_header(headers, "", :head), do: headers

    defp put_content_length_header(headers, "", :delete), do: headers

    defp put_content_length_header(headers, body, _) do
      Map.put(headers, "content-length", IO.iodata_length(body) |> Integer.to_string())
    end

    @spec add_bucket_to_path(operation :: ExAws.Operation.S3.t(), config :: map) ::
            {operation :: ExAws.Operation.S3.t(), config :: map}
    def add_bucket_to_path(operation, config \\ %{})

    def add_bucket_to_path(%{bucket: nil}, _config) do
      raise "#{__MODULE__}.perform/2 cannot perform operation on `nil` bucket"
    end

    def add_bucket_to_path(operation, %{virtual_host: true, bucket_as_host: true} = config) do
      # When bucket_as_host is true, use the bucket name as the full hostname
      {put_in(operation.path, ensure_absolute(operation.path)),
       Map.put(config, :host, operation.bucket)}
    end

    def add_bucket_to_path(operation, %{virtual_host: true, host: base_host} = config) do
      vhost_domain = "#{operation.bucket}.#{base_host}"

      {put_in(operation.path, ensure_absolute(operation.path)),
       Map.put(config, :host, vhost_domain)}
    end

    def add_bucket_to_path(operation, config) do
      path = "/#{operation.bucket}#{ensure_absolute(operation.path)}" |> expand_dot()
      {operation |> Map.put(:path, path), config}
    end

    @spec add_resource_to_params(operation :: ExAws.Operation.S3.t()) :: ExAws.Operation.S3.t()
    def add_resource_to_params(operation) do
      params = operation.params |> Map.new() |> Map.put(operation.resource, 1)
      operation |> Map.put(:params, params)
    end

    defp ensure_absolute(<<"/", _rest::binary>> = path), do: path
    defp ensure_absolute(path), do: "/#{path}"

    # A subset of Elixir's built-in Path.expand/1 - because it's OS-specific
    # we can't use it to normalise paths with "." or ".." in them (otherwise
    # it breaks on Windows because the /'s become \'s).
    defp expand_dot(<<"/", rest::binary>>), do: "/" <> do_expand_dot(rest)
    defp expand_dot(path), do: do_expand_dot(path)

    defp do_expand_dot(path), do: do_expand_dot(:binary.split(path, "/", [:global]), [])
    defp do_expand_dot([".." | t], [_, _ | acc]), do: do_expand_dot(t, acc)
    defp do_expand_dot([".." | t], []), do: do_expand_dot(t, [])
    defp do_expand_dot(["." | t], acc), do: do_expand_dot(t, acc)
    defp do_expand_dot([h | t], acc), do: do_expand_dot(t, ["/", h | acc])
    defp do_expand_dot([], []), do: ""
    defp do_expand_dot([], ["/" | acc]), do: IO.iodata_to_binary(:lists.reverse(acc))
  end
end
