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

require "api"
require "commands"
require "settings"

# A {Tap} is used to extend the formulae provided by Homebrew core.
# Usually, it's synced with a remote Git repository. And it's likely
# a GitHub repository with the name of `user/homebrew-repository`. In such
# cases, `user/repository` will be used as the {#name} of this {Tap}, where
# {#user} represents the GitHub username and {#repository} represents the
# repository name without the leading `homebrew-`.
class Tap
  extend Cachable

  HOMEBREW_TAP_CASK_RENAMES_FILE = "cask_renames.json"
  private_constant :HOMEBREW_TAP_CASK_RENAMES_FILE
  HOMEBREW_TAP_FORMULA_RENAMES_FILE = "formula_renames.json"
  private_constant :HOMEBREW_TAP_FORMULA_RENAMES_FILE
  HOMEBREW_TAP_MIGRATIONS_FILE = "tap_migrations.json"
  private_constant :HOMEBREW_TAP_MIGRATIONS_FILE
  HOMEBREW_TAP_AUTOBUMP_FILE = ".github/autobump.txt"
  private_constant :HOMEBREW_TAP_AUTOBUMP_FILE
  HOMEBREW_TAP_PYPI_FORMULA_MAPPINGS_FILE = "pypi_formula_mappings.json"
  private_constant :HOMEBREW_TAP_PYPI_FORMULA_MAPPINGS_FILE
  HOMEBREW_TAP_SYNCED_VERSIONS_FORMULAE_FILE = "synced_versions_formulae.json"
  private_constant :HOMEBREW_TAP_SYNCED_VERSIONS_FORMULAE_FILE
  HOMEBREW_TAP_AUDIT_EXCEPTIONS_DIR = "audit_exceptions"
  private_constant :HOMEBREW_TAP_AUDIT_EXCEPTIONS_DIR
  HOMEBREW_TAP_STYLE_EXCEPTIONS_DIR = "style_exceptions"
  private_constant :HOMEBREW_TAP_STYLE_EXCEPTIONS_DIR

  TAP_MIGRATIONS_STALE_SECONDS = 86400 # 1 day
  private_constant :TAP_MIGRATIONS_STALE_SECONDS

  HOMEBREW_TAP_JSON_FILES = %W[
    #{HOMEBREW_TAP_FORMULA_RENAMES_FILE}
    #{HOMEBREW_TAP_CASK_RENAMES_FILE}
    #{HOMEBREW_TAP_MIGRATIONS_FILE}
    #{HOMEBREW_TAP_PYPI_FORMULA_MAPPINGS_FILE}
    #{HOMEBREW_TAP_SYNCED_VERSIONS_FORMULAE_FILE}
    #{HOMEBREW_TAP_AUDIT_EXCEPTIONS_DIR}/*.json
    #{HOMEBREW_TAP_STYLE_EXCEPTIONS_DIR}/*.json
  ].freeze

  class InvalidNameError < ArgumentError; end

  # Fetch a {Tap} by name.
  #
  # @api public
  sig { params(user: String, repository: String).returns(Tap) }
  def self.fetch(user, repository = T.unsafe(nil))
    user, repository = user.split("/", 2) if repository.nil?

    if [user, repository].any? { |part| part.nil? || part.include?("/") }
      raise InvalidNameError, "Invalid tap name: '#{[*user, *repository].join("/")}'"
    end

    user = T.must(user)
    repository = T.must(repository)

    # We special case homebrew and linuxbrew so that users don't have to shift in a terminal.
    user = user.capitalize if ["homebrew", "linuxbrew"].include?(user)
    repository = repository.sub(HOMEBREW_OFFICIAL_REPO_PREFIXES_REGEX, "")

    return CoreTap.instance if ["Homebrew", "Linuxbrew"].include?(user) && ["core", "homebrew"].include?(repository)
    return CoreCaskTap.instance if user == "Homebrew" && repository == "cask"

    cache_key = "#{user}/#{repository}".downcase
    cache.fetch(cache_key) { |key| cache[key] = new(user, repository) }
  end

  # Get a {Tap} from its path or a path inside of it.
  #
  # @api public
  def self.from_path(path)
    match = File.expand_path(path).match(HOMEBREW_TAP_PATH_REGEX)

    return unless match
    return unless (user = match[:user])
    return unless (repository = match[:repository])

    fetch(user, repository)
  end

  sig { params(name: String).returns(T.nilable([Tap, String])) }
  def self.with_formula_name(name)
    return unless (match = name.match(HOMEBREW_TAP_FORMULA_REGEX))

    user = T.must(match[:user])
    repository = T.must(match[:repository])
    name = T.must(match[:name])

    # Relative paths are not taps.
    return if [user, repository].intersect?([".", ".."])

    tap = fetch(user, repository)
    [tap, name.downcase]
  end

  sig { params(token: String).returns(T.nilable([Tap, String])) }
  def self.with_cask_token(token)
    return unless (match = token.match(HOMEBREW_TAP_CASK_REGEX))

    user = T.must(match[:user])
    repository = T.must(match[:repository])
    token = T.must(match[:token])

    # Relative paths are not taps.
    return if [user, repository].intersect?([".", ".."])

    tap = fetch(user, repository)
    [tap, token.downcase]
  end

  sig { returns(T::Set[Tap]) }
  def self.allowed_taps
    cache_key = :"allowed_taps_#{Homebrew::EnvConfig.allowed_taps.to_s.tr(" ", "_")}"
    cache[cache_key] ||= begin
      allowed_tap_list = Homebrew::EnvConfig.allowed_taps.to_s.split

      Set.new(allowed_tap_list.filter_map do |tap|
        Tap.fetch(tap)
      rescue Tap::InvalidNameError
        opoo "Invalid tap name in `HOMEBREW_ALLOWED_TAPS`: #{tap}"
        nil
      end).freeze
    end
  end

  sig { returns(T::Set[Tap]) }
  def self.forbidden_taps
    cache_key = :"forbidden_taps_#{Homebrew::EnvConfig.forbidden_taps.to_s.tr(" ", "_")}"
    cache[cache_key] ||= begin
      forbidden_tap_list = Homebrew::EnvConfig.forbidden_taps.to_s.split

      Set.new(forbidden_tap_list.filter_map do |tap|
        Tap.fetch(tap)
      rescue Tap::InvalidNameError
        opoo "Invalid tap name in `HOMEBREW_FORBIDDEN_TAPS`: #{tap}"
        nil
      end).freeze
    end
  end

  # @api public
  extend Enumerable

  # The user name of this {Tap}. Usually, it's the GitHub username of
  # this {Tap}'s remote repository.
  #
  # @api public
  attr_reader :user

  # The repository name of this {Tap} without the leading `homebrew-`.
  #
  # @api public
  attr_reader :repository

  # @deprecated
  sig { returns(T::Boolean) }
  def repo
    # delete this whole function when removing odisabled
    odisabled "Tap#repo", "Tap#repository"
    repository
  end

  # The name of this {Tap}. It combines {#user} and {#repository} with a slash.
  # {#name} is always in lowercase.
  # e.g. `user/repository`
  #
  # @api public
  attr_reader :name

  # @api public
  sig { returns(String) }
  def to_s = name

  # The full name of this {Tap}, including the `homebrew-` prefix.
  # It combines {#user} and 'homebrew-'-prefixed {#repository} with a slash.
  # e.g. `user/homebrew-repository`
  #
  # @api public
  attr_reader :full_name

  # The local path to this {Tap}.
  # e.g. `/usr/local/Library/Taps/user/homebrew-repository`
  #
  # @api public
  sig { returns(Pathname) }
  attr_reader :path

  # The git repository of this {Tap}.
  sig { returns(GitRepository) }
  attr_reader :git_repository

  # Always use `Tap.fetch` instead of `Tap.new`.
  private_class_method :new

  def initialize(user, repository)
    require "git_repository"

    @user = user
    @repository = repository
    @name = "#{@user}/#{@repository}".downcase
    @full_name = "#{@user}/homebrew-#{@repository}"
    @path = HOMEBREW_TAP_DIRECTORY/@full_name.downcase
    @git_repository = GitRepository.new(@path)
  end

  # Clear internal cache.
  def clear_cache
    @remote = nil
    @repository_var_suffix = nil
    remove_instance_variable(:@private) if instance_variable_defined?(:@private)

    @formula_dir = nil
    @formula_files = nil
    @formula_files_by_name = nil
    @formula_names = nil
    @prefix_to_versioned_formulae_names = nil
    @formula_renames = nil
    @formula_reverse_renames = nil

    @cask_dir = nil
    @cask_files = nil
    @cask_files_by_name = nil
    @cask_tokens = nil
    @cask_renames = nil
    @cask_reverse_renames = nil

    @alias_dir = nil
    @alias_files = nil
    @aliases = nil
    @alias_table = nil
    @alias_reverse_table = nil

    @command_dir = nil
    @command_files = nil

    @tap_migrations = nil
    @reverse_tap_migrations_renames = nil

    @audit_exceptions = nil
    @style_exceptions = nil
    @pypi_formula_mappings = nil
    @synced_versions_formulae = nil

    @config = nil
    @spell_checker = nil
  end

  sig { void }
  def ensure_installed!
    return if installed?

    install
  end

  # The remote path to this {Tap}.
  # e.g. `https://github.com/user/homebrew-repository`
  #
  # @api public
  def remote
    return default_remote unless installed?

    @remote ||= git_repository.origin_url
  end

  # The remote repository name of this {Tap}.
  # e.g. `user/homebrew-repository`
  #
  # @api public
  sig { returns(T.nilable(String)) }
  def remote_repository
    return unless (remote = self.remote)
    return unless (match = remote.match(HOMEBREW_TAP_REPOSITORY_REGEX))

    @remote_repository ||= T.must(match[:remote_repository])
  end

  # @deprecated
  sig { returns(T.nilable(String)) }
  def remote_repo
    # delete this whole function when removing odisabled
    odisabled "Tap#remote_repo", "Tap#remote_repository"
    remote_repository
  end

  # The default remote path to this {Tap}.
  sig { returns(String) }
  def default_remote
    "https://github.com/#{full_name}"
  end

  sig { returns(String) }
  def repository_var_suffix
    @repository_var_suffix ||= path.to_s
                                   .delete_prefix(HOMEBREW_TAP_DIRECTORY.to_s)
                                   .tr("^A-Za-z0-9", "_")
                                   .upcase
  end

  # @deprecated
  sig { returns(String) }
  def repo_var_suffix
    # delete this whole function when removing odisabled
    odisabled "Tap#repo_var_suffix", "Tap#repository_var_suffix"
    repository_var_suffix
  end

  # Check whether this {Tap} is a Git repository.
  #
  # @api public
  def git?
    git_repository.git_repository?
  end

  # Git branch for this {Tap}.
  #
  # @api public
  def git_branch
    raise TapUnavailableError, name unless installed?

    git_repository.branch_name
  end

  # Git HEAD for this {Tap}.
  #
  # @api public
  def git_head
    raise TapUnavailableError, name unless installed?

    @git_head ||= git_repository.head_ref
  end

  # Time since last git commit for this {Tap}.
  #
  # @api public
  def git_last_commit
    raise TapUnavailableError, name unless installed?

    git_repository.last_committed
  end

  # The issues URL of this {Tap}.
  # e.g. `https://github.com/user/homebrew-repository/issues`
  #
  # @api public
  sig { returns(T.nilable(String)) }
  def issues_url
    return if !official? && custom_remote?

    "#{default_remote}/issues"
  end

  # Check whether this {Tap} is an official Homebrew tap.
  #
  # @api public
  def official?
    user == "Homebrew"
  end

  # Check whether the remote of this {Tap} is a private repository.
  #
  # @api public
  sig { returns(T::Boolean) }
  def private?
    return @private if defined?(@private)

    @private = if (value = config[:private]).nil?
      config[:private] = begin
        if custom_remote?
          true
        else
          # Don't store config if we don't know for sure.
          return false if (value = GitHub.private_repo?(full_name)).nil?

          value
        end
      rescue GitHub::API::HTTPNotFoundError
        true
      rescue GitHub::API::Error
        false
      end
    else
      value
    end
  end

  # {TapConfig} of this {Tap}.
  sig { returns(TapConfig) }
  def config
    @config ||= begin
      raise TapUnavailableError, name unless installed?

      TapConfig.new(self)
    end
  end

  # Check whether this {Tap} is installed.
  #
  # @api public
  sig { returns(T::Boolean) }
  def installed?
    path.directory?
  end

  # Check whether this {Tap} is a shallow clone.
  def shallow?
    (path/".git/shallow").exist?
  end

  sig { returns(T::Boolean) }
  def core_tap?
    false
  end

  sig { returns(T::Boolean) }
  def core_cask_tap?
    false
  end

  # Install this {Tap}.
  #
  # @param clone_target [String] If passed, it will be used as the clone remote.
  # @param quiet [Boolean] If set, suppress all output.
  # @param custom_remote [Boolean] If set, change the tap's remote if already installed.
  # @param verify [Boolean] If set, verify all the formula, casks and aliases in the tap are valid.
  # @param force [Boolean] If set, force core and cask taps to install even under API mode.
  #
  # @api public
  def install(quiet: false, clone_target: nil,
              custom_remote: false, verify: false, force: false)
    require "descriptions"
    require "readall"

    if official? && DEPRECATED_OFFICIAL_TAPS.include?(repository)
      odie "#{name} was deprecated. This tap is now empty and all its contents were either deleted or migrated."
    elsif user == "caskroom" || name == "phinze/cask"
      new_repository = (repository == "cask") ? "cask" : "cask-#{repository}"
      odie "#{name} was moved. Tap homebrew/#{new_repository} instead."
    end

    raise TapNoCustomRemoteError, name if custom_remote && clone_target.nil?

    requested_remote = clone_target || default_remote

    if installed? && !custom_remote
      raise TapRemoteMismatchError.new(name, @remote, requested_remote) if clone_target && requested_remote != remote
      raise TapAlreadyTappedError, name unless shallow?
    end

    if !allowed_by_env? || forbidden_by_env?
      owner = Homebrew::EnvConfig.forbidden_owner
      owner_contact = if (contact = Homebrew::EnvConfig.forbidden_owner_contact.presence)
        "\n#{contact}"
      end

      error_message = "The installation of the #{full_name} was requested but #{owner}\n"
      error_message << "has not allowed this tap in `HOMEBREW_ALLOWED_TAPS`" unless allowed_by_env?
      error_message << " and\n" if !allowed_by_env? && forbidden_by_env?
      error_message << "has forbidden this tap in `HOMEBREW_FORBIDDEN_TAPS`" if forbidden_by_env?
      error_message << ".#{owner_contact}"

      odie error_message
    end

    # ensure git is installed
    Utils::Git.ensure_installed!

    if installed?
      if requested_remote != remote # we are sure that clone_target is not nil and custom_remote is true here
        fix_remote_configuration(requested_remote:, quiet:)
      end

      config.delete(:forceautoupdate)

      $stderr.ohai "Unshallowing #{name}" if shallow? && !quiet
      args = %w[fetch]
      # Git throws an error when attempting to unshallow a full clone
      args << "--unshallow" if shallow?
      args << "-q" if quiet
      path.cd { safe_system "git", *args }
      return
    elsif (core_tap? || core_cask_tap?) && !Homebrew::EnvConfig.no_install_from_api? && !force
      odie "Tapping #{name} is no longer typically necessary.\n" \
           "Add #{Formatter.option("--force")} if you are sure you need it for contributing to Homebrew."
    end

    clear_cache
    Tap.clear_cache

    $stderr.ohai "Tapping #{name}" unless quiet
    args =  %W[clone #{requested_remote} #{path}]

    # Override possible user configs like:
    #   git config --global clone.defaultRemoteName notorigin
    args << "--origin=origin"
    args << "-q" if quiet

    # Override user-set default template.
    args << "--template="
    # Prevent `fsmonitor` from watching this repository.
    args << "--config" << "core.fsmonitor=false"

    begin
      safe_system "git", *args

      if verify && !Homebrew::EnvConfig.developer? && !Readall.valid_tap?(self, aliases: true)
        raise "Cannot tap #{name}: invalid syntax in tap!"
      end
    rescue Interrupt, RuntimeError
      ignore_interrupts do
        # wait for git to possibly cleanup the top directory when interrupt happens.
        sleep 0.1
        FileUtils.rm_rf path
        path.parent.rmdir_if_possible
      end
      raise
    end

    Commands.rebuild_commands_completion_list
    link_completions_and_manpages

    formatted_contents = contents.presence&.to_sentence&.prepend(" ")
    $stderr.puts "Tapped#{formatted_contents} (#{path.abv})." unless quiet

    require "description_cache_store"
    CacheStoreDatabase.use(:descriptions) do |db|
      DescriptionCacheStore.new(db)
                           .update_from_formula_names!(formula_names)
    end
    CacheStoreDatabase.use(:cask_descriptions) do |db|
      CaskDescriptionCacheStore.new(db)
                               .update_from_cask_tokens!(cask_tokens)
    end

    if official?
      untapped = self.class.untapped_official_taps
      untapped -= [name]

      if untapped.empty?
        Homebrew::Settings.delete :untapped
      else
        Homebrew::Settings.write :untapped, untapped.join(";")
      end
    end

    return if clone_target
    return unless private?
    return if quiet

    path.cd do
      return if Utils.popen_read("git", "config", "--get", "credential.helper").present?
    end

    $stderr.puts <<~EOS
      It looks like you tapped a private repository. To avoid entering your
      credentials each time you update, you can use git HTTP credential
      caching or issue the following command:
        cd #{path}
        git remote set-url origin git@github.com:#{full_name}.git
    EOS
  end

  def link_completions_and_manpages
    require "utils/link"

    command = "brew tap --repair"
    Utils::Link.link_manpages(path, command)

    require "completions"
    Homebrew::Completions.show_completions_message_if_needed
    if official? || Homebrew::Completions.link_completions?
      Utils::Link.link_completions(path, command)
    else
      Utils::Link.unlink_completions(path)
    end
  end

  def fix_remote_configuration(requested_remote: nil, quiet: false)
    if requested_remote.present?
      path.cd do
        safe_system "git", "remote", "set-url", "origin", requested_remote
        safe_system "git", "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"
      end
      $stderr.ohai "#{name}: changed remote from #{remote} to #{requested_remote}" unless quiet
    end
    return unless remote

    current_upstream_head = git_repository.origin_branch_name
    return if current_upstream_head.present? && requested_remote.blank? &&
              git_repository.origin_has_branch?(current_upstream_head)

    args = %w[fetch]
    args << "--quiet" if quiet
    args << "origin"
    args << "+refs/heads/*:refs/remotes/origin/*"
    safe_system "git", "-C", path, *args
    git_repository.set_head_origin_auto

    current_upstream_head ||= T.must(git_repository.origin_branch_name)

    new_upstream_head = T.must(git_repository.origin_branch_name)
    return if new_upstream_head == current_upstream_head

    safe_system "git", "-C", path, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"
    git_repository.rename_branch old: current_upstream_head, new: new_upstream_head
    git_repository.set_upstream_branch local: new_upstream_head, origin: new_upstream_head

    return if quiet

    $stderr.ohai "#{name}: changed default branch name from #{current_upstream_head} to #{new_upstream_head}!"
  end

  # Uninstall this {Tap}.
  #
  # @api public
  def uninstall(manual: false)
    require "descriptions"
    raise TapUnavailableError, name unless installed?

    $stderr.puts "Untapping #{name}..."

    abv = path.abv
    formatted_contents = contents.presence&.to_sentence&.prepend(" ")

    require "description_cache_store"
    CacheStoreDatabase.use(:descriptions) do |db|
      DescriptionCacheStore.new(db)
                           .delete_from_formula_names!(formula_names)
    end
    CacheStoreDatabase.use(:cask_descriptions) do |db|
      CaskDescriptionCacheStore.new(db)
                               .delete_from_cask_tokens!(cask_tokens)
    end

    require "utils/link"
    Utils::Link.unlink_manpages(path)
    Utils::Link.unlink_completions(path)
    FileUtils.rm_r(path)
    path.parent.rmdir_if_possible
    $stderr.puts "Untapped#{formatted_contents} (#{abv})."

    Commands.rebuild_commands_completion_list
    clear_cache
    Tap.clear_cache

    return if !manual || !official?

    untapped = self.class.untapped_official_taps
    return if untapped.include? name

    untapped << name
    Homebrew::Settings.write :untapped, untapped.join(";")
  end

  # Check whether the {#remote} of {Tap} is customized.
  #
  # @api public
  sig { returns(T::Boolean) }
  def custom_remote?
    return true unless (remote = self.remote)

    !remote.casecmp(default_remote).zero?
  end

  # Path to the directory of all {Formula} files for this {Tap}.
  #
  # @api public
  sig { returns(Pathname) }
  def formula_dir
    # Official formulae taps always use this directory, saves time to hardcode.
    @formula_dir ||= if official?
      path/"Formula"
    else
      potential_formula_dirs.find(&:directory?) || (path/"Formula")
    end
  end

  sig { returns(T::Array[Pathname]) }
  def potential_formula_dirs
    @potential_formula_dirs ||= [path/"Formula", path/"HomebrewFormula", path].freeze
  end

  sig { params(name: String).returns(Pathname) }
  def new_formula_path(name)
    formula_dir/"#{name.downcase}.rb"
  end

  # Path to the directory of all {Cask} files for this {Tap}.
  #
  # @api public
  sig { returns(Pathname) }
  def cask_dir
    @cask_dir ||= path/"Casks"
  end

  sig { params(token: String).returns(Pathname) }
  def new_cask_path(token)
    cask_dir/"#{token.downcase}.rb"
  end

  sig { params(token: String).returns(String) }
  def relative_cask_path(token)
    new_cask_path(token).to_s
                        .delete_prefix("#{path}/")
  end

  def contents
    contents = []

    if (command_count = command_files.count).positive?
      contents << Utils.pluralize("command", command_count, include_count: true)
    end

    if (cask_count = cask_files.count).positive?
      contents << Utils.pluralize("cask", cask_count, include_count: true)
    end

    if (formula_count = formula_files.count).positive?
      contents << Utils.pluralize("formula", formula_count, plural: "e", include_count: true)
    end

    contents
  end

  # An array of all {Formula} files of this {Tap}.
  sig { returns(T::Array[Pathname]) }
  def formula_files
    @formula_files ||= if formula_dir.directory?
      if formula_dir == path
        # We only want the top level here so we don't treat commands & casks as formulae.
        # Sharding is only supported in Formula/ and HomebrewFormula/.
        Pathname.glob(formula_dir/"*.rb")
      else
        Pathname.glob(formula_dir/"**/*.rb")
      end
    else
      []
    end
  end

  # A mapping of {Formula} names to {Formula} file paths.
  sig { returns(T::Hash[String, Pathname]) }
  def formula_files_by_name
    @formula_files_by_name ||= formula_files.each_with_object({}) do |file, hash|
      # If there's more than one file with the same basename: use the longer one to prioritise more specific results.
      basename = file.basename(".rb").to_s
      existing_file = hash[basename]
      hash[basename] = file if existing_file.nil? || existing_file.to_s.length < file.to_s.length
    end
  end

  # An array of all {Cask} files of this {Tap}.
  sig { returns(T::Array[Pathname]) }
  def cask_files
    @cask_files ||= if cask_dir.directory?
      Pathname.glob(cask_dir/"**/*.rb")
    else
      []
    end
  end

  # A mapping of {Cask} tokens to {Cask} file paths.
  sig { returns(T::Hash[String, Pathname]) }
  def cask_files_by_name
    @cask_files_by_name ||= cask_files.each_with_object({}) do |file, hash|
      # If there's more than one file with the same basename: use the longer one to prioritise more specific results.
      basename = file.basename(".rb").to_s
      existing_file = hash[basename]
      hash[basename] = file if existing_file.nil? || existing_file.to_s.length < file.to_s.length
    end
  end

  RUBY_FILE_NAME_REGEX = %r{[^/]+\.rb}
  private_constant :RUBY_FILE_NAME_REGEX

  ZERO_OR_MORE_SUBDIRECTORIES_REGEX = %r{(?:[^/]+/)*}
  private_constant :ZERO_OR_MORE_SUBDIRECTORIES_REGEX

  sig { returns(Regexp) }
  def formula_file_regex
    @formula_file_regex ||= case formula_dir
    when path/"Formula"
      %r{^Formula/#{ZERO_OR_MORE_SUBDIRECTORIES_REGEX.source}#{RUBY_FILE_NAME_REGEX.source}$}o
    when path/"HomebrewFormula"
      %r{^HomebrewFormula/#{ZERO_OR_MORE_SUBDIRECTORIES_REGEX.source}#{RUBY_FILE_NAME_REGEX.source}$}o
    when path
      /^#{RUBY_FILE_NAME_REGEX.source}$/o
    else
      raise ArgumentError, "Unexpected formula_dir: #{formula_dir}"
    end
  end
  private :formula_file_regex

  # accepts the relative path of a file from {Tap}'s path
  sig { params(file: String).returns(T::Boolean) }
  def formula_file?(file)
    file.match?(formula_file_regex)
  end

  CASK_FILE_REGEX = %r{^Casks/#{ZERO_OR_MORE_SUBDIRECTORIES_REGEX.source}#{RUBY_FILE_NAME_REGEX.source}$}
  private_constant :CASK_FILE_REGEX

  # accepts the relative path of a file from {Tap}'s path
  sig { params(file: String).returns(T::Boolean) }
  def cask_file?(file)
    file.match?(CASK_FILE_REGEX)
  end

  # An array of all {Formula} names of this {Tap}.
  sig { returns(T::Array[String]) }
  def formula_names
    @formula_names ||= formula_files.map { formula_file_to_name(_1) }
  end

  # A hash of all {Formula} name prefixes to versioned {Formula} in this {Tap}.
  sig { returns(T::Hash[String, T::Array[String]]) }
  def prefix_to_versioned_formulae_names
    @prefix_to_versioned_formulae_names ||= formula_names
                                            .select { |name| name.include?("@") }
                                            .group_by { |name| name.gsub(/(@[\d.]+)?$/, "") }
                                            .transform_values(&:sort)
                                            .freeze
  end

  # An array of all {Cask} tokens of this {Tap}.
  sig { returns(T::Array[String]) }
  def cask_tokens
    @cask_tokens ||= cask_files.map { formula_file_to_name(_1) }
  end

  # Path to the directory of all alias files for this {Tap}.
  sig { returns(Pathname) }
  def alias_dir
    @alias_dir ||= path/"Aliases"
  end

  # An array of all alias files of this {Tap}.
  sig { returns(T::Array[Pathname]) }
  def alias_files
    @alias_files ||= Pathname.glob("#{alias_dir}/*").select(&:file?)
  end

  # An array of all aliases of this {Tap}.
  sig { returns(T::Array[String]) }
  def aliases
    @aliases ||= alias_table.keys
  end

  # Mapping from aliases to formula names.
  sig { returns(T::Hash[String, String]) }
  def alias_table
    @alias_table ||= alias_files.each_with_object({}) do |alias_file, alias_table|
      alias_table[alias_file_to_name(alias_file)] = formula_file_to_name(alias_file.resolved_path)
    end
  end

  # Mapping from formula names to aliases.
  sig { returns(T::Hash[String, T::Array[String]]) }
  def alias_reverse_table
    @alias_reverse_table ||= alias_table.each_with_object({}) do |(alias_name, formula_name), alias_reverse_table|
      alias_reverse_table[formula_name] ||= []
      alias_reverse_table[formula_name] << alias_name
    end
  end

  sig { returns(Pathname) }
  def command_dir
    @command_dir ||= path/"cmd"
  end

  # An array of all commands files of this {Tap}.
  sig { returns(T::Array[Pathname]) }
  def command_files
    @command_files ||= if command_dir.directory?
      Commands.find_commands(command_dir)
    else
      []
    end
  end

  sig { returns(Hash) }
  def to_hash
    hash = {
      "name"          => name,
      "user"          => user,
      "repo"          => repository,
      "repository"    => repository,
      "path"          => path.to_s,
      "installed"     => installed?,
      "official"      => official?,
      "formula_names" => formula_names,
      "cask_tokens"   => cask_tokens,
    }

    if installed?
      hash["formula_files"] = formula_files.map(&:to_s)
      hash["cask_files"] = cask_files.map(&:to_s)
      hash["command_files"] = command_files.map(&:to_s)
      hash["remote"] = remote
      hash["custom_remote"] = custom_remote?
      hash["private"] = private?
      hash["HEAD"] = git_head || "(none)"
      hash["last_commit"] = git_last_commit || "never"
      hash["branch"] = git_branch || "(none)"
    end

    hash
  end

  # Hash with tap cask renames.
  sig { returns(T::Hash[String, String]) }
  def cask_renames
    @cask_renames ||= if (rename_file = path/HOMEBREW_TAP_CASK_RENAMES_FILE).file?
      JSON.parse(rename_file.read)
    else
      {}
    end
  end

  # Mapping from new to old cask tokens. Reverse of {#cask_renames}.
  sig { returns(T::Hash[String, T::Array[String]]) }
  def cask_reverse_renames
    @cask_reverse_renames ||= cask_renames.each_with_object({}) do |(old_name, new_name), hash|
      hash[new_name] ||= []
      hash[new_name] << old_name
    end
  end

  # Hash with tap formula renames.
  sig { returns(T::Hash[String, String]) }
  def formula_renames
    @formula_renames ||= if (rename_file = path/HOMEBREW_TAP_FORMULA_RENAMES_FILE).file?
      JSON.parse(rename_file.read)
    else
      {}
    end
  end

  # Mapping from new to old formula names. Reverse of {#formula_renames}.
  sig { returns(T::Hash[String, T::Array[String]]) }
  def formula_reverse_renames
    @formula_reverse_renames ||= formula_renames.each_with_object({}) do |(old_name, new_name), hash|
      hash[new_name] ||= []
      hash[new_name] << old_name
    end
  end

  # Hash with tap migrations.
  sig { returns(T::Hash[String, String]) }
  def tap_migrations
    @tap_migrations ||= if (migration_file = path/HOMEBREW_TAP_MIGRATIONS_FILE).file?
      JSON.parse(migration_file.read)
    else
      {}
    end
  end

  sig { returns(T::Hash[String, T::Array[String]]) }
  def reverse_tap_migrations_renames
    @reverse_tap_migrations_renames ||= tap_migrations.each_with_object({}) do |(old_name, new_name), hash|
      # Only include renames:
      # + `homebrew/cask/water-buffalo`
      # - `homebrew/cask`
      next if new_name.count("/") != 2

      hash[new_name] ||= []
      hash[new_name] << old_name
    end
  end

  # The old names a formula or cask had before getting migrated to the current tap.
  sig { params(current_tap: Tap, name_or_token: String).returns(T::Array[String]) }
  def self.tap_migration_oldnames(current_tap, name_or_token)
    key = "#{current_tap}/#{name_or_token}"

    Tap.each_with_object([]) do |tap, array|
      next unless (renames = tap.reverse_tap_migrations_renames[key])

      array.concat(renames)
    end
  end

  # Array with autobump names
  sig { returns(T::Array[String]) }
  def autobump
    autobump_packages = if core_cask_tap?
      Homebrew::API::Cask.all_casks
    elsif core_tap?
      Homebrew::API::Formula.all_formulae
    else
      {}
    end

    @autobump ||= autobump_packages.select do |_, p|
      next if p["deprecated"] || p["disabled"]
      next if p["skip_livecheck"]

      p["autobump"] == true
    end.keys

    if @autobump.blank?
      @autobump = if (autobump_file = path/HOMEBREW_TAP_AUTOBUMP_FILE).file?
        autobump_file.readlines(chomp: true)
      else
        []
      end
    end

    @autobump
  end

  # Whether this {Tap} allows running bump commands on the given {Formula} or {Cask}.
  sig { params(formula_or_cask_name: String).returns(T::Boolean) }
  def allow_bump?(formula_or_cask_name)
    ENV["HOMEBREW_TEST_BOT_AUTOBUMP"].present? || !official? || autobump.exclude?(formula_or_cask_name)
  end

  # Hash with audit exceptions
  sig { returns(Hash) }
  def audit_exceptions
    @audit_exceptions ||= read_formula_list_directory("#{HOMEBREW_TAP_AUDIT_EXCEPTIONS_DIR}/*")
  end

  # Hash with style exceptions
  sig { returns(Hash) }
  def style_exceptions
    @style_exceptions ||= read_formula_list_directory("#{HOMEBREW_TAP_STYLE_EXCEPTIONS_DIR}/*")
  end

  # Hash with pypi formula mappings
  sig { returns(Hash) }
  def pypi_formula_mappings
    @pypi_formula_mappings ||= read_formula_list(path/HOMEBREW_TAP_PYPI_FORMULA_MAPPINGS_FILE)
  end

  # Array with synced versions formulae
  sig { returns(T::Array[T::Array[String]]) }
  def synced_versions_formulae
    @synced_versions_formulae ||= if (synced_file = path/HOMEBREW_TAP_SYNCED_VERSIONS_FORMULAE_FILE).file?
      JSON.parse(synced_file.read)
    else
      []
    end
  end

  sig { returns(T::Boolean) }
  def should_report_analytics?
    installed? && !private?
  end

  sig { params(other: T.nilable(T.any(String, Tap))).returns(T::Boolean) }
  def ==(other)
    other = Tap.fetch(other) if other.is_a?(String)
    other.is_a?(self.class) && name == other.name
  end
  alias eql? ==

  sig { returns(Integer) }
  def hash
    [self.class, name].hash
  end

  # All locally installed taps.
  #
  # @api public
  sig { returns(T::Array[Tap]) }
  def self.installed
    cache[:installed] ||= if HOMEBREW_TAP_DIRECTORY.directory?
      HOMEBREW_TAP_DIRECTORY.subdirs.flat_map(&:subdirs).map { from_path(_1) }
    else
      []
    end
  end

  # All locally installed and core taps. Core taps might not be installed locally when using the API.
  sig { returns(T::Array[Tap]) }
  def self.all
    cache[:all] ||= installed | core_taps
  end

  sig { returns(T::Array[Tap]) }
  def self.core_taps
    [CoreTap.instance].freeze
  end

  # Enumerate all available {Tap}s.
  #
  # @api public
  def self.each(&block)
    if Homebrew::EnvConfig.no_install_from_api?
      installed.each(&block)
    else
      all.each(&block)
    end
  end

  # An array of official taps that have been manually untapped
  sig { returns(T::Array[String]) }
  def self.untapped_official_taps
    Homebrew::Settings.read(:untapped)&.split(";") || []
  end

  sig { params(file: Pathname).returns(String) }
  def formula_file_to_name(file)
    "#{name}/#{file.basename(".rb")}"
  end

  sig { params(file: Pathname).returns(String) }
  def alias_file_to_name(file)
    "#{name}/#{file.basename}"
  end

  def audit_exception(list, formula_or_cask, value = nil)
    return false if audit_exceptions.blank?
    return false unless audit_exceptions.key? list

    list = audit_exceptions[list]

    case list
    when Array
      list.include? formula_or_cask
    when Hash
      return false unless list.include? formula_or_cask
      return list[formula_or_cask] if value.blank?

      return list[formula_or_cask].include?(value) if list[formula_or_cask].is_a?(Array)

      list[formula_or_cask] == value
    end
  end

  sig { returns(T::Boolean) }
  def allowed_by_env?
    @allowed_by_env ||= begin
      allowed_taps = self.class.allowed_taps

      official? || allowed_taps.blank? || allowed_taps.include?(self)
    end
  end

  sig { returns(T::Boolean) }
  def forbidden_by_env?
    @forbidden_by_env ||= self.class.forbidden_taps.include?(self)
  end

  private

  sig { params(file: Pathname).returns(T.any(T::Array[String], Hash)) }
  def read_formula_list(file)
    JSON.parse file.read
  rescue JSON::ParserError
    opoo "#{file} contains invalid JSON"
    {}
  rescue Errno::ENOENT
    {}
  end

  sig { params(directory: String).returns(Hash) }
  def read_formula_list_directory(directory)
    list = {}

    Pathname.glob(path/directory).each do |exception_file|
      list_name = exception_file.basename.to_s.chomp(".json").to_sym
      list_contents = read_formula_list exception_file

      next if list_contents.blank?

      list[list_name] = list_contents
    end

    list
  end
end

class AbstractCoreTap < Tap
  extend T::Helpers

  abstract!

  private_class_method :fetch

  # Get the singleton instance for this {Tap}.
  #
  # @api internal
  sig { returns(T.attached_class) }
  def self.instance
    @instance ||= T.unsafe(self).new
  end

  sig { override.void }
  def ensure_installed!
    return unless Homebrew::EnvConfig.no_install_from_api?
    return if Homebrew::EnvConfig.automatically_set_no_install_from_api?

    super
  end

  sig { params(file: Pathname).returns(String) }
  def formula_file_to_name(file)
    file.basename(".rb").to_s
  end

  sig { override.returns(T::Boolean) }
  def should_report_analytics?
    return super if Homebrew::EnvConfig.no_install_from_api?

    true
  end
end

# A specialized {Tap} class for the core formulae.
class CoreTap < AbstractCoreTap
  sig { void }
  def initialize
    super "Homebrew", "core"
  end

  sig { override.void }
  def ensure_installed!
    return if ENV["HOMEBREW_TESTS"]

    super
  end

  sig { returns(T.nilable(String)) }
  def remote
    return super if Homebrew::EnvConfig.no_install_from_api?

    Homebrew::EnvConfig.core_git_remote
  end

  # CoreTap never allows shallow clones (on request from GitHub).
  def install(quiet: false, clone_target: nil,
              custom_remote: false, verify: false, force: false)
    remote = Homebrew::EnvConfig.core_git_remote # set by HOMEBREW_CORE_GIT_REMOTE
    requested_remote = clone_target || remote

    # The remote will changed again on `brew update` since remotes for homebrew/core are mismatched
    raise TapCoreRemoteMismatchError.new(name, remote, requested_remote) if requested_remote != remote

    if remote != default_remote
      $stderr.puts "HOMEBREW_CORE_GIT_REMOTE set: using #{remote} as the Homebrew/homebrew-core Git remote."
    end

    super(quiet:, clone_target: remote, custom_remote:, force:)
  end

  sig { params(manual: T::Boolean).void }
  def uninstall(manual: false)
    raise "Tap#uninstall is not available for CoreTap" if Homebrew::EnvConfig.no_install_from_api?

    super
  end

  sig { returns(T::Boolean) }
  def core_tap?
    true
  end

  sig { returns(T::Boolean) }
  def linuxbrew_core?
    remote_repository.to_s.end_with?("/linuxbrew-core") || remote_repository == "Linuxbrew/homebrew-core"
  end

  sig { returns(Pathname) }
  def formula_dir
    @formula_dir ||= begin
      ensure_installed!
      super
    end
  end

  sig { params(name: String).returns(Pathname) }
  def new_formula_path(name)
    formula_subdir = if name.start_with?("lib")
      "lib"
    else
      name[0].to_s
    end

    return super unless (formula_dir/formula_subdir).directory?

    formula_dir/formula_subdir/"#{name.downcase}.rb"
  end

  sig { returns(Pathname) }
  def alias_dir
    @alias_dir ||= begin
      ensure_installed!
      super
    end
  end

  sig { returns(T::Hash[String, String]) }
  def formula_renames
    @formula_renames ||= if Homebrew::EnvConfig.no_install_from_api?
      ensure_installed!
      super
    else
      Homebrew::API::Formula.all_renames
    end
  end

  sig { returns(Hash) }
  def tap_migrations
    @tap_migrations ||= if Homebrew::EnvConfig.no_install_from_api?
      ensure_installed!
      super
    else
      migrations, = Homebrew::API.fetch_json_api_file "formula_tap_migrations.jws.json",
                                                      stale_seconds: TAP_MIGRATIONS_STALE_SECONDS
      migrations
    end
  end

  sig { returns(T::Array[String]) }
  def autobump
    @autobump ||= begin
      ensure_installed!
      super
    end
  end

  sig { returns(Hash) }
  def audit_exceptions
    @audit_exceptions ||= begin
      ensure_installed!
      super
    end
  end

  sig { returns(Hash) }
  def style_exceptions
    @style_exceptions ||= begin
      ensure_installed!
      super
    end
  end

  sig { returns(Hash) }
  def pypi_formula_mappings
    @pypi_formula_mappings ||= begin
      ensure_installed!
      super
    end
  end

  sig { returns(T::Array[T::Array[String]]) }
  def synced_versions_formulae
    @synced_versions_formulae ||= begin
      ensure_installed!
      super
    end
  end

  sig { params(file: Pathname).returns(String) }
  def alias_file_to_name(file)
    file.basename.to_s
  end

  sig { returns(T::Hash[String, String]) }
  def alias_table
    @alias_table ||= if Homebrew::EnvConfig.no_install_from_api?
      super
    else
      Homebrew::API::Formula.all_aliases
    end
  end

  sig { returns(T::Array[Pathname]) }
  def formula_files
    return super if Homebrew::EnvConfig.no_install_from_api?

    formula_files_by_name.values
  end

  sig { returns(T::Array[String]) }
  def formula_names
    return super if Homebrew::EnvConfig.no_install_from_api?

    Homebrew::API::Formula.all_formulae.keys
  end

  sig { returns(T::Hash[String, Pathname]) }
  def formula_files_by_name
    return super if Homebrew::EnvConfig.no_install_from_api?

    @formula_files_by_name ||= begin
      tap_path = path.to_s
      Homebrew::API::Formula.all_formulae.each_with_object({}) do |item, hash|
        name, formula_hash = item
        # If there's more than one item with the same path: use the longer one to prioritise more specific results.
        existing_path = hash[name]
        # Pathname equivalent is slow in a tight loop
        new_path = File.join(tap_path, formula_hash.fetch("ruby_source_path"))
        hash[name] = Pathname(new_path) if existing_path.nil? || existing_path.to_s.length < new_path.length
      end
    end
  end
end

# A specialized {Tap} class for homebrew-cask.
class CoreCaskTap < AbstractCoreTap
  sig { void }
  def initialize
    super "Homebrew", "cask"
  end

  sig { override.returns(T::Boolean) }
  def core_cask_tap?
    true
  end

  sig { params(token: String).returns(Pathname) }
  def new_cask_path(token)
    cask_subdir = if token.start_with?("font-")
      "font/font-#{token.delete_prefix("font-")[0]}"
    else
      token[0].to_s
    end
    cask_dir/cask_subdir/"#{token.downcase}.rb"
  end

  sig { override.returns(T::Array[Pathname]) }
  def cask_files
    return super if Homebrew::EnvConfig.no_install_from_api?

    cask_files_by_name.values
  end

  sig { override.returns(T::Array[String]) }
  def cask_tokens
    return super if Homebrew::EnvConfig.no_install_from_api?

    Homebrew::API::Cask.all_casks.keys
  end

  sig { override.returns(T::Hash[String, Pathname]) }
  def cask_files_by_name
    return super if Homebrew::EnvConfig.no_install_from_api?

    @cask_files_by_name ||= begin
      tap_path = path.to_s
      Homebrew::API::Cask.all_casks.each_with_object({}) do |item, hash|
        name, cask_hash = item
        # If there's more than one item with the same path: use the longer one to prioritise more specific results.
        existing_path = hash[name]
        # Pathname equivalent is slow in a tight loop
        new_path = File.join(tap_path, cask_hash.fetch("ruby_source_path"))
        hash[name] = Pathname(new_path) if existing_path.nil? || existing_path.to_s.length < new_path.length
      end
    end
  end

  sig { override.returns(T::Hash[String, String]) }
  def cask_renames
    @cask_renames ||= if Homebrew::EnvConfig.no_install_from_api?
      super
    else
      Homebrew::API::Cask.all_renames
    end
  end

  sig { override.returns(Hash) }
  def tap_migrations
    @tap_migrations ||= if Homebrew::EnvConfig.no_install_from_api?
      super
    else
      migrations, = Homebrew::API.fetch_json_api_file "cask_tap_migrations.jws.json",
                                                      stale_seconds: TAP_MIGRATIONS_STALE_SECONDS
      migrations
    end
  end
end

# Permanent configuration per {Tap} using `git-config(1)`.
class TapConfig
  sig { returns(Tap) }
  attr_reader :tap

  sig { params(tap: Tap).void }
  def initialize(tap)
    @tap = tap
  end

  sig { params(key: Symbol).returns(T.nilable(T::Boolean)) }
  def [](key)
    return unless tap.git?
    return unless Utils::Git.available?

    case Homebrew::Settings.read(key, repo: tap.path)
    when "true" then true
    when "false" then false
    end
  end

  sig { params(key: Symbol, value: T::Boolean).void }
  def []=(key, value)
    return unless tap.git?
    return unless Utils::Git.available?

    Homebrew::Settings.write key, value.to_s, repo: tap.path
  end

  sig { params(key: Symbol).void }
  def delete(key)
    return unless tap.git?
    return unless Utils::Git.available?

    Homebrew::Settings.delete key, repo: tap.path
  end
end

require "extend/os/tap"
