defmodule Temp do
  @type options :: nil | Path.t | map

  @doc """
  Returns `:ok` when the tracking server used to track temporary files started properly.
  """

  @pdict_key :"$__temp_tracker__"

  @spec track :: Agent.on_start
  def track() do
    case Process.get(@pdict_key) do
      nil ->
        start_tracker()
      v ->
        {:ok, v}
    end
  end

  defp start_tracker() do
    case GenServer.start_link(Temp.Tracker, nil, []) do
      {:ok, pid} ->
        Process.put(@pdict_key, pid)
        {:ok, pid}
      err ->
        err
    end
  end

  @doc """
  Same as `track/0`, but raises an exception on failure. Otherwise, returns `:ok`
  """
  @spec track! :: pid | no_return
  def track!() do
    case track() do
      {:ok, pid} -> pid
      {:error, err} -> raise Temp.Error, message: err
    end
  end

  @doc """
  Return the paths currently tracked.
  """
  @spec tracked :: Set.t
  def tracked(tracker \\ get_tracker!()) do
    GenServer.call(tracker, :tracked)
  end


  @doc """
  Cleans up the temporary files tracked.
  """
  @spec cleanup(pid, Keyword.t) :: [Path.t]
  def cleanup(tracker \\ get_tracker!(), opts \\ []) do
    GenServer.call(tracker, :cleanup, opts[:timeout] || :infinity)
  end

  @doc """
  Returns a `{:ok, path}` where `path` is a path that can be used freely in the
  system temporary directory, or `{:error, reason}` if it fails to get the
  system temporary directory.

  This path is not tracked, so any file created will need manually removing, or
  use `track_file/1` to have it removed automatically.

  ## Options

  The following options can be used to customize the generated path

    * `:prefix` - prepends the given prefix to the path

    * `:suffix` - appends the given suffix to the path,
      this is useful to generate a file with a particular extension

    * `:basedir` - places the generated file in the designated base directory
      instead of the system temporary directory
  """
  @spec path(options) :: {:ok, Path.t} | {:error, String.t}
  def path(options \\ nil) do
    case generate_name(options, "f") do
      {:ok, path, _} -> {:ok, path}
      err -> err
    end
  end

  @doc """
  Same as `path/1`, but raises an exception on failure. Otherwise, returns a temporary path.
  """
  @spec path!(options) :: Path.t | no_return
  def path!(options \\ nil) do
    case path(options) do
      {:ok, path} -> path
      {:error, err} -> raise Temp.Error, message: err
    end
  end

  @doc """
  Returns `{:ok, fd, file_path}` if no callback is passed, or `{:ok, file_path}`
  if callback is passed, where `fd` is the file descriptor of a temporary file
  and `file_path` is the path of the temporary file.
  When no callback is passed, the file descriptor should be closed.
  Returns `{:error, reason}` if a failure occurs.

  The resulting file is automatically tracked if tracking is enabled.

  ## Options

  See `path/1`.
  """
  @spec open(options, nil | (File.io_device -> any)) :: {:ok, Path.t} | {:ok, File.io_device, Path.t} | {:error, any}
  def open(options \\ nil, func \\ nil) do
    case generate_name(options, "f") do
      {:ok, path, options} ->
        options = Map.put(options, :mode, options[:mode] || [:read, :write])
        ret = if func do
          File.open(path, options[:mode], func)
        else
          File.open(path, options[:mode])
        end
        case ret do
          {:ok, res} ->
            if tracker = get_tracker(), do: register_path(tracker, path)
            if func, do: {:ok, path}, else: {:ok, res, path}
          err -> err
        end
      err -> err
    end
  end

  @doc """
  Add a file to the tracker, so that it will be removed automatically or on Temp.cleanup.
  """
  @spec track_file(any) :: {:error, :tracker_not_found} | {:ok, Path.t}
  def track_file(path, tracker \\ get_tracker()) do
    case is_nil(tracker) do
      true -> {:error, :tracker_not_found}
      false -> {:ok, register_path(tracker, path)}
    end
  end

  @doc """
  Same as `open/1`, but raises an exception on failure.
  """
  @spec open!(options, nil | (File.io_device -> any)) :: Path.t | {File.io_device, Path.t} | no_return
  def open!(options \\ nil, func \\ nil) do
    case open(options, func) do
      {:ok, res, path} -> {res, path}
      {:ok, path} -> path
      {:error, err} -> raise Temp.Error, message: err
    end
  end


  @doc """
  Returns `{:ok, dir_path}` where `dir_path` is the path is the path of the
  created temporary directory.
  Returns `{:error, reason}` if a failure occurs.

  The directory is automatically tracked if tracking is enabled.

  ## Options

  See `path/1`.
  """
  @spec mkdir(options) :: {:ok, Path.t} | {:error, any}
  def mkdir(options \\ %{}) do
    case generate_name(options, "d") do
      {:ok, path, _} ->
        case File.mkdir path do
          :ok ->
            if tracker = get_tracker(), do: register_path(tracker, path)
            {:ok, path}
          err -> err
        end
      err -> err
    end
  end

  @doc """
  Same as `mkdir/1`, but raises an exception on failure. Otherwise, returns
  a temporary directory path.
  """
  @spec mkdir!(options) :: Path.t | no_return
  def mkdir!(options \\ %{}) do
    case mkdir(options) do
      {:ok, path} ->
        if tracker = get_tracker(), do: register_path(tracker, path)
        path
      {:error, err} -> raise Temp.Error, message: err
    end
  end

  @spec generate_name(options, Path.t) :: {:ok, Path.t, map | Keyword.t} | {:error, String.t}
  defp generate_name(options, default_prefix)
  defp generate_name(options, default_prefix) when is_list(options) do
    generate_name(Enum.into(options,%{}), default_prefix)
  end
  defp generate_name(options, default_prefix) do
    case prefix(options) do
      {:ok, path} ->
        affixes = parse_affixes(options, default_prefix)
        parts = [timestamp(), "-", :os.getpid(), "-", random_string()]
        parts =
          if affixes[:prefix] do
            [affixes[:prefix], "-"] ++ parts
          else
            parts
          end
        parts = add_suffix(parts, affixes[:suffix])
        name = Path.join(path, Enum.join(parts))
        {:ok, name, affixes}
      err -> err
    end
  end

  defp add_suffix(parts, suffix)
  defp add_suffix(parts, nil), do: parts
  defp add_suffix(parts, ("." <> _suffix) = suffix), do: parts ++ [suffix]
  defp add_suffix(parts, suffix), do: parts ++ ["-", suffix]

  defp prefix(%{basedir: dir}), do: {:ok, dir}
  defp prefix(_) do
    case System.tmp_dir do
      nil -> {:error, "no tmp_dir readable"}
      path -> {:ok, path}
    end
  end

  defp parse_affixes(nil, default_prefix), do: %{prefix: default_prefix}
  defp parse_affixes(affixes, _) when is_bitstring(affixes), do: %{prefix: affixes, suffix: nil}
  defp parse_affixes(affixes, default_prefix) when is_map(affixes) do
    affixes
    |> Map.put(:prefix, affixes[:prefix] || default_prefix)
    |> Map.put(:suffix, affixes[:suffix] || nil)
  end
  defp parse_affixes(_, default_prefix) do
    %{prefix: default_prefix, suffix: nil}
  end

  defp get_tracker do
    Process.get(@pdict_key)
  end

  defp get_tracker!() do
    case get_tracker() do
      nil ->
        raise Temp.Error, message: "temp tracker not started"
      pid ->
        pid
    end
  end

  defp register_path(tracker, path) do
    GenServer.call(tracker, {:add, path})
  end

  defp timestamp do
    {ms, s, _} = :os.timestamp
    Integer.to_string(ms * 1_000_000 + s)
  end

  defp random_string do
    Integer.to_string(rand_uniform(0x100000000), 36) |> String.downcase
  end

  if :erlang.system_info(:otp_release) >= ~c"18" do
    defp rand_uniform(num) do
      :rand.uniform(num)
    end
  else
    defp rand_uniform(num) do
      :random.uniform(num)
    end
  end
end
