#!/usr/bin/env ruby
# Checks that Gemfile.lock and Gemfile.saas.lock are in sync for shared dependencies.
# Since Gemfile.saas evals Gemfile, shared gems should have identical versions.
#
# Usage:
#   bin/bundle-drift [check]     # check for drift (default subcommand)
#   bin/bundle-drift correct     # restore alignment (Gemfile.saas.lock is authoritative)
#   bin/bundle-drift forward     # push Gemfile.lock changes into Gemfile.saas.lock
#   bin/bundle-drift self-test   # run built-in tests
require "bundler"
require "fileutils"

GEMFILE_LOCK = "Gemfile.lock"
GEMFILE_SAAS_LOCK = "Gemfile.saas.lock"

class GemfileDriftChecker
  def initialize
    @oss_lockfile = parse_lockfile(GEMFILE_LOCK)
    @saas_lockfile = parse_lockfile(GEMFILE_SAAS_LOCK)
  end

  def check
    find_drift.tap do
      report it
    end
  end

  private
    def parse_lockfile(path)
      Bundler::LockfileParser.new(File.read(path))
    end

    def find_drift
      oss_specs, saas_specs = specs_hash(@oss_lockfile), specs_hash(@saas_lockfile)
      oss_deps, saas_deps = deps_hash(@oss_lockfile), deps_hash(@saas_lockfile)
      oss_sdeps, saas_sdeps = spec_deps_hash(@oss_lockfile), spec_deps_hash(@saas_lockfile)
      shared_gems = oss_specs.keys & saas_specs.keys
      shared_deps = oss_deps.keys & saas_deps.keys

      shared_gems.filter_map do |name|
        oss_version, saas_version = oss_specs[name], saas_specs[name]
        version_drift = oss_version != saas_version

        oss_dep, saas_dep = oss_deps[name], saas_deps[name]
        dep_drift = shared_deps.include?(name) && oss_dep != saas_dep

        oss_sd, saas_sd = oss_sdeps[name] || {}, saas_sdeps[name] || {}
        subdep_changes = (oss_sd.keys | saas_sd.keys).filter_map do |dep_name|
          if oss_sd[dep_name] != saas_sd[dep_name]
            { name: dep_name, oss: oss_sd[dep_name], saas: saas_sd[dep_name] }
          end
        end

        if version_drift || dep_drift || subdep_changes.any?
          { name: name, oss: oss_version, saas: saas_version, oss_dep: oss_dep, saas_dep: saas_dep, subdep_changes: subdep_changes }
        end
      end.sort_by { |d| d[:name] }
    end

    def specs_hash(lockfile)
      lockfile.specs.to_h { |spec| [ spec.name, spec.version.to_s ] }
    end

    def deps_hash(lockfile)
      lockfile.dependencies.transform_values { |dep| dep.requirement.to_s }
    end

    def spec_deps_hash(lockfile)
      lockfile.specs.to_h do |spec|
        [spec.name, spec.dependencies.map { |d| [d.name, d.requirement.to_s] }.sort.to_h]
      end
    end

    def report(drift)
      if drift.empty?
        puts "✓ Gemfile.lock and Gemfile.saas.lock are in sync"
      else
        puts "✗ Gemfile lock files have drifted!\n\n"

        spec_drift = drift.select { |d| d[:oss] != d[:saas] }
        dep_drift = drift.select { |d| d[:oss_dep] != d[:saas_dep] }

        if spec_drift.any?
          puts "  Spec versions:"
          spec_drift.each do |d|
            puts "    #{d[:name]}: #{d[:oss]} (Gemfile.lock) vs #{d[:saas]} (Gemfile.saas.lock)"
          end
        end

        if dep_drift.any?
          puts "  Dependency requirements:"
          dep_drift.each do |d|
            puts "    #{d[:name]}: #{d[:oss_dep]} (Gemfile.lock) vs #{d[:saas_dep]} (Gemfile.saas.lock)"
          end
        end

        subdep_drift = drift.select { |d| d[:subdep_changes]&.any? }
        if subdep_drift.any?
          puts "  Spec sub-dependency requirements:"
          subdep_drift.each do |d|
            d[:subdep_changes].each do |sd|
              puts "    #{d[:name]} → #{sd[:name]}: #{sd[:oss] || "(absent)"} (Gemfile.lock) vs #{sd[:saas] || "(absent)"} (Gemfile.saas.lock)"
            end
          end
        end

        puts "\nRun 'bin/bundle-drift correct' to restore alignment."
      end
    end
