defmodule Hex.RemoteConverger do
  @moduledoc false

  @behaviour Mix.RemoteConverger

  alias Hex.Registry.Server, as: Registry

  def post_converge() do
    Hex.UpdateChecker.check()

    if Hex.State.get(:print_retired_warning) do
      Hex.Shell.error("Found retired packages, see above for details")
      Hex.State.put(:print_retired_warning, false)
    end

    if Hex.State.get(:print_advisory_warning) do
      Hex.Shell.error("Found packages with security advisories, see above for details")
      Hex.State.put(:print_advisory_warning, false)
    end

    if Hex.State.get(:print_sponsored_tip) do
      Hex.Shell.info(
        "You have added/upgraded packages you could sponsor, " <>
          "run `mix hex.sponsor` to learn more"
      )

      Hex.State.put(:print_sponsored_tip, false)
    end

    Registry.persist()
  end

  def remote?(dep) do
    !!dep.opts[:hex]
  end

  def converge(deps, lock) do
    unless Code.ensure_loaded?(Hex.Mix) do
      Hex.Stdlib.ensure_application!(:hex)
    end

    Registry.open()

    {:ok, policy} = Hex.Policy.load()
    Hex.State.put(:active_policy, policy)

    # We cannot use given lock here, because all deps that are being
    # converged have been removed from the lock by Mix
    # We need the old lock to get the children of Hex packages
    old_lock = Mix.Dep.Lock.read()

    overridden = Hex.Mix.overridden_deps(deps)
    requests = Hex.Mix.deps_to_requests(deps)

    prefetches =
      [
        Hex.Mix.packages_from_lock(lock),
        Hex.Mix.packages_from_lock(old_lock),
        packages_from_requests(requests)
      ]
      |> Enum.concat()
      |> verify_prefetches()

    check_and_refresh_auth(prefetches)
    Registry.prefetch(prefetches)

    locked = prepare_locked(lock, old_lock, deps)

    verify_lock(lock)
    verify_deps(deps, Hex.Mix.top_level(deps))
    verify_input(requests, locked)

    Hex.Shell.info("Resolving Hex dependencies...")
    run_solver(lock, old_lock, requests, locked, overridden)
  end

  defp run_solver(lock, old_lock, requests, locked, overridden) do
    start_time = System.monotonic_time(:millisecond)
    dependencies = Enum.map(requests, &request_to_dependency/1)
    setup_cooldown(old_lock, locked)
    locked = Enum.map(locked, &request_to_locked/1)
    overridden = Enum.map(overridden, &Atom.to_string/1)
    verify_otp_app_names(dependencies)

    level = Logger.level()
    Logger.configure(level: if(Hex.State.fetch!(:debug_solver), do: :debug, else: :info))

    solution =
      try do
        Hex.Solver.run(
          Hex.Registry.Policy,
          dependencies,
          locked,
          overridden,
          ansi: Hex.Shell.ansi_enabled?()
        )
      after
        Logger.configure(level: level)
      end

    current_time = System.monotonic_time(:millisecond)
    total_time = Float.round((current_time - start_time) / 1000, 3)
    Hex.Shell.info("Resolution completed in #{total_time}s")

    case solution do
      {:ok, resolved} ->
        resolved = normalize_resolved(resolved)
        print_cooldown_summary()
        print_policy_summary()
        solver_success(resolved, requests, lock, old_lock)

      {:error, message} ->
        Hex.Shell.info(message)

        if note = Hex.Policy.Diagnostics.failure_note(Hex.State.fetch!(:policy_filtered_versions)) do
          Hex.Shell.info("\n" <> note)
        end

        print_cooldown_summary()
        # The failure note above already lists the hidden versions, so the
        # summary here contributes only the active-policy header.
        print_policy_summary(list_filtered: false)
        Mix.raise("Hex dependency resolution failed")
    end
  end

  defp setup_cooldown(old_lock, locked) do
    # The global cutoff covers only the local cooldown configuration. Policy
    # cooldowns are per-repository restrictions enforced in Hex.Policy.Filter,
    # so they are not folded into this cutoff; AND composition across the two
    # filters yields the strictest minimum age per repository.
    local = Hex.State.fetch!(:cooldown)
    cutoff = Hex.Cooldown.build_cutoff(local)
    Hex.State.put(:cooldown_cutoff, cutoff)

    bypass = build_cooldown_bypass(old_lock, locked, cutoff)
    Hex.State.put(:cooldown_bypass_packages, bypass)

    locked_versions = build_cooldown_locked_versions(old_lock)
    Hex.State.put(:cooldown_locked_versions, locked_versions)
    Hex.State.put(:policy_locked_versions, locked_versions)
    Hex.State.put(:cooldown_filtered_versions, [])
    Hex.State.put(:policy_filtered_versions, [])
  end

  @doc false
  def build_cooldown_locked_versions(old_lock) do
    # Maps {repo, package} to the list of versions currently in the lockfile
    # for that package. The cooldown filter treats these as exempt: a version
    # the user is already running is trusted, even if cooldown would otherwise
    # filter it. This lets re-resolution fall back to the locked version when
    # no newer eligible candidate exists, instead of failing.
    for %{repo: repo, name: name, version: version} <- Hex.Mix.from_lock(old_lock),
        into: %{} do
      {{repo || "hexpm", name}, [to_string(version)]}
    end
  end

  @doc false
  def build_cooldown_bypass(_old_lock, _locked, :disabled), do: MapSet.new()

  def build_cooldown_bypass(old_lock, locked, _cutoff) do
    # The bypass set has three sources:
    #
    # 1. Packages in `locked` (the post-`prepare_locked/3` set): the lockfile
    #    entry survived requirement checking and dep unlocking, so this
    #    package is being installed from the lock — no re-resolution, no
    #    cooldown. Matches the design's "cooldown does not apply when
    #    proceeding from the lockfile" promise.
    #
    # 2. Packages in `old_lock` whose locked version is known-unsafe (retired
    #    or carrying a security advisory): the user re-resolving is trying
    #    to escape that version; cooldown must not block the escape. Walks
    #    `old_lock` rather than `locked` because `mix deps.update foo` to
    #    escape an unsafe foo removes foo from `locked`.
    #
    # 3. The currently-locked children of any package in (2): escaping an
    #    unsafe parent typically requires re-resolving its dependency
    #    subtree, and the new parent version usually wants newer versions of
    #    those children. Mirroring `mix deps.update`'s behavior of unlocking
    #    the children alongside the explicit target, we lift cooldown on the
    #    same set.
    lock_satisfied = for %{name: name} <- locked, into: MapSet.new(), do: name
    MapSet.union(lock_satisfied, unsafe_lock_bypass(old_lock))
  end

  @doc """
  Set of package names whose cooldown should be lifted because the lock
  currently points at an unsafe version of that package, plus the names
  of that package's lock-children.
  """
  def unsafe_lock_bypass(lock) do
    Enum.reduce(lock, MapSet.new(), fn {_app, lock_entry}, acc ->
      case Hex.Utils.lock(lock_entry) do
        %{repo: repo, name: name, version: version, deps: deps} ->
          if locked_version_unsafe?(repo || "hexpm", name, to_string(version)) do
            children = for {_app, _req, opts} <- deps || [], do: opts[:hex]
            acc |> MapSet.put(name) |> MapSet.union(MapSet.new(children))
          else
            acc
          end

        nil ->
          acc
      end
    end)
  end

  defp locked_version_unsafe?(repo, name, version) do
    Registry.retired(repo, name, version) != nil or
      Registry.advisories(repo, name, version) not in [nil, []]
  end

  @doc false
  def print_cooldown_summary() do
    cutoff = Hex.State.fetch!(:cooldown_cutoff)
    entries = Hex.State.fetch!(:cooldown_filtered_versions)

    if summary = Hex.Cooldown.format_summary(entries, cutoff) do
      Hex.Shell.info(summary)
    end
  end

  defp request_to_dependency(request) do
    constraint =
      if request.requirement do
        Hex.Solver.parse_constraint!(request.requirement)
      else
        %Hex.Solver.Constraints.Range{}
      end

    %{
      repo: if(request.repo != "hexpm", do: request.repo),
      name: request.name,
      constraint: constraint,
      optional: false,
      label: request.app,
      from: request.from,
      dependencies: Enum.map(request.dependencies, &request_to_dependency/1)
    }
  end

  defp request_to_locked(request) do
    %{
      repo: if(request.repo != "hexpm", do: request.repo),
      name: request.name,
      version: Hex.Solver.parse_constraint!(request.version),
      label: request.app
    }
  end

  def normalize_resolved(resolved) do
    resolved
    |> Enum.map(fn {name, {version, repo}} ->
      {repo || "hexpm", name, to_string(version)}
    end)
    |> Enum.sort()
  end

  defp solver_success(resolved, requests, lock, old_lock) do
    parent_deps =
      Enum.flat_map(requests, fn %{name: package, dependencies: deps} ->
        if deps == [] do
          []
        else
          [package]
        end
      end)

    resolved = Enum.reject(resolved, fn {_repo, package, _version} -> package in parent_deps end)
    resolved = add_apps_to_resolved(resolved, requests)
    print_success(resolved, old_lock)
    verify_resolved(resolved, old_lock)
    new_lock = Hex.Mix.to_lock(resolved)
    Hex.SCM.prefetch(new_lock)

    deps_to_warn =
      for %{repo: repo, name: name, app: app, requirement: requirement, warn_if_outdated: true} <-
            requests do
        {:ok, requirement} = Version.parse_requirement(requirement)
        {:ok, versions} = Registry.versions(repo, name)

        versions =
          versions
          |> Enum.filter(fn vsn -> vsn.pre == [] and Version.match?(vsn, requirement) end)
          |> Enum.sort(&(Version.compare(&1, &2) == :gt))

        with [latest_version | _] <- versions,
             {:hex, _name, version, _checksum1, _managers, _, ^repo, _checksum2} <-
               Map.fetch!(new_lock, String.to_atom(app)),
             :gt <- Version.compare(latest_version, version) do
          {name, latest_version}
        else
          _ ->
            nil
        end
      end
      |> Enum.filter(& &1)

    if deps_to_warn != [] do
      IO.warn(
        [
          "the following deps are outdated and set \"warn_if_outdated: true\":\n\n",
          Enum.map_join(deps_to_warn, "\n", fn {name, version} ->
            " * #{name} #{version} is available"
          end)
        ],
        []
      )
    end

    lock_merge(lock, new_lock)
  end

  defp add_apps_to_resolved(resolved, requests) do
    resolved =
      Enum.map(resolved, fn {repo, package, version} ->
        apps =
          Enum.flat_map(resolved, fn {parent_repo, parent_package, parent_version} ->
            repo = if repo != "hexpm", do: repo

            deps =
              case Registry.dependencies(parent_repo, parent_package, parent_version) do
                {:ok, deps} -> deps
                :error -> []
              end

            app =
              Enum.find_value(deps, fn
                %{repo: ^repo, name: ^package, label: app} -> app
                %{} -> nil
              end)

            if app do
              [{parent_repo, parent_package, app}]
            else
              []
            end
          end)

        root_app = find_root_app_in_requests(requests, repo, package)
        apps = Enum.uniq_by(List.wrap(root_app) ++ apps, fn {_repo, _package, app} -> app end)

        case apps do
          [{_repo, _package, app}] ->
            {repo, package, app, version}

          apps when apps != [] ->
            name = Hex.Utils.package_name(repo, package)

            header =
              "Conflicting OTP application names in dependency definition of " <>
                "\"#{name} {version}\", in the following packages:\n\n"

            list =
              Enum.map_join(apps, "\n", fn {parent_repo, parent_package, app} ->
                name = Hex.Utils.package_name(parent_repo, parent_package)
                "  * #{name} defined application :#{app}"
              end)

            Mix.raise(header <> list)
        end
      end)

    Enum.each(resolved, fn {_repo, _package, app, _version} ->
      conflicting =
        Enum.filter(resolved, fn
          {_repo, _package, ^app, _version} -> true
          {_repo, _package, _app, _version} -> false
        end)

      unless length(conflicting) == 1 do
        header =
          "Multiple packages resolved with the same OTP application name of \"#{app}\":\n\n"

        list =
          Enum.map_join(conflicting, "\n", fn {repo, package, _app, version} ->
            name = Hex.Utils.package_name(repo, package)
            "  * #{name} #{version}"
          end)

        Mix.raise(header <> list)
      end
    end)

    resolved
  end

  defp find_root_app_in_requests(requests, repo, package) do
    Enum.find_value(requests, fn
      %{repo: ^repo, name: ^package, app: app} -> {"hexpm", "myapp", app}
      %{dependencies: dependencies} -> find_root_app_in_requests(dependencies, repo, package)
      %{} -> nil
    end)
  end

  defp packages_from_requests(deps) do
    Enum.flat_map(deps, fn
      %{repo: repo, name: package, dependencies: []} -> [{repo, package}]
      %{dependencies: dependencies} -> packages_from_requests(dependencies)
    end)
  end

  defp lock_merge(old, new) do
    Map.merge(old, new, fn _key, old_tuple, new_tuple ->
      if lock_tuple_needs_update?(old_tuple, new_tuple) do
        new_tuple
      else
        old_tuple
      end
    end)
  end

  defp lock_tuple_needs_update?(old_tuple, new_tuple) do
    old_info = Hex.Utils.lock(old_tuple)
    new_info = Hex.Utils.lock(new_tuple)

    not (old_info != nil and
           new_info != nil and
           old_info.name == new_info.name and
           old_info.version == new_info.version and
           old_info.inner_checksum == new_info.inner_checksum and
           old_info.outer_checksum == new_info.outer_checksum and
           old_info.repo == new_info.repo)
  end

  def deps(%Mix.Dep{app: app}, lock) do
    case Hex.Utils.lock(lock[app]) do
      %{name: name, version: version, deps: nil, repo: repo} ->
        Registry.open()
        Registry.prefetch([{repo, name}])
        get_deps(repo, name, version)

      %{deps: deps} ->
        deps

      nil ->
        []
    end
  end

  defp get_deps(repo, name, version) do
    deps =
      case Registry.dependencies(repo, name, version) do
        {:ok, deps} -> deps
        :error -> []
      end

    for %{repo: repo, name: name, constraint: req, optional: optional, label: app} <- deps do
      app = String.to_atom(app)
      opts = [optional: optional, hex: name, repo: repo]
      {app, to_string(req), opts}
    end
  end

  # TODO: Use requests
  defp verify_deps(deps, top_level) do
    Enum.each(deps, fn dep ->
      if dep.app in top_level and dep.scm == Hex.SCM and dep.requirement == nil do
        Hex.Shell.warn(
          "#{dep.app} is missing its version requirement, " <>
            "use \">= 0.0.0\" if it should match any version"
        )
      end
    end)
  end

  defp verify_input(requests, locked) do
    Enum.each(requests, fn
      %{repo: repo, name: name, requirement: req, from: from, dependencies: []} ->
        verify_package_req(repo, name, req, from)

      %{} ->
        nil
    end)

    Enum.each(locked, fn %{repo: repo, name: name, version: req} ->
      verify_package_req(repo, name, req, "mix.lock")
    end)
  end

  defp verify_package_req(repo, name, req, from) do
    if Registry.versions(repo, name) == :error do
      Mix.raise("No package with name #{name} (from: #{from}) in registry")
    end

    if req != nil and Version.parse_requirement(req) == :error do
      Mix.raise(
        "Required version #{inspect(req)} for package #{name} is incorrectly specified (from: #{from})"
      )
    end
  end

  defp verify_otp_app_names(dependencies) do
    verify_otp_app_names(dependencies, %{})
  end

  defp verify_otp_app_names([%{dependencies: []} = dependency | dependencies], map) do
    map =
      Map.update(map, {dependency.repo, dependency.name}, dependency, fn existing_dependency ->
        if existing_dependency.label != dependency.label do
          name = Hex.Utils.package_name(dependency.repo, dependency.name)

          Mix.raise("""
          Conflicting OTP application names in dependency definition of #{inspect(name)}, in the following locations:

            * #{existing_dependency.from} defined application :#{existing_dependency.label}
            * #{dependency.from} defined application :#{dependency.label}
          """)
        end

        dependency
      end)

    verify_otp_app_names(dependencies, map)
  end

  defp verify_otp_app_names([%{dependencies: sub_dependencies} | dependencies], map) do
    verify_otp_app_names(sub_dependencies ++ dependencies, map)
  end

  defp verify_otp_app_names([], _map) do
    :ok
  end

  defp print_success(resolved, old_lock) do
    if resolved != [] do
      previously_locked_versions = dep_info_from_lock(old_lock)

      dep_changes =
        resolved
        |> group_dependency_changes(previously_locked_versions)
        |> annotate_dependency_changes()

      Enum.each(dep_changes, fn {mod, deps} ->
        unless deps == [], do: print_category(mod)
        print_dependency_group(deps, mod)
      end)

      all_deps = Enum.flat_map(dep_changes, fn {_mod, deps} -> deps end)
      has_retired = Enum.any?(all_deps, fn {_dep, retired, _adv} -> retired != nil end)
      has_advisory = Enum.any?(all_deps, fn {_dep, _retired, adv} -> adv != [] end)

      if has_retired do
        Hex.State.put(:print_retired_warning, true)
      end

      if has_advisory do
        Hex.State.put(:print_advisory_warning, true)
      end
    end
  end

  defp annotate_dependency_changes(dep_changes) do
    ignore_advisories = Hex.State.fetch!(:ignore_advisories)
    ignore_retirements = Hex.State.fetch!(:ignore_retirements)

    Enum.map(dep_changes, fn {mod, deps} ->
      annotated =
        Enum.map(deps, fn {name, repo, _prev, version, _warning} = dep ->
          retired =
            case Registry.retired(repo, name, version) do
              %{} = retired ->
                if Hex.Ignores.retirement_ignored?(name, version, ignore_retirements) do
                  nil
                else
                  retired
                end

              nil ->
                nil
            end

          advisories =
            (Registry.advisories(repo, name, version) || [])
            |> Hex.Ignores.reject_ignored_advisories(ignore_advisories)

          {dep, retired, advisories}
        end)

      {mod, annotated}
    end)
  end

  defp group_dependency_changes(resolved, previously_locked_versions) do
    state = %{new: [], eq: [], gt: [], lt: []}

    resolved
    |> Enum.map(fn {repo, name, _app, version} -> {name, {repo, version}} end)
    |> Enum.sort()
    |> Enum.reduce(state, fn {name, {repo, version}}, acc ->
      previous_version =
        previously_locked_versions
        |> Map.get(name)
        |> version_string_or_nil()

      change = categorize_dependency_change(previous_version, version)
      warning = warning_message(previous_version, version)
      Map.put(acc, change, acc[change] ++ [{name, repo, previous_version, version, warning}])
    end)
  end

  defp dep_info_from_lock(lock) do
    Enum.flat_map(lock, fn {_app, info} ->
      case Hex.Utils.lock(info) do
        %{name: name, repo: repo, version: version} ->
          [{name, {repo, version}}]

        nil ->
          []
      end
    end)
    |> Map.new()
  end

  defp version_string_or_nil(nil), do: nil
  defp version_string_or_nil({_repo, version_string}), do: version_string

  defp categorize_dependency_change(nil, _version), do: :new

  defp categorize_dependency_change(previous_version, version) do
    Version.compare(version, previous_version)
  end

  defp warning_message(nil, _version), do: nil

  defp warning_message(previous_version, version) do
    prev_ver = Version.parse!(previous_version)
    new_ver = Version.parse!(version)

    cond do
      major_version_change?(prev_ver, new_ver) -> " (major)"
      breaking_minor_version_change?(prev_ver, new_ver) -> " (minor)"
      true -> nil
    end
  end

  defp print_category(mod) do
    case mod do
      :new -> Hex.Shell.info("New:")
      :eq -> Hex.Shell.info("Unchanged:")
      :lt -> Hex.Shell.info("Downgraded:")
      :gt -> Hex.Shell.info("Upgraded:")
    end
  end

  defp print_dependency_group(deps, mod) do
    Enum.each(deps, fn {{name, _repo, previous_version, version, warning}, retired, advisories} ->
      print_status(retired, advisories, mod, name, previous_version, version, warning)
    end)
  end

  defp print_status(retired, advisories, mod, name, previous_version, version, warning) do
    version_str = version_string(mod, name, previous_version, version)
    retired_tag = if retired, do: [:yellow, " RETIRED!", :reset], else: []
    vulnerable_tag = if advisories != [], do: [:red, " VULNERABLE!", :reset], else: []
    warning_tag = if warning, do: [:red, warning, :reset], else: []

    line = [version_color(mod), version_str, :reset, retired_tag, vulnerable_tag, warning_tag]
    Hex.Shell.info(Hex.Shell.format(line))

    if retired do
      Hex.Shell.warn("    #{Hex.Utils.package_retirement_message(retired)}")
    end

    advisories
    |> :mix_hex_advisory.group_for_display()
    |> Enum.with_index()
    |> Enum.each(fn {advisory, index} ->
      if retired || index > 0, do: Hex.Shell.info("")

      Hex.Shell.info(
        Hex.Shell.format(["    " | Hex.Utils.format_advisory_ansi(advisory, "    ")])
      )
    end)
  end

  defp version_color(:new), do: :green
  defp version_color(:gt), do: :green
  defp version_color(:lt), do: :yellow
  defp version_color(:eq), do: []

  defp version_string(mod, name, _previous_version, version) when mod in [:eq, :new] do
    "  #{name} #{version}"
  end

  defp version_string(_mod, name, previous_version, version) do
    "  #{name} #{previous_version} => #{version}"
  end

  defp verify_prefetches(prefetches) do
    prefetches
    |> Enum.map(fn {repo, _package} -> repo end)
    |> Enum.uniq()
    |> Enum.each(&verify_repo/1)

    prefetches
  end

  defp verify_repo(repo) do
    case Hex.Repo.fetch_repo(repo) do
      {:ok, _} ->
        :ok

      :error ->
        case repo do
          "hexpm:" <> organization ->
            if Hex.Shell.yes?(
                 "No authenticated organization found for #{organization}. Do you want to authenticate it now?"
               ) do
              Mix.Tasks.Hex.Organization.run(["auth", organization])
            else
              Hex.Repo.get_repo(repo)
            end

          _ ->
            Mix.raise(
              "Unknown repository #{inspect(repo)}, add new repositories " <>
                "with the `mix hex.repo add` task"
            )
        end
    end
  end

  defp verify_resolved(resolved, lock) do
    Enum.each(resolved, fn {repo, name, app, version} ->
      case Hex.Utils.lock(lock[String.to_atom(app)]) do
        %{name: ^name, version: ^version, repo: ^repo} = lock ->
          verify_inner_checksum(repo, name, version, lock.inner_checksum)
          verify_outer_checksum(repo, name, version, lock.outer_checksum)
          verify_deps(repo, name, version, lock.deps)

        _ ->
          :ok
      end
    end)
  end

  defp verify_inner_checksum(repo, name, version, checksum) do
    registry_checksum = Registry.inner_checksum(repo, name, version)

    if checksum && Base.decode16!(checksum, case: :lower) != registry_checksum do
      Mix.raise("Registry checksum mismatch against lock (#{name} #{version})")
    end
  end

  defp verify_outer_checksum(repo, name, version, checksum) do
    registry_checksum = Registry.outer_checksum(repo, name, version)

    if checksum && Base.decode16!(checksum, case: :lower) != registry_checksum do
      Mix.raise("Registry checksum mismatch against lock (#{name} #{version})")
    end
  end

  defp verify_deps(repo, name, version, deps)
  defp verify_deps(_repo, _name, _version, nil), do: []

  defp verify_deps(repo, name, version, deps) do
    # TODO: Use requests?
    deps =
      Enum.map(deps, fn {app, req, opts} ->
        %{
          repo: if(opts[:repo] != "hexpm", do: opts[:repo]),
          name: opts[:hex],
          constraint: Hex.Solver.parse_constraint!(req),
          optional: !!opts[:optional],
          label: Atom.to_string(app)
        }
      end)

    registry_deps =
      case Registry.dependencies(repo, name, version) do
        {:ok, deps} -> deps
        :error -> []
      end

    if Enum.sort(deps) != Enum.sort(registry_deps) do
      Mix.raise("Registry dependencies mismatch against lock (#{name} #{version})")
    end
  end

  defp verify_lock(lock) do
    Enum.each(lock, fn {_app, info} ->
      case Hex.Utils.lock(info) do
        %{name: name, version: version, repo: repo} ->
          verify_dep(repo, name, version)

        nil ->
          :ok
      end
    end)
  end

  defp verify_dep(repo, name, version) do
    Hex.Repo.get_repo(repo)

    case Registry.versions(repo, name) do
      {:ok, versions} ->
        versions = Enum.map(versions, &to_string/1)

        unless version in versions do
          Mix.raise("Unknown package version #{name} #{version} in lockfile")
        end

      :error ->
        Mix.raise("Unknown package #{name} in lockfile")
    end
  end

  defp with_children(apps, lock) do
    [apps, do_with_children(apps, lock)]
    |> List.flatten()
  end

  defp do_with_children(names, lock) do
    Enum.map(names, fn name ->
      case Hex.Utils.lock(lock[String.to_atom(name)]) do
        %{name: name, version: version, deps: nil, repo: repo} ->
          # Do not error on bad data in the old lock because we should just
          # fix it automatically
          case Registry.dependencies(repo, name, version) do
            {:ok, deps} ->
              apps = Enum.map(deps, fn %{label: app} -> app end)
              [apps, do_with_children(apps, lock)]

            :error ->
              []
          end

        %{deps: deps} ->
          apps = Enum.map(deps, &Atom.to_string(elem(&1, 0)))
          [apps, do_with_children(apps, lock)]

        nil ->
          []
      end
    end)
  end

  defp prepare_locked(lock, old_lock, deps) do
    # Remove dependencies from the lock if:
    # 1. They are defined as git or path in mix.exs
    # 2. If the requirement or repo in mix.exs does not match the locked version
    # 3. If it's a child of another Hex package being unlocked/updated

    unlock_without_children =
      for {app, _} <- old_lock,
          not Map.has_key?(lock, app),
          do: Atom.to_string(app)

    unlock =
      (unlock_deps(deps, old_lock) ++ unlock_without_children)
      |> Enum.uniq()
      |> with_children(old_lock)
      |> Enum.uniq()

    old_lock
    |> Hex.Mix.from_lock()
    |> Enum.reject(fn %{app: app} -> app in unlock end)
  end

  defp unlock_deps(deps, old_lock) do
    Enum.filter(deps, fn
      %Mix.Dep{scm: Hex.SCM, app: app, requirement: req, opts: opts} ->
        name = opts[:hex]

        case Hex.Utils.lock(old_lock[app]) do
          %{name: ^name, version: version, repo: repo} ->
            (req && not Version.match?(version, req)) || repo != opts[:repo]

          %{} ->
            false

          nil ->
            true
        end

      %Mix.Dep{} ->
        true
    end)
    |> Enum.map(&Atom.to_string(&1.app))
  end

  defp major_version_change?(%Version{} = version1, %Version{} = version2) do
    version1.major != version2.major
  end

  defp breaking_minor_version_change?(%Version{} = version1, %Version{} = version2) do
    version1.major == 0 and version2.major == 0 and version1.minor != version2.minor
  end

  defp check_and_refresh_auth(prefetches) do
    if auth_preflight_required?(prefetches) do
      # Try to get token with authentication prompting enabled
      # hex_cli_auth ensures only one process prompts even if multiple processes
      # detect the expired token concurrently

      config = Hex.API.Client.config([])

      :read
      |> Hex.Auth.with_api(
        config,
        fn
          %{api_key: api_key} when is_binary(api_key) -> :ok
          %{} -> {:error, :no_auth}
        end,
        optional: true,
        auth_inline: true
      )
      |> case do
        :ok ->
          # Token is valid, was successfully refreshed, or user authenticated
          :ok

        {:error, {:auth_error, :auth_declined}} ->
          # User declined authentication
          Hex.Shell.warn(
            "Private packages will not be available. " <>
              "Run `mix hex.user auth` to authenticate."
          )

        {:error, {:auth_error, _reason}} ->
          Hex.Shell.warn(
            "Authentication failed. Private packages will not be available. " <>
              "Run `mix hex.user auth` to authenticate."
          )

        {:error, _reason} ->
          # Other errors (shouldn't happen with prompt_auth: true, but handle gracefully)
          :ok
      end
    else
      :ok
    end
  end

  @doc false
  def auth_preflight_required?(prefetches) do
    prefetches
    |> Enum.map(fn {repo, _package} -> repo end)
    |> Enum.uniq()
    |> Enum.any?(&repo_requires_user_oauth?/1)
  end

  defp repo_requires_user_oauth?("hexpm:" <> _ = repo) do
    repo
    |> Hex.Repo.get_repo()
    |> repo_uses_user_oauth?()
  end

  defp repo_requires_user_oauth?(_repo) do
    false
  end

  defp repo_uses_user_oauth?(repo_config) do
    Map.get(repo_config, :trusted, true) && !repo_config.auth_key
  end

  @doc false
  def print_policy_summary(opts \\ []) do
    case Hex.State.fetch!(:active_policy) do
      nil ->
        :ok

      policy ->
        filtered =
          if Keyword.get(opts, :list_filtered, true) do
            Hex.State.fetch!(:policy_filtered_versions)
          else
            []
          end

        local_cooldown = Hex.State.fetch!(:cooldown)

        if summary = Hex.Policy.Diagnostics.resolution_summary(policy, filtered, local_cooldown) do
          Hex.Shell.info(summary)
        end
    end
  end
end
