defmodule Mix.Local.Installer do
  @moduledoc """
  This module implements pieces of functionality shared by the archive- and escript-related
  tasks.
  """

  @doc """
  Checks that the argument given to install is supported by the respective module.
  """
  @callback check_path_or_url(String.t) :: :ok | {:error, String.t}

  @doc """
  Returns a list of already installed version of the same archive or escript.
  """
  @callback find_previous_versions(String.t, Path.t) :: [Path.t]

  @doc """
  The installation itself.
  """
  @callback install(dest :: Path.t, contents :: binary, previous :: [Path.t]) :: :ok

  @doc """
  Common implementation of installation for archives and escripts.

  Relies on a few callbacks provided by respective callback modules
  for customizing certain steps in the installation process.
  """
  @spec install({module, atom}, OptionParser.argv, Keyword.t) :: boolean
  def install({module, name}, argv, switches) do
    {opts, args} = OptionParser.parse!(argv, strict: switches)

    case args do
      [src] ->
        with :ok <- check_argument(src), :ok <- module.check_path_or_url(src) do
          do_install({module, name}, src, opts)
        else
          {:error, message} -> Mix.raise message <> "\n" <> usage(name)
        end

      [] ->
        src = Mix.Local.name_for(name, Mix.Project.config)
        if File.exists?(src) do
          do_install({module, name}, src, opts)
        else
          Mix.raise "Expected an #{name} to exist in the current directory " <>
                    "or an argument to be given.\n#{usage(name)}"
        end

      _ ->
        Mix.raise "Unexpected arguments.\n#{usage(name)}"
    end
  end

  defp check_argument(arg) do
    if local_path?(arg) or file_url?(arg) do
      :ok
    else
      {:error, "Expected a local file path or a file URL."}
    end
  end

  defp local_path?(url_or_path) do
    File.regular?(url_or_path)
  end

  defp file_url?(url_or_path) do
    URI.parse(url_or_path).scheme in ["http", "https"]
  end

  defp usage(name), do: "Usage: mix #{name}.install <path or url>"

  defp do_install({module, name}, src, opts) do
    src_basename = Path.basename(URI.parse(src).path)
    dst = Path.join(Mix.Local.path_for(name), src_basename)
    previous_files = module.find_previous_versions(src, dst)

    if opts[:force] || should_install?(name, src, previous_files) do
      case Mix.Utils.read_path(src, opts) do
        {:ok, binary} ->
          module.install(dst, binary, previous_files)

        :badpath ->
          Mix.raise "Expected #{inspect src} to be a URL or a local file path"

        {:local, message} ->
          Mix.raise message

        {kind, message} when kind in [:remote, :checksum] ->
          Mix.raise """
          #{message}

          Could not fetch #{name} at:

              #{src}

          Please download the #{name} above manually to your current directory and run:

              mix #{name}.install ./#{src_basename}
          """
      end

      true
    else
      false
    end
  end

  defp should_install?(name, src, previous_files) do
    message = case previous_files do
      [] ->
        "Are you sure you want to install #{name} #{inspect src}?"
      [file] ->
        "Found existing #{name}: #{file}.\n" <>
        "Are you sure you want to replace it with #{inspect src}?"
      files ->
        "Found existing #{name}s: #{Enum.map_join(files, ", ", &Path.basename/1)}.\n" <>
        "Are you sure you want to replace them with #{inspect src}?"
    end
    Mix.shell.yes?(message)
  end

  @doc """
  Prints a list of items in a uniform way. Used for printing the list of installed archives and
  escripts.
  """
  @spec print_list(atom, [String.t]) :: :ok
  def print_list(type, []) do
    Mix.shell.info "No #{type}s currently installed."
  end

  def print_list(type, items) do
    Enum.each items, fn item -> Mix.shell.info ["* ", item] end
    item_name = String.capitalize("#{type}")
    Mix.shell.info "#{item_name}s installed at: #{Mix.Local.path_for(type)}"
  end

  @doc """
  A common implementation for uninstalling archives and scripts.
  """
  @spec uninstall(atom, OptionParser.argv) :: boolean
  def uninstall(type, argv) do
    {_, argv, _} = OptionParser.parse(argv)

    item_name = "#{type}"
    item_plural = "#{type}s"
    root = Mix.Local.path_for(type)

    if name = List.first(argv) do
      path = Path.join(root, name)
      cond do
        not File.exists?(path) ->
          Mix.shell.error "Could not find a local #{item_name} named #{inspect name}. "<>
                          "Existing #{item_plural} are:"
          Mix.Task.run item_name
          nil
        should_uninstall?(path, item_name) ->
          File.rm_rf!(path)
          path
        true ->
          nil
      end
    else
      Mix.raise "No #{item_name} was given to #{item_name}.uninstall"
    end
  end

  defp should_uninstall?(path, item_name) do
    Mix.shell.yes?("Are you sure you want to uninstall #{item_name} #{path}?")
  end
end