end

class GemfileDriftCorrector
  def correct
    drift = GemfileDriftChecker.new.check
    return puts "\nNothing to correct." if drift.empty?

    puts "\nRestoring alignment (Gemfile.saas.lock is authoritative)...\n\n"

    original_content = File.read(GEMFILE_LOCK)

    FileUtils.cp(GEMFILE_SAAS_LOCK, GEMFILE_LOCK)

    puts "▸ Re-locking Gemfile (seeded from Gemfile.saas.lock)"
    unless system("BUNDLE_GEMFILE=Gemfile bundle lock")
      File.write(GEMFILE_LOCK, original_content)
      abort("Failed to lock Gemfile. Restored original.")
    end

    puts "\n▸ Verifying alignment"
    new_drift = GemfileDriftChecker.new.check

    if new_drift.empty?
      puts "\n✓ Lock files are now in sync"
      show_diff(original_content, File.read(GEMFILE_LOCK))
    else
      puts "\n✗ Lock files still have drift after correction."
      puts "  Bundler couldn't resolve to matching versions."
      puts "  Restoring original Gemfile.lock."
      File.write(GEMFILE_LOCK, original_content)
      exit 1
    end
  end

  private
    def show_diff(original, corrected)
      require "tempfile"

      Tempfile.create("gemfile-lock-original") do |f|
        f.write(original)
        f.flush

        diff = `diff -u #{f.path} #{GEMFILE_LOCK} 2>/dev/null`
        unless diff.empty?
          puts "\nChanges made to Gemfile.lock:"
          puts diff
        end
      end
    end
end

