require "inspec/log"
require "inspec/errors"

module Inspec
  #
  # Inspec::Resolver is a simple dependency resolver. Unlike Bundler
  # or Berkshelf, it does not attempt to resolve each named dependency
  # to a single version. Rather, it traverses down the dependency tree
  # and:
  #
  # - Fetches the dependency from the source
  # - Checks the presence of cycles, and
  # - Checks that the specified dependency source satisfies the
  #   specified version constraint
  #
  # The full dependency tree is then available for the loader, which
  # will provide the isolation necessary to support multiple versions
  # of the same profile being used at runtime.
  #
  # Currently the fetching happens somewhat lazily depending on the
  # implementation of the fetcher being used.
  #
  class Resolver
    # Here deps is an Array of Hashes
    def self.resolve(dependencies, cache, working_dir, backend)
      reqs = dependencies.map do |dep|
        req = Inspec::Requirement.from_metadata(dep, cache, cwd: working_dir, backend: backend)
        req || raise("Cannot initialize dependency: #{req}")
      end
      new.resolve(reqs)
    end

    def detect_duplicates(deps, top_level, path_string)
      seen_items_local = []
      deps.each do |dep|
        if seen_items_local.include?(dep.name)
          problem_cookbook = if top_level
                               "the inspec.yml for this profile."
                             else
                               "the dependency information for #{path_string.split(" ").last}"
                             end
          raise Inspec::DuplicateDep, "The dependency #{dep.name} is listed twice in #{problem_cookbook}"
        else
          seen_items_local << dep.name
        end
      end
    end

    # Here deps is an Array of Inspec::Requirement
    def resolve(deps, top_level = true, seen_items = {}, path_string = "") # rubocop:disable Metrics/AbcSize
      graph = {}
      if top_level
        Inspec::Log.debug("Starting traversal of dependencies #{deps.map(&:to_s)}")
      else
        Inspec::Log.debug("Traversing dependency tree of transitive dependency #{deps.map(&:name)}")
      end

      detect_duplicates(deps, top_level, path_string)
      deps.each do |dep|
        # Calling dep.resolved_source forces a fetch. Handle any airgap chicanery early.
        if Inspec::Config.cached[:airgap]
          begin
            dep.resolved_source
          rescue Inspec::FetcherFailure
            Inspec::Log.debug("Failed to fetch #{dep.name}, falling back to archives if possible")
            retry if fallback_to_archive_on_fetch_failure(dep)
          end
        end

        new_seen_items = seen_items.dup
        new_path_string = if path_string.empty?
                            dep.name
                          else
                            path_string + " -> #{dep.name}"
                          end

        raise Inspec::CyclicDependencyError, "Dependency #{dep} would cause a dependency cycle (#{new_path_string})" if new_seen_items.key?(dep.resolved_source)

        new_seen_items[dep.resolved_source] = true

        unless dep.source_satisfies_spec?
          raise Inspec::UnsatisfiedVersionSpecification, "The profile #{dep.name} from #{dep.resolved_source} has a version #{dep.source_version} which doesn't match #{dep.version_constraints}"
        end

        Inspec::Log.debug("Adding dependency #{dep.name} (#{dep.resolved_source})")
        graph[dep.name] = dep
        unless dep.dependencies.empty?
          resolve(dep.dependencies, false, new_seen_items.dup, new_path_string)
        end
      end

      Inspec::Log.debug("Dependency traversal complete.") if top_level
      graph
    end

    def fallback_to_archive_on_fetch_failure(dep)
      # This facility is intended to handle situations in which
      # the failing dependency *is* available in an archive that we have
      # available as a local dependency. We just need to find the archive and
      # alter the fetcher to refer to information in the archive.
      # Note that the vendor cache already should have the archive inflated
      # for this to work (see warm_cache_from_archives() from profile_vendor.rb)
      # Refs 4727

      # This is where any existing archives should have been inflated -
      # that is, this is the vendor cache. Each archive would have a lockfile.
      cache_path = dep.cache.path
      worth_retrying = false

      Dir["#{cache_path}/*/inspec.lock"].each do |lockfile_path|
        lockfile = Inspec::Lockfile.from_file(lockfile_path)
        dep_set = Inspec::DependencySet.from_lockfile(lockfile, dep.opts)
        dep2 = dep_set.dep_list[dep.name]
        next unless dep2

        if dep.opts.key?(:compliance)
          # This is ugly. The compliance fetcher works differently than the others,
          # and fails at the resolve stage, not the fetch stage. That means we can't
          # tweak the fetcher, we have to tweak the deps opts themselves.
          dep.opts[:sha256] = dep2.opts[:sha256]
          worth_retrying = true
        else
          # All other fetchers can be generalized, because they will survive their constructor.
          fetcher = dep.fetcher.fetcher # Not the CachedFetcher, but its fetcher
          made_a_change = fetcher.update_from_opts(dep2.opts)
        end
        worth_retrying ||= made_a_change
      end
      worth_retrying
    end
  end
end
