require 'pub_grub/partial_solution'
require 'pub_grub/term'
require 'pub_grub/incompatibility'
require 'pub_grub/solve_failure'

module PubGrub
  class VersionSolver
    attr_reader :source
    attr_reader :solution

    def initialize(source:, root: Package.root)
      @source = source

      # { package => [incompatibility, ...]}
      @incompatibilities = Hash.new do |h, k|
        h[k] = []
      end

      @seen_incompatibilities = Set.new

      @solution = PartialSolution.new

      @package_depth = { root => 0 }

      add_incompatibility Incompatibility.new([
        Term.new(VersionConstraint.any(root), false)
      ], cause: :root)

      propagate(root)
    end

    def solved?
      solution.unsatisfied.empty?
    end

    # Returns true if there is more work to be done, false otherwise
    def work
      return false if solved?

      next_package = choose_package_version
      propagate(next_package)

      if solved?
        logger.info { "Solution found after #{solution.attempted_solutions} attempts:" }
        solution.decisions.each do |package, version|
          next if package == Package.root
          logger.info { "* #{package} #{version}" }
        end

        false
      else
        true
      end
    end

    def solve
      work until solved?

      solution.decisions
    end

    alias_method :result, :solve

    private

    def propagate(initial_package)
      changed = [initial_package]
      while package = changed.shift
        @incompatibilities[package].reverse_each do |incompatibility|
          result = propagate_incompatibility(incompatibility)
          if result == :conflict
            root_cause = resolve_conflict(incompatibility)
            changed.clear
            changed << propagate_incompatibility(root_cause)
          elsif result # should be a Package
            changed << result
          end
        end
        changed.uniq!
      end
    end

    def propagate_incompatibility(incompatibility)
      unsatisfied = nil
      incompatibility.terms.each do |term|
        relation = solution.relation(term)
        if relation == :disjoint
          return nil
        elsif relation == :overlap
          # If more than one term is inconclusive, we can't deduce anything
          return nil if unsatisfied
          unsatisfied = term
        end
      end

      if !unsatisfied
        return :conflict
      end

      logger.debug { "derived: #{unsatisfied.invert}" }

      solution.derive(unsatisfied.invert, incompatibility)

      unsatisfied.package
    end

    def next_package_to_try
      solution.unsatisfied.min_by do |term|
        package = term.package
        versions = source.versions_for(package, term.constraint.range)

        [@package_depth[package], versions.count]
      end.package
    end

    def choose_package_version
      if solution.unsatisfied.empty?
        logger.info "No packages unsatisfied. Solving complete!"
        return nil
      end

      package = next_package_to_try
      unsatisfied_term = solution.unsatisfied.find { |t| t.package == package }
      version = source.versions_for(package, unsatisfied_term.constraint.range).first

      if version.nil?
        cause = Incompatibility::NoVersions.new(unsatisfied_term)
        add_incompatibility Incompatibility.new([unsatisfied_term], cause: cause)
        return package
      end

      conflict = false

      source.incompatibilities_for(package, version).each do |incompatibility|
        if @seen_incompatibilities.include?(incompatibility)
          logger.debug { "knew: #{incompatibility}" }
          next
        end
        @seen_incompatibilities.add(incompatibility)

        add_incompatibility incompatibility

        conflict ||= incompatibility.terms.all? do |term|
          term.package == package || solution.satisfies?(term)
        end

        # Update depths of new packages
        depth = @package_depth[package] + 1
        incompatibility.terms.each do |term|
          @package_depth[term.package] ||= depth
        end
      end

      unless conflict
        logger.info { "selecting #{package} #{version}" }

        solution.decide(package, version)
      end

      package
    end

    def resolve_conflict(incompatibility)
      logger.info { "conflict: #{incompatibility}" }

      new_incompatibility = false

      while !incompatibility.failure?
        most_recent_term = nil
        most_recent_satisfier = nil
        difference = nil

        previous_level = 1

        incompatibility.terms.each do |term|
          satisfier = solution.satisfier(term)

          if most_recent_satisfier.nil?
            most_recent_term = term
            most_recent_satisfier = satisfier
          elsif most_recent_satisfier.index < satisfier.index
            previous_level = [previous_level, most_recent_satisfier.decision_level].max
            most_recent_term = term
            most_recent_satisfier = satisfier
            difference = nil
          else
            previous_level = [previous_level, satisfier.decision_level].max
          end

          if most_recent_term == term
            difference = most_recent_satisfier.term.difference(most_recent_term)
            if difference.empty?
              difference = nil
            else
              difference_satisfier = solution.satisfier(difference.inverse)
              previous_level = [previous_level, difference_satisfier.decision_level].max
            end
          end
        end

        if previous_level < most_recent_satisfier.decision_level ||
            most_recent_satisfier.decision?

          logger.info { "backtracking to #{previous_level}" }
          solution.backtrack(previous_level)

          if new_incompatibility
            add_incompatibility(incompatibility)
          end

          return incompatibility
        end

        new_terms = []
        new_terms += incompatibility.terms - [most_recent_term]
        new_terms += most_recent_satisfier.cause.terms.reject { |term|
          term.package == most_recent_satisfier.term.package
        }
        if difference
          new_terms << difference.invert
        end

        incompatibility = Incompatibility.new(new_terms, cause: Incompatibility::ConflictCause.new(incompatibility, most_recent_satisfier.cause))

        new_incompatibility = true

        partially = difference ? " partially" : ""
        logger.info { "! #{most_recent_term} is#{partially} satisfied by #{most_recent_satisfier.term}" }
        logger.info { "! which is caused by #{most_recent_satisfier.cause}" }
        logger.info { "! thus #{incompatibility}" }
      end

      raise SolveFailure.new(incompatibility)
    end

    def add_incompatibility(incompatibility)
      logger.debug { "fact: #{incompatibility}" }
      incompatibility.terms.each do |term|
        package = term.package
        @incompatibilities[package] << incompatibility
      end
    end

    def logger
      PubGrub.logger
    end
  end
end