class GemfileDriftForwarder
  def forward
    drift = GemfileDriftChecker.new.check
    return puts "\nNothing to forward." if drift.empty?

    puts "\nForwarding Gemfile.lock changes into Gemfile.saas.lock...\n\n"

    original_content = File.read(GEMFILE_SAAS_LOCK)
    patched_content = original_content.dup

    drift.each do |d|
      if d[:oss] != d[:saas]
        puts "  #{d[:name]}: spec #{d[:saas]} → #{d[:oss]}"
        patched_content.gsub!(/#{Regexp.escape(d[:name])} \(#{Regexp.escape(d[:saas])}([^)]*)\)/) do
          "#{d[:name]} (#{d[:oss]}#{$1})"
        end
      end

      if d[:oss_dep] && d[:saas_dep] && d[:oss_dep] != d[:saas_dep]
        puts "  #{d[:name]}: dep #{d[:saas_dep]} → #{d[:oss_dep]}"
        patched_content = patch_dependency_requirement(patched_content, d[:name], d[:saas_dep], d[:oss_dep])
      end

      if d[:subdep_changes]&.any?
        d[:subdep_changes].each do |sd|
          if sd[:oss] && sd[:saas]
            puts "  #{d[:name]} → #{sd[:name]}: subdep #{sd[:saas]} → #{sd[:oss]}"
            patched_content = patch_spec_subdependency(patched_content, d[:name], sd[:name], sd[:saas], sd[:oss])
          end
        end
      end
    end

    File.write(GEMFILE_SAAS_LOCK, patched_content)

    puts "\n▸ Verifying alignment"
    new_drift = GemfileDriftChecker.new.check

    if new_drift.empty?
      puts "\n✓ Lock files are now in sync"
      show_diff(original_content, patched_content)
    else
      puts "\n✗ Lock files still have drift after forwarding."
      puts "  Restoring original Gemfile.saas.lock."
      File.write(GEMFILE_SAAS_LOCK, original_content)
      exit 1
    end
  end

  private
    def patch_spec_subdependency(content, parent_name, dep_name, old_req, new_req)
      in_parent_spec = false
      content.lines.map do |line|
        if line.match?(/\A {4}\S/)
          in_parent_spec = line.match?(/\A {4}#{Regexp.escape(parent_name)} \(/)
        elsif !line.match?(/\A {6}/)
          in_parent_spec = false
        end

        if in_parent_spec && line.match?(/\A {6}#{Regexp.escape(dep_name)} \(#{Regexp.escape(old_req)}\)/)
          line.sub("#{dep_name} (#{old_req})", "#{dep_name} (#{new_req})")
        else
          line
        end
      end.join
    end

    def patch_dependency_requirement(content, name, old_req, new_req)
      in_deps = false
      content.lines.map do |line|
        if line.strip == "DEPENDENCIES"
          in_deps = true
        elsif in_deps && line.match?(/\A\S/)
          in_deps = false
        end

        if in_deps
          line.sub(/#{Regexp.escape(name)} \(#{Regexp.escape(old_req)}\)/, "#{name} (#{new_req})")
        else
          line
        end
      end.join
    end

    def show_diff(original, patched)
      require "tempfile"

      Tempfile.create("gemfile-saas-lock-original") do |f|
        f.write(original)
        f.flush

        diff = `diff -u #{f.path} #{GEMFILE_SAAS_LOCK} 2>/dev/null`
        unless diff.empty?
          puts "\nChanges made to Gemfile.saas.lock:"
          puts diff
        end
      end
    end
end

case command = ARGV[0] || "check"
when "check"
  exit 1 unless GemfileDriftChecker.new.check.empty?
when "correct"
  GemfileDriftCorrector.new.correct
when "forward"
  GemfileDriftForwarder.new.forward
when "self-test"
  require "minitest/autorun"
  require "tmpdir"

  LOCKFILE_TEMPLATE = <<~LOCKFILE
    GEM
      remote: https://rubygems.org/
      specs:
        shared_gem (%{shared_version})
        oss_only_gem (1.0.0)
        saas_only_gem (2.0.0)

    PLATFORMS
      ruby

    DEPENDENCIES
    %{dependencies}

    BUNDLED WITH
      2.6.2
  LOCKFILE

  LOCKFILE_WITH_SUBDEPS_TEMPLATE = <<~LOCKFILE
    GEM
      remote: https://rubygems.org/
      specs:
        child_gem (%{child_version})
        parent_gem (%{parent_version})
          child_gem (%{child_req})

    PLATFORMS
      ruby

    DEPENDENCIES
      parent_gem (%{parent_dep})

    BUNDLED WITH
      2.6.2
  LOCKFILE

  class GemfileDriftCheckerTest < Minitest::Test
    def setup
      @original_dir = Dir.pwd
      @tmpdir = Dir.mktmpdir("bundle-drift-test")
      Dir.chdir(@tmpdir)
    end

    def teardown
      Dir.chdir(@original_dir)
      FileUtils.rm_rf(@tmpdir)
    end

    def test_detects_spec_version_drift
      write_lockfiles(
        oss_version: "1.0.0", oss_deps: "  shared_gem (~> 1.0)\n  oss_only_gem",
        saas_version: "2.0.0", saas_deps: "  shared_gem (~> 2.0)\n  saas_only_gem"
      )

      drift = GemfileDriftChecker.new.send(:find_drift)

      assert_equal 1, drift.length
      assert_equal "shared_gem", drift.first[:name]
    end

    def test_detects_dependency_requirement_drift_even_when_spec_versions_match
      write_lockfiles(
        oss_version: "1.4.0", oss_deps: "  shared_gem (~> 1.4)\n  oss_only_gem",
        saas_version: "1.4.0", saas_deps: "  shared_gem (~> 1.3)\n  saas_only_gem"
      )

      drift = GemfileDriftChecker.new.send(:find_drift)

      assert_equal 1, drift.length
      assert_equal "shared_gem", drift.first[:name]
    end

    def test_reports_no_drift_when_specs_and_dependencies_match
      write_lockfiles(
        oss_version: "1.4.0", oss_deps: "  shared_gem (~> 1.4)\n  oss_only_gem",
        saas_version: "1.4.0", saas_deps: "  shared_gem (~> 1.4)\n  saas_only_gem"
      )

      drift = GemfileDriftChecker.new.send(:find_drift)

      assert_empty drift
    end

    def test_forward_patches_dependency_requirements_in_addition_to_spec_versions
      write_lockfiles(
        oss_version: "1.4.0", oss_deps: "  shared_gem (~> 1.4)\n  oss_only_gem",
        saas_version: "1.3.2", saas_deps: "  shared_gem (~> 1.3)\n  saas_only_gem"
      )

      capture_io { GemfileDriftForwarder.new.forward }

      saas_content = File.read("Gemfile.saas.lock")
      assert_includes saas_content, "shared_gem (1.4.0)"
      assert_includes saas_content, "shared_gem (~> 1.4)"
      refute_includes saas_content, "shared_gem (~> 1.3)"
    end

    def test_detects_spec_subdependency_drift_even_when_versions_match
      write_lockfiles_with_subdeps(
        oss_child_req: "~> 3, >= 3.2.0",
        saas_child_req: "~> 3, >= 3.1.0"
      )

      drift = GemfileDriftChecker.new.send(:find_drift)

      assert_equal 1, drift.length
      assert_equal "parent_gem", drift.first[:name]
      assert_equal 1, drift.first[:subdep_changes].length
      assert_equal "child_gem", drift.first[:subdep_changes].first[:name]
    end

    def test_no_drift_when_spec_subdependencies_match
      write_lockfiles_with_subdeps(
        oss_child_req: "~> 3, >= 3.2.0",
        saas_child_req: "~> 3, >= 3.2.0"
      )

      drift = GemfileDriftChecker.new.send(:find_drift)

      assert_empty drift
    end

    def test_forward_patches_spec_subdependency_requirements
      write_lockfiles_with_subdeps(
        oss_child_req: "~> 3, >= 3.2.0",
        saas_child_req: "~> 3, >= 3.1.0"
      )

      capture_io { GemfileDriftForwarder.new.forward }

      saas_content = File.read("Gemfile.saas.lock")
      assert_includes saas_content, "child_gem (~> 3, >= 3.2.0)"
      refute_includes saas_content, "child_gem (~> 3, >= 3.1.0)"
    end

    private
      def write_lockfiles(oss_version:, oss_deps:, saas_version:, saas_deps:)
        File.write("Gemfile.lock", LOCKFILE_TEMPLATE % { shared_version: oss_version, dependencies: oss_deps })
        File.write("Gemfile.saas.lock", LOCKFILE_TEMPLATE % { shared_version: saas_version, dependencies: saas_deps })
      end

      def write_lockfiles_with_subdeps(oss_child_req:, saas_child_req:)
        common = { parent_version: "1.0.0", child_version: "3.2.0", parent_dep: "~> 1.0" }
        File.write("Gemfile.lock", LOCKFILE_WITH_SUBDEPS_TEMPLATE % common.merge(child_req: oss_child_req))
        File.write("Gemfile.saas.lock", LOCKFILE_WITH_SUBDEPS_TEMPLATE % common.merge(child_req: saas_child_req))
      end
  end
else
  abort "Usage: bin/bundle-drift [check|correct|forward|self-test]"
end
