defmodule Mix.Tasks.Hex.Info do
  use Mix.Task

  @shortdoc "Prints Hex information"

  @moduledoc """
  Prints Hex package or system information.

      $ mix hex.info [PACKAGE [VERSION]]

  If `package` is not given, print system information. This includes when
  registry was last updated and current system version.

  If `package` is given, print information about the package. This includes all
  released versions and package metadata.

  If `package` and `version` is given, print release information.

  ## Command line options

    * `--organization ORGANIZATION` - Set this for private packages belonging to an organization
  """
  @behaviour Hex.Mix.TaskDescription

  @switches [organization: :string]

  @impl true
  def run(args) do
    Hex.start()
    {opts, args} = OptionParser.parse!(args, strict: @switches)

    case args do
      [] ->
        general()

      [package] ->
        package(opts[:organization], package)

      [package, version] ->
        release(opts[:organization], package, version)

      _ ->
        Mix.raise("""
        Invalid arguments, expected:

        mix hex.info [PACKAGE [VERSION]]
        """)
    end
  end

  @impl true
  def tasks() do
    [
      {"", "Prints Hex information"},
      {"PACKAGE [VERSION]", "Prints package information"}
    ]
  end

  defp general() do
    Hex.Shell.info("Hex:    #{Hex.version()}")
    Hex.Shell.info("Elixir: #{System.version()}")
    Hex.Shell.info("OTP:    #{Hex.Utils.otp_version()}")
    Hex.Shell.info("")
    Hex.Shell.info("Built with: Elixir #{Hex.elixir_version()} and OTP #{Hex.otp_version()}")

    Hex.Registry.Server.open()
    Hex.UpdateChecker.check()
    Hex.Registry.Server.close()
  end

  defp package(_organization, "") do
    Hex.Shell.error("Package name is empty")
    Mix.Tasks.Hex.set_exit_code(1)
  end

  defp package(organization, package) do
    auth = organization && Mix.Tasks.Hex.auth_info(:read)

    case Hex.API.Package.get(organization, package, auth) do
      {:ok, {code, _, body}} when code in 200..299 ->
        print_package(body, locked_dep(package))

      {:ok, {404, _, _}} ->
        Hex.Shell.error("No package with name #{package}")
        Mix.Tasks.Hex.set_exit_code(1)

      other ->
        Hex.Shell.error("Failed to retrieve package information")
        Hex.Utils.print_error_result(other)
        Mix.Tasks.Hex.set_exit_code(1)
    end
  end

  defp release(organization, package, version) do
    auth = organization && Mix.Tasks.Hex.auth_info(:read)

    case Hex.API.Release.get(organization, package, version, auth) do
      {:ok, {code, _, body}} when code in 200..299 ->
        print_release(organization, package, body)

      {:ok, {404, _, _}} ->
        Hex.Shell.error("No release with name #{package} #{version}")
        Mix.Tasks.Hex.set_exit_code(1)

      other ->
        Hex.Shell.error("Failed to retrieve release information")
        Hex.Utils.print_error_result(other)
        Mix.Tasks.Hex.set_exit_code(1)
    end
  end

  defp print_package(package, locked_package) do
    meta = package["meta"]
    desc = meta["description"] || "No description provided"
    Hex.Shell.info(desc <> "\n")
    releases = package["releases"] || []
    retirements = package["retirements"] || %{}
    Hex.Shell.info("Config: " <> package["configs"]["mix.exs"])
    print_locked_package(locked_package)

    Hex.Shell.info(["\nRecent releases:\n" | format_releases(releases, Map.keys(retirements))])

    print_downloads(package["downloads"])
    print_meta(meta)
  end

  defp format_releases(releases, retirements) do
    {releases, rest} = Enum.split(releases, 8)

    releases
    |> Enum.map(&["  ", format_version(&1, retirements), "\n"])
    |> add_ellipsis(rest)
  end

  defp format_version(%{"version" => version} = release, retirements) do
    date = format_release_date(release["inserted_at"])

    if version in retirements do
      [:yellow, version, date, " (retired)", :reset]
    else
      [version, date]
    end
  end

  defp format_release_date(nil), do: ""

  defp format_release_date(date_string) do
    case parse_date(date_string) do
      {:ok, date} -> " (#{date})"
      _ -> ""
    end
  end

  defp add_ellipsis(output, []), do: output
  defp add_ellipsis(output, _rest), do: output ++ ["  ...\n"]

  defp print_downloads(nil), do: :ok

  defp print_downloads(downloads) do
    parts =
      Enum.reject(
        [
          format_download_count("Yesterday", downloads["day"]),
          format_download_count("Last 7 days", downloads["week"]),
          format_download_count("All time", downloads["all"])
        ],
        &is_nil/1
      )

    if parts != [] do
      Hex.Shell.info("Downloads:\n  " <> Enum.join(parts, "\n  ") <> "\n")
    end
  end

  defp format_download_count(_label, nil), do: nil
  defp format_download_count(label, count), do: "#{label}: #{add_thousands_separators(count)}"

  defp add_thousands_separators(count, separator \\ " ") do
    count
    |> Integer.to_string()
    |> String.reverse()
    |> String.codepoints()
    |> Enum.chunk_every(3)
    |> Enum.join(separator)
    |> String.reverse()
  end

  defp print_meta(meta) do
    print_list(meta, "licenses")
    print_dict(meta, "links")
  end

  defp print_release(organization, package, release) do
    version = release["version"]

    print_retirement(release)
    Hex.Shell.info("Config: " <> release["configs"]["mix.exs"])
    print_release_published_at(release["inserted_at"])

    if release["has_docs"] do
      Hex.Shell.info("Documentation at: #{Hex.Utils.hexdocs_url(organization, package, version)}")
    end

    print_release_downloads(release["downloads"])

    if requirements = release["requirements"] do
      Hex.Shell.info("Dependencies:")

      Enum.each(requirements, fn {name, req} ->
        app = req["app"]
        app = if app && app != name, do: " (app: #{app})"
        optional = if req["optional"], do: " (optional)"
        Hex.Shell.info("  #{name} #{req["requirement"]}#{app}#{optional}")
      end)
    end

    print_publisher(release)
  end

  defp print_release_downloads(count) when is_integer(count) do
    Hex.Shell.info("Downloads: #{add_thousands_separators(count)}")
  end

  defp print_release_downloads(_), do: :ok

  defp print_release_published_at(nil), do: :ok

  defp print_release_published_at(date_string) do
    case parse_date(date_string) do
      {:ok, date} -> Hex.Shell.info("Released: #{date}")
      _ -> :ok
    end
  end

  defp parse_date(date_string) do
    case DateTime.from_iso8601(date_string) do
      {:ok, datetime, _offset} -> {:ok, DateTime.to_date(datetime)}
      _ -> Date.from_iso8601(date_string)
    end
  end

  defp print_locked_package(nil), do: nil

  defp print_locked_package(locked_package) do
    Hex.Shell.info(["Locked version: #{locked_package.version}"])
  end

  defp print_retirement(%{"retirement" => nil}), do: ""

  defp print_retirement(release) do
    retirement = %{
      reason: release["retirement"]["reason"],
      message: release["retirement"]["message"]
    }

    Hex.Shell.warn([
      [:bright, "This version has been retired"],
      [:normal, ": "],
      [:normal, Hex.Utils.package_retirement_message(retirement)]
    ])
  end

  defp print_publisher(release) do
    publisher_username = release["publisher"]["username"]

    publisher_email = release["publisher"]["email"]
    email_or_empty = if publisher_email, do: " (#{publisher_email})", else: ""

    Hex.Shell.info("Published by: #{publisher_username}#{email_or_empty}")
  end

  defp print_list(meta, name) do
    list = Map.get(meta, name, [])

    if list != [] do
      Hex.Shell.info(String.capitalize(name) <> ": " <> Enum.join(list, ", "))
    end
  end

  defp print_dict(meta, name) do
    title = String.capitalize(name)
    dict = Map.get(meta, name, [])

    if dict != [] do
      Hex.Shell.info(title <> ":")

      Enum.each(dict, fn {key, val} ->
        Hex.Shell.info("  #{key}: #{val}")
      end)
    end
  end

  # Pull out the locked dependency version, if it exists
  defp locked_dep(package_name) do
    Mix.Dep.Lock.read()
    |> Enum.map(fn {_app, info} -> Hex.Utils.lock(info) end)
    |> Enum.find(fn locked -> locked && locked.name == package_name end)
  end
end
