#!/usr/bin/env ruby
# frozen_string_literal: true

ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "bundler"

# parse the Gemfile, but don't actual set anything up
# this ensures "base_gems" is valid
Bundler.definition

base_gems = Gem.loaded_specs.keys.to_set

require "shellwords"
require "yaml"

output = `git status --porcelain --untracked=no`.strip
unless output.empty?
  warn "git status is not clean; please commit or stash your changes first"
  exit 1
end

config = YAML.safe_load_file(File.expand_path("bundle_update_config.yml", __dir__))
ignored_gems = config["ignored_gems"].to_set
cohort_gems = config["cohort_gems"]

explicit_dependencies = Bundler.definition.dependencies.to_set(&:name)
reverse_dependencies = {}

# infer "cohort" gems from the lockfile automatically
Bundler.definition.specs.each do |spec|
  spec.dependencies.each do |dep|
    (reverse_dependencies[dep.name] ||= []) << spec.name
  end
end

Bundler.definition.specs.each do |spec| # rubocop:disable Style/CombinableLoops
  next if explicit_dependencies.include?(spec.name)

  parent = spec.name
  until explicit_dependencies.include?(parent)
    parents = reverse_dependencies[parent]
    if parents.length > 1
      parent = nil
      break
    end
    parent = parents.first
  end

  next unless parent

  (cohort_gems[parent] ||= []) << spec.name
end

cohort_gems_inverse = {}
cohort_gems.each do |parent_gem, child_gems|
  child_gems.each do |child_gem|
    (cohort_gems_inverse[child_gem] ||= []) << parent_gem
  end
end

output = Bundler.with_unbundled_env { `bundle outdated --parseable` }
output = output.split("\n").map(&:strip)

gems_to_update = Set.new
output.each do |line|
  next if line.empty?

  gem, details = line.split(" ", 2)
  next if details.include?("requested = ") # exact requirements can't be updated

  gems_to_update << gem
end

# only update "rails" if "activesupport" _and_ "rails" need updated
gems_to_update.reject! { |gem, _| (parent_gems = cohort_gems_inverse[gem]) && gems_to_update.intersect?(parent_gems) }

gems_to_update = gems_to_update.to_a

until gems_to_update.empty?
  parent_gem = nil
  gem = gems_to_update.shift
  # if "aws-sdk-core" and "aws-sdk-s3" need updated, do them together
  if (parent_gems = cohort_gems_inverse[gem])
    parent_gem = parent_gems.min_by { |g| -(cohort_gems[g] & gems_to_update).length }
    sibling_gems = cohort_gems[parent_gem] & gems_to_update
    if sibling_gems.empty?
      parent_gem = nil
    else
      gems_to_update -= sibling_gems
      gem = ([gem] + sibling_gems).join(" ")
    end
  end
  next if ignored_gems.include?(gem)

  puts "Updating #{parent_gem || gem}..."

  system("bundle update #{gem} --quiet")

  unless $?.success?
    system("git reset --hard HEAD > #{IO::NULL}")
    next
  end

  output = `git status --porcelain --untracked=no`.strip
  # couldn't update; should have warned anyway
  next if output.empty?

  message = "bundle update #{parent_gem || gem}"
  message += "\n\n!! this commit needs hotfixed, since it is a base gem" if base_gems.include?(gem)

  `git commit -am #{Shellwords.escape(message)} 2> #{IO::NULL}`
end
