# typed: true # rubocop:todo Sorbet/StrictSigil
# frozen_string_literal: true

require "lock_file"
require "keg"
require "tab"
require "utils/output"

# Helper class for migrating a formula from an old to a new name.
class Migrator
  extend Utils::Output::Mixin
  include Context
  include Utils::Output::Mixin

  # Error for when a migration is necessary.
  class MigrationNeededError < RuntimeError
    def initialize(oldname, newname)
      super <<~EOS
        #{oldname} was renamed to #{newname} and needs to be migrated by running:
          brew migrate #{oldname}
      EOS
    end
  end

  # Error for when the old name's path does not exist.
  class MigratorNoOldpathError < RuntimeError
    def initialize(oldname)
      super "#{HOMEBREW_CELLAR/oldname} doesn't exist."
    end
  end

  # Error for when a formula is migrated to a different tap without explicitly using its fully-qualified name.
  class MigratorDifferentTapsError < RuntimeError
    def initialize(formula, oldname, tap)
      msg = if tap.core_tap?
        "Please try to use #{oldname} to refer to the formula.\n"
      elsif tap
        "Please try to use fully-qualified #{tap}/#{oldname} to refer to the formula.\n"
      end

      super <<~EOS
        #{formula.name} from #{formula.tap} is given, but old name #{oldname} was installed from #{tap || "path or url"}.
        #{msg}To force migration, run:
          brew migrate --force #{oldname}
      EOS
    end
  end

  # Instance of renamed formula.
  attr_reader :formula

  # Old name of the formula.
  attr_reader :oldname

  # Path to oldname's Cellar.
  attr_reader :old_cellar

  # Path to oldname pin.
  attr_reader :old_pin_record

  # Path to oldname opt.
  attr_reader :old_opt_records

  # Oldname linked kegs.
  attr_reader :old_linked_kegs

  # Oldname linked kegs that were fully linked.
  attr_reader :old_full_linked_kegs

  # Tabs from oldname kegs.
  attr_reader :old_tabs

  # Tap of the old name.
  attr_reader :old_tap

  # Resolved path to oldname pin.
  attr_reader :old_pin_link_record

  # New name of the formula.
  attr_reader :newname

  # Path to newname Cellar according to new name.
  attr_reader :new_cellar

  # True if new Cellar existed at initialization time.
  attr_reader :new_cellar_existed

  # Path to newname pin.
  attr_reader :new_pin_record

  # Path to newname keg that will be linked if old_linked_keg isn't nil.
  attr_reader :new_linked_keg_record

  def self.oldnames_needing_migration(formula)
    formula.oldnames.select do |oldname|
      oldname_rack = HOMEBREW_CELLAR/oldname
      next false if oldname_rack.symlink?
      next false unless oldname_rack.directory?

      true
    end
  end

  def self.needs_migration?(formula)
    !oldnames_needing_migration(formula).empty?
  end

  def self.migrate_if_needed(formula, force:, dry_run: false)
    oldnames = Migrator.oldnames_needing_migration(formula)

    begin
      oldnames.each do |oldname|
        if dry_run
          oh1 "Would migrate formula #{Formatter.identifier(oldname)} to #{Formatter.identifier(formula.name)}"
          next
        end

        migrator = Migrator.new(formula, oldname, force:)
        migrator.migrate
      end
    rescue => e
      onoe e
    end
  end

  def initialize(formula, oldname, force: false)
    @oldname = oldname
    @newname = formula.name

    @formula = formula
    @old_cellar = HOMEBREW_CELLAR/oldname
    raise MigratorNoOldpathError, oldname unless old_cellar.exist?

    @old_tabs = old_cellar.subdirs.map { |d| Keg.new(d).tab }
    @old_tap = old_tabs.first.tap

    raise MigratorDifferentTapsError.new(formula, oldname, old_tap) if !force && !from_same_tap_user?

    @new_cellar = HOMEBREW_CELLAR/formula.name
    @new_cellar_existed = @new_cellar.exist?

    @old_linked_kegs = linked_old_linked_kegs
    @old_full_linked_kegs = []
    @old_opt_records = []
    old_linked_kegs.each do |old_linked_keg|
      @old_full_linked_kegs << old_linked_keg if old_linked_keg.linked?
      @old_opt_records << old_linked_keg.opt_record if old_linked_keg.optlinked?
    end
    unless old_linked_kegs.empty?
      @new_linked_keg_record = HOMEBREW_CELLAR/"#{newname}/#{File.basename(old_linked_kegs.first)}"
    end

    @old_pin_record = HOMEBREW_PINNED_KEGS/oldname
    @new_pin_record = HOMEBREW_PINNED_KEGS/newname
    @pinned = old_pin_record.symlink?
    @old_pin_link_record = old_pin_record.readlink if @pinned
  end

  # Fix `INSTALL_RECEIPT`s for tap-migrated formula.
  def fix_tabs
    old_tabs.each do |tab|
      tab.tap = formula.tap
      tab.write
    end
  end

  sig { returns(T::Boolean) }
  def from_same_tap_user?
    formula_tap_user = formula.tap.user if formula.tap
    old_tap_user = nil

    new_tap = if old_tap
      old_tap_user, = old_tap.user
      if (migrate_tap = old_tap.tap_migrations[oldname])
        new_tap_user, new_tap_repo = migrate_tap.split("/")
        "#{new_tap_user}/#{new_tap_repo}"
      end
    end

    if formula_tap_user == old_tap_user
      true
    # Homebrew didn't use to update tabs while performing tap-migrations,
    # so there can be `INSTALL_RECEIPT`s containing wrong information about tap,
    # so we check if there is an entry about oldname migrated to tap and if
    # newname's tap is the same as tap to which oldname migrated, then we
    # can perform migrations and the taps for oldname and newname are the same.
    elsif formula.tap && old_tap && formula.tap == new_tap
      fix_tabs
      true
    else
      false
    end
  end

  def linked_old_linked_kegs
    keg_dirs = []
    keg_dirs += new_cellar.subdirs if new_cellar.exist?
    keg_dirs += old_cellar.subdirs
    kegs = keg_dirs.map { |d| Keg.new(d) }
    kegs.select { |keg| keg.linked? || keg.optlinked? }
  end

  def pinned?
    @pinned
  end

  def migrate
    oh1 "Migrating formula #{Formatter.identifier(oldname)} to #{Formatter.identifier(newname)}"
    lock
    unlink_oldname
    unlink_newname if new_cellar.exist?
    repin
    move_to_new_directory
    link_oldname_cellar
    link_oldname_opt
    link_newname unless old_linked_kegs.empty?
    update_tabs
    return unless formula.outdated?

    opoo <<~EOS
      #{Formatter.identifier(newname)} is outdated!
      To avoid broken installations, as soon as possible please run:
        brew upgrade
      Or, if you're OK with a less reliable fix:
        brew upgrade #{newname}
    EOS
  rescue Interrupt
    ignore_interrupts { backup_oldname }
  # Any exception means the migration did not complete.
  rescue Exception => e # rubocop:disable Lint/RescueException
    onoe "The migration did not complete successfully."
    puts e
    if debug?
      require "utils/backtrace"
      puts Utils::Backtrace.clean(e)
    end
    puts "Backing up..."
    ignore_interrupts { backup_oldname }
  ensure
    unlock
  end

  def remove_conflicts(directory)
    conflicted = T.let(false, T::Boolean)

    directory.each_child do |c|
      if c.directory? && !c.symlink?
        conflicted ||= remove_conflicts(c)
      else
        next unless (new_cellar/c.relative_path_from(old_cellar)).exist?

        begin
          FileUtils.rm_rf c
        rescue Errno::EACCES
          conflicted = true
          onoe "#{new_cellar/c.basename} already exists."
        end
      end
    end

    conflicted
  end

  def merge_directory(directory)
    directory.each_child do |c|
      new_path = new_cellar/c.relative_path_from(old_cellar)

      if c.directory? && !c.symlink? && new_path.exist?
        merge_directory(c)
        c.unlink
      else
        FileUtils.mv(c, new_path)
      end
    end
  end

  # Move everything from `Cellar/oldname` to `Cellar/newname`.
  def move_to_new_directory
    return unless old_cellar.exist?

    if new_cellar.exist?
      conflicted = remove_conflicts(old_cellar)
      odie "Remove #{new_cellar} and #{old_cellar} manually and run `brew reinstall #{newname}`." if conflicted
    end

    oh1 "Moving #{Formatter.identifier(oldname)} versions to #{new_cellar}"
    if new_cellar.exist?
      merge_directory(old_cellar)
    else
      FileUtils.mv(old_cellar, new_cellar)
    end
  end

  def repin
    return unless pinned?

    # `old_pin_record` is a relative symlink and when we try to to read it
    # from <dir> we actually try to find file
    # <dir>/../<...>/../Cellar/name/version.
    # To repin formula we need to update the link thus that it points to
    # the right directory.
    #
    # NOTE: `old_pin_record.realpath.sub(oldname, newname)` is unacceptable
    # here, because it resolves every symlink for `old_pin_record` and then
    # substitutes oldname with newname. It breaks things like
    # `Pathname#make_relative_symlink`, where `Pathname#relative_path_from`
    # is used to find the relative path from source to destination parent
    # and it assumes no symlinks.
    src_oldname = (old_pin_record.dirname/old_pin_link_record).expand_path
    new_pin_record.make_relative_symlink(src_oldname.sub(oldname, newname))
    old_pin_record.delete
  end

  def unlink_oldname
    oh1 "Unlinking #{Formatter.identifier(oldname)}"
    old_cellar.subdirs.each do |d|
      keg = Keg.new(d)
      keg.unlink(verbose: verbose?)
    end
  end

  def unlink_newname
    oh1 "Temporarily unlinking #{Formatter.identifier(newname)}"
    new_cellar.subdirs.each do |d|
      keg = Keg.new(d)
      keg.unlink(verbose: verbose?)
    end
  end

  def link_newname
    oh1 "Relinking #{Formatter.identifier(newname)}"
    new_keg = Keg.new(new_linked_keg_record)

    # If old_keg wasn't linked then we just optlink a keg.
    # If old keg wasn't optlinked and linked, we don't call this method at all.
    # If formula is keg-only we also optlink it.
    if formula.keg_only? || old_full_linked_kegs.empty?
      begin
        new_keg.optlink(verbose: verbose?)
      rescue Keg::LinkError => e
        onoe "Failed to create #{formula.opt_prefix}"
        raise
      end
      return
    end

    new_keg.remove_linked_keg_record if new_keg.linked?

    begin
      new_keg.link(overwrite: true, verbose: verbose?)
    rescue Keg::ConflictError => e
      onoe "The `brew link` step did not complete successfully."
      puts e
      puts
      puts "Possible conflicting files are:"
      new_keg.link(dry_run: true, overwrite: true, verbose: verbose?)
      raise
    rescue Keg::LinkError => e
      onoe "The `brew link` step did not complete successfully."
      puts e
      puts
      puts "You can try again using:"
      puts "  brew link #{formula.name}"
    # Any exception means the `brew link` step did not complete.
    rescue Exception => e # rubocop:disable Lint/RescueException
      onoe "An unexpected error occurred during linking"
      puts e
      if debug?
        require "utils/backtrace"
        puts Utils::Backtrace.clean(e)
      end
      ignore_interrupts { new_keg.unlink(verbose: verbose?) }
      raise
    end
  end

  # Link keg to opt if it was linked before migrating.
  def link_oldname_opt
    old_opt_records.each do |old_opt_record|
      old_opt_record.delete if old_opt_record.symlink?
      old_opt_record.make_relative_symlink(new_linked_keg_record)
    end
  end

  # After migration every `INSTALL_RECEIPT.json` has the wrong path to the formula
  # so we must update `INSTALL_RECEIPT`s.
  def update_tabs
    new_tabs = new_cellar.subdirs.map { |d| Keg.new(d).tab }
    new_tabs.each do |tab|
      tab.source["path"] = formula.path.to_s if tab.source["path"]
      tab.write
    end
  end

  # Remove `opt/oldname` link if it belongs to newname.
  def unlink_oldname_opt
    return unless new_linked_keg_record.exist?

    old_opt_records.each do |old_opt_record|
      next unless old_opt_record.symlink?
      next unless old_opt_record.exist?
      next if new_linked_keg_record.realpath != old_opt_record.realpath

      old_opt_record.unlink
      old_opt_record.parent.rmdir_if_possible
    end
  end

  # Remove `Cellar/oldname` if it exists.
  def link_oldname_cellar
    old_cellar.delete if old_cellar.symlink? || old_cellar.exist?
    old_cellar.make_relative_symlink(formula.rack)
  end

  # Remove `Cellar/oldname` link if it belongs to newname.
  def unlink_oldname_cellar
    if (old_cellar.symlink? && !old_cellar.exist?) ||
       (old_cellar.symlink? && formula.rack.exist? && formula.rack.realpath == old_cellar.realpath)
      old_cellar.unlink
    end
  end

  # Backup everything if errors occur while migrating.
  def backup_oldname
    unlink_oldname_opt
    unlink_oldname_cellar
    backup_oldname_cellar
    backup_old_tabs

    if pinned? && !old_pin_record.symlink?
      src_oldname = (old_pin_record.dirname/old_pin_link_record).expand_path
      old_pin_record.make_relative_symlink(src_oldname)
      new_pin_record.delete
    end

    if new_cellar.exist?
      new_cellar.subdirs.each do |d|
        newname_keg = Keg.new(d)
        newname_keg.unlink(verbose: verbose?)
        newname_keg.uninstall unless new_cellar_existed
      end
    end

    return if old_linked_kegs.empty?

    # The keg used to be linked and when we backup everything we restore
    # Cellar/oldname, the target also gets restored, so we are able to
    # create a keg using its old path
    old_full_linked_kegs.each do |old_linked_keg|
      old_linked_keg.link(verbose: verbose?)
    rescue Keg::LinkError
      old_linked_keg.unlink(verbose: verbose?)
      raise
    rescue Keg::AlreadyLinkedError
      old_linked_keg.unlink(verbose: verbose?)
      retry
    end
    (old_linked_kegs - old_full_linked_kegs).each do |old_linked_keg|
      old_linked_keg.optlink(verbose: verbose?)
    end
  end

  def backup_oldname_cellar
    FileUtils.mv(new_cellar, old_cellar) unless old_cellar.exist?
  end

  def backup_old_tabs
    old_tabs.each(&:write)
  end

  def lock
    @newname_lock = FormulaLock.new newname
    @oldname_lock = FormulaLock.new oldname
    @newname_lock.lock
    @oldname_lock.lock
  end

  def unlock
    @newname_lock.unlock
    @oldname_lock.unlock
  end
end
