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

class Keg
  PREFIX_PLACEHOLDER = "@@HOMEBREW_PREFIX@@"
  CELLAR_PLACEHOLDER = "@@HOMEBREW_CELLAR@@"
  REPOSITORY_PLACEHOLDER = "@@HOMEBREW_REPOSITORY@@"
  LIBRARY_PLACEHOLDER = "@@HOMEBREW_LIBRARY@@"
  PERL_PLACEHOLDER = "@@HOMEBREW_PERL@@"
  JAVA_PLACEHOLDER = "@@HOMEBREW_JAVA@@"
  NULL_BYTE = "\x00"
  NULL_BYTE_STRING = "\\x00"

  class Relocation
    RELOCATABLE_PATH_REGEX_PREFIX = /(?:(?<=-F|-I|-L|-isystem)|(?<![a-zA-Z0-9]))/

    def initialize
      @replacement_map = T.let({}, T::Hash[Symbol, [T.any(String, Regexp), String]])
    end

    def freeze
      @replacement_map.freeze
      super
    end

    sig { params(key: Symbol, old_value: T.any(String, Regexp), new_value: String, path: T::Boolean).void }
    def add_replacement_pair(key, old_value, new_value, path: false)
      old_value = self.class.path_to_regex(old_value) if path
      @replacement_map[key] = [old_value, new_value]
    end

    sig { params(key: Symbol).returns([T.any(String, Regexp), String]) }
    def replacement_pair_for(key)
      @replacement_map.fetch(key)
    end

    sig { params(text: String).returns(T::Boolean) }
    def replace_text(text)
      replacements = @replacement_map.values.to_h

      sorted_keys = replacements.keys.sort_by do |key|
        key.is_a?(String) ? key.length : 999
      end.reverse

      any_changed = T.let(nil, T.nilable(String))
      sorted_keys.each do |key|
        changed = text.gsub!(key, replacements.fetch(key))
        any_changed ||= changed
      end
      !any_changed.nil?
    end

    sig { params(path: T.any(String, Regexp)).returns(Regexp) }
    def self.path_to_regex(path)
      path = case path
      when String
        Regexp.escape(path)
      when Regexp
        path.source
      end
      Regexp.new(RELOCATABLE_PATH_REGEX_PREFIX.source + path)
    end
  end

  def fix_dynamic_linkage
    symlink_files.each do |file|
      link = file.readlink
      # Don't fix relative symlinks
      next unless link.absolute?

      link_starts_cellar = link.to_s.start_with?(HOMEBREW_CELLAR.to_s)
      link_starts_prefix = link.to_s.start_with?(HOMEBREW_PREFIX.to_s)
      next if !link_starts_cellar && !link_starts_prefix

      new_src = link.relative_path_from(file.parent)
      file.unlink
      FileUtils.ln_s(new_src, file)
    end
  end
  alias generic_fix_dynamic_linkage fix_dynamic_linkage

  def relocate_dynamic_linkage(_relocation)
    []
  end

  JAVA_REGEX = %r{#{HOMEBREW_PREFIX}/opt/openjdk(@\d+(\.\d+)*)?/libexec(/openjdk\.jdk/Contents/Home)?}

  def prepare_relocation_to_placeholders
    relocation = Relocation.new
    relocation.add_replacement_pair(:prefix, HOMEBREW_PREFIX.to_s, PREFIX_PLACEHOLDER, path: true)
    relocation.add_replacement_pair(:cellar, HOMEBREW_CELLAR.to_s, CELLAR_PLACEHOLDER, path: true)
    # when HOMEBREW_PREFIX == HOMEBREW_REPOSITORY we should use HOMEBREW_PREFIX for all relocations to avoid
    # being unable to differentiate between them.
    if HOMEBREW_PREFIX != HOMEBREW_REPOSITORY
      relocation.add_replacement_pair(:repository, HOMEBREW_REPOSITORY.to_s, REPOSITORY_PLACEHOLDER, path: true)
    end
    relocation.add_replacement_pair(:library, HOMEBREW_LIBRARY.to_s, LIBRARY_PLACEHOLDER, path: true)
    relocation.add_replacement_pair(:perl,
                                    %r{\A#![ \t]*(?:/usr/bin/perl\d\.\d+|#{HOMEBREW_PREFIX}/opt/perl/bin/perl)( |$)}o,
                                    "#!#{PERL_PLACEHOLDER}\\1")
    relocation.add_replacement_pair(:java, JAVA_REGEX, JAVA_PLACEHOLDER)

    relocation
  end
  alias generic_prepare_relocation_to_placeholders prepare_relocation_to_placeholders

  def replace_locations_with_placeholders
    relocation = prepare_relocation_to_placeholders.freeze
    relocate_dynamic_linkage(relocation)
    replace_text_in_files(relocation)
  end

  def prepare_relocation_to_locations
    relocation = Relocation.new
    relocation.add_replacement_pair(:prefix, PREFIX_PLACEHOLDER, HOMEBREW_PREFIX.to_s)
    relocation.add_replacement_pair(:cellar, CELLAR_PLACEHOLDER, HOMEBREW_CELLAR.to_s)
    relocation.add_replacement_pair(:repository, REPOSITORY_PLACEHOLDER, HOMEBREW_REPOSITORY.to_s)
    relocation.add_replacement_pair(:library, LIBRARY_PLACEHOLDER, HOMEBREW_LIBRARY.to_s)
    relocation.add_replacement_pair(:perl, PERL_PLACEHOLDER, "#{HOMEBREW_PREFIX}/opt/perl/bin/perl")
    if (openjdk = openjdk_dep_name_if_applicable)
      relocation.add_replacement_pair(:java, JAVA_PLACEHOLDER, "#{HOMEBREW_PREFIX}/opt/#{openjdk}/libexec")
    end

    relocation
  end
  alias generic_prepare_relocation_to_locations prepare_relocation_to_locations

  def replace_placeholders_with_locations(files, skip_linkage: false)
    relocation = prepare_relocation_to_locations.freeze
    relocate_dynamic_linkage(relocation) unless skip_linkage
    replace_text_in_files(relocation, files:)
  end

  def openjdk_dep_name_if_applicable
    deps = runtime_dependencies
    return if deps.blank?

    dep_names = deps.map { |d| d["full_name"] }
    dep_names.find { |d| d.match? Version.formula_optionally_versioned_regex(:openjdk) }
  end

  def replace_text_in_files(relocation, files: nil)
    files ||= text_files | libtool_files

    changed_files = T.let([], Array)
    files.map { path.join(_1) }.group_by { |f| f.stat.ino }.each_value do |first, *rest|
      s = first.open("rb", &:read)

      next unless relocation.replace_text(s)

      changed_files += [first, *rest].map { |file| file.relative_path_from(path) }

      begin
        first.atomic_write(s)
      rescue SystemCallError
        first.ensure_writable do
          first.open("wb") { |f| f.write(s) }
        end
      else
        rest.each { |file| FileUtils.ln(first, file, force: true) }
      end
    end
    changed_files
  end

  def relocate_build_prefix(keg, old_prefix, new_prefix)
    each_unique_file_matching(old_prefix) do |file|
      # Skip files which are not binary, as they do not need null padding.
      next unless keg.binary_file?(file)

      # Skip sharballs, which appear to break if patched.
      next if file.text_executable?

      # Split binary by null characters into array and substitute new prefix for old prefix.
      # Null padding is added if the new string is too short.
      file.ensure_writable do
        binary = File.binread file
        odebug "Replacing build prefix in: #{file}"
        binary_strings = binary.split(/#{NULL_BYTE}/o, -1)
        match_indices = binary_strings.each_index.select { |i| binary_strings.fetch(i).include?(old_prefix) }

        # Only perform substitution on strings which match prefix regex.
        match_indices.each do |i|
          s = binary_strings.fetch(i)
          binary_strings[i] = s.gsub(old_prefix, new_prefix)
                               .ljust(s.size, NULL_BYTE)
        end

        # Rejoin strings by null bytes.
        patched_binary = binary_strings.join(NULL_BYTE)
        if patched_binary.size != binary.size
          raise <<~EOS
            Patching failed!  Original and patched binary sizes do not match.
            Original size: #{binary.size}
            Patched size: #{patched_binary.size}
          EOS
        end

        file.atomic_write patched_binary
      end
      codesign_patched_binary(file)
    end
  end

  def detect_cxx_stdlibs(_options = {})
    []
  end

  def recursive_fgrep_args
    # for GNU grep; overridden for BSD grep on OS X
    "-lr"
  end

  def egrep_args
    grep_bin = "grep"
    grep_args = [
      "--files-with-matches",
      "--perl-regexp",
      "--binary-files=text",
    ]

    [grep_bin, grep_args]
  end
  alias generic_egrep_args egrep_args

  def each_unique_file_matching(string)
    Utils.popen_read("fgrep", recursive_fgrep_args, string, to_s) do |io|
      hardlinks = Set.new

      until io.eof?
        file = Pathname.new(io.readline.chomp)
        # Don't return symbolic links.
        next if file.symlink?

        # To avoid returning hardlinks, only return files with unique inodes.
        # Hardlinks will have the same inode as the file they point to.
        yield file if hardlinks.add? file.stat.ino
      end
    end
  end

  def binary_file?(file)
    grep_bin, grep_args = egrep_args

    # We need to pass NULL_BYTE_STRING, the literal string "\x00", to grep
    # rather than NULL_BYTE, a literal null byte, because grep will internally
    # convert the literal string "\x00" to a null byte.
    Utils.popen_read(grep_bin, *grep_args, NULL_BYTE_STRING, file).present?
  end

  def lib
    path/"lib"
  end

  def libexec
    path/"libexec"
  end

  def text_files
    text_files = []
    return text_files if !which("file") || !which("xargs")

    # file has known issues with reading files on other locales. Has
    # been fixed upstream for some time, but a sufficiently new enough
    # file with that fix is only available in macOS Sierra.
    # https://bugs.gw.com/view.php?id=292
    with_custom_locale("C") do
      files = Set.new path.find.reject { |pn|
        next true if pn.symlink?
        next true if pn.directory?
        next false if pn.basename.to_s == "orig-prefix.txt" # for python virtualenvs
        next true if pn == self/".brew/#{name}.rb"

        require "metafiles"
        next true if Metafiles::EXTENSIONS.include?(pn.extname)

        if pn.text_executable?
          text_files << pn
          next true
        end
        false
      }
      output, _status = Open3.capture2("xargs -0 file --no-dereference --print0",
                                       stdin_data: files.to_a.join("\0"))
      # `file` output sometimes contains data from the file, which may include
      # invalid UTF-8 entities, so tell Ruby this is just a bytestring
      output.force_encoding(Encoding::ASCII_8BIT)
      output.each_line do |line|
        path, info = line.split("\0", 2)
        # `file` sometimes prints more than one line of output per file;
        # subsequent lines do not contain a null-byte separator, so `info`
        # will be `nil` for those lines
        next unless info
        next unless info.include?("text")

        path = Pathname.new(path)
        next unless files.include?(path)

        text_files << path
      end
    end

    text_files
  end

  def libtool_files
    libtool_files = []

    path.find do |pn|
      next if pn.symlink? || pn.directory? || Keg::LIBTOOL_EXTENSIONS.exclude?(pn.extname)

      libtool_files << pn
    end
    libtool_files
  end

  def symlink_files
    symlink_files = []
    path.find do |pn|
      symlink_files << pn if pn.symlink?
    end

    symlink_files
  end

  def self.text_matches_in_file(file, string, ignores, linked_libraries, formula_and_runtime_deps_names)
    text_matches = []
    path_regex = Relocation.path_to_regex(string)
    Utils.popen_read("strings", "-t", "x", "-", file.to_s) do |io|
      until io.eof?
        str = io.readline.chomp
        next if ignores.any? { |i| str.match?(i) }
        next unless str.match? path_regex

        offset, match = str.split(" ", 2)

        # Some binaries contain strings with lists of files
        # e.g. `/usr/local/lib/foo:/usr/local/share/foo:/usr/lib/foo`
        # Each item in the list should be checked separately
        match.split(":").each do |sub_match|
          # Not all items in the list may be matches
          next unless sub_match.match? path_regex
          next if linked_libraries.include? sub_match # Don't bother reporting a string if it was found by otool

          # Do not report matches to files that do not exist.
          next unless File.exist? sub_match

          # Do not report matches to build dependencies.
          if formula_and_runtime_deps_names.present?
            begin
              keg_name = Keg.for(Pathname.new(sub_match)).name
              next unless formula_and_runtime_deps_names.include? keg_name
            rescue NotAKegError
              nil
            end
          end

          text_matches << [match, offset] unless text_matches.any? { |text| text.last == offset }
        end
      end
    end
    text_matches
  end

  def self.file_linked_libraries(_file, _string)
    []
  end
end

require "extend/os/keg_relocate"
