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

require "system_command"
require "extend/pathname/disk_usage_extension"
require "extend/pathname/observer_pathname_extension"

# Homebrew extends Ruby's `Pathname` to make our code more readable.
# @see https://ruby-doc.org/stdlib-2.6.3/libdoc/pathname/rdoc/Pathname.html Ruby's Pathname API
class Pathname
  include SystemCommand::Mixin
  include DiskUsageExtension

  # Moves a file from the original location to the {Pathname}'s.
  #
  # @api public
  sig {
    params(sources: T.any(
      Resource, Resource::Partial, String, Pathname,
      T::Array[T.any(String, Pathname)], T::Hash[T.any(String, Pathname), String]
    )).void
  }
  def install(*sources)
    sources.each do |src|
      case src
      when Resource
        src.stage(self)
      when Resource::Partial
        src.resource.stage { install(*src.files) }
      when Array
        if src.empty?
          opoo "Tried to install empty array to #{self}"
          break
        end
        src.each { |s| install_p(s, File.basename(s)) }
      when Hash
        if src.empty?
          opoo "Tried to install empty hash to #{self}"
          break
        end
        src.each { |s, new_basename| install_p(s, new_basename) }
      else
        install_p(src, File.basename(src))
      end
    end
  end

  sig { params(src: T.any(String, Pathname), new_basename: String).void }
  def install_p(src, new_basename)
    src = Pathname(src)
    raise Errno::ENOENT, src.to_s if !src.symlink? && !src.exist?

    dst = join(new_basename)
    dst = yield(src, dst) if block_given?
    return unless dst

    mkpath

    # Use `FileUtils.mv` over `File.rename` to handle filesystem boundaries. If `src`
    # is a symlink and its target is moved first, `FileUtils.mv` will fail
    # (https://bugs.ruby-lang.org/issues/7707).
    #
    # In that case, use the system `mv` command.
    if src.symlink?
      raise unless Kernel.system "mv", src.to_s, dst
    else
      FileUtils.mv src, dst
    end
  end
  private :install_p

  # Creates symlinks to sources in this folder.
  #
  # @api public
  sig {
    params(
      sources: T.any(String, Pathname, T::Array[T.any(String, Pathname)], T::Hash[T.any(String, Pathname), String]),
    ).void
  }
  def install_symlink(*sources)
    sources.each do |src|
      case src
      when Array
        src.each { |s| install_symlink_p(s, File.basename(s)) }
      when Hash
        src.each { |s, new_basename| install_symlink_p(s, new_basename) }
      else
        install_symlink_p(src, File.basename(src))
      end
    end
  end

  def install_symlink_p(src, new_basename)
    mkpath
    dstdir = realpath
    src = Pathname(src).expand_path(dstdir)
    src = src.dirname.realpath/src.basename if src.dirname.exist?
    FileUtils.ln_sf(src.relative_path_from(dstdir), dstdir/new_basename)
  end
  private :install_symlink_p

  # Only appends to a file that is already created.
  #
  # @api public
  sig { params(content: String, open_args: T.untyped).void }
  def append_lines(content, **open_args)
    raise "Cannot append file that doesn't exist: #{self}" unless exist?

    T.unsafe(self).open("a", **open_args) { |f| f.puts(content) }
  end

  # Write to a file atomically.
  #
  # NOTE: This always overwrites.
  #
  # @api public
  sig { params(content: String).void }
  def atomic_write(content)
    require "extend/file/atomic"

    old_stat = stat if exist?
    File.atomic_write(self) do |file|
      file.write(content)
    end

    return unless old_stat

    # Try to restore original file's permissions separately
    # atomic_write does it itself, but it actually erases
    # them if chown fails
    begin
      # Set correct permissions on new file
      chown(old_stat.uid, nil)
      chown(nil, old_stat.gid)
    rescue Errno::EPERM, Errno::EACCES
      # Changing file ownership failed, moving on.
      nil
    end

    begin
      # This operation will affect filesystem ACL's
      chmod(old_stat.mode)
    rescue Errno::EPERM, Errno::EACCES
      # Changing file permissions failed, moving on.
      nil
    end
  end

  def cp_path_sub(pattern, replacement)
    raise "#{self} does not exist" unless exist?

    dst = sub(pattern, replacement)

    raise "#{self} is the same file as #{dst}" if self == dst

    if directory?
      dst.mkpath
    else
      dst.dirname.mkpath
      dst = yield(self, dst) if block_given?
      FileUtils.cp(self, dst)
    end
  end

  # Extended to support common double extensions.
  #
  # @api public
  sig { returns(String) }
  def extname
    basename = File.basename(self)

    bottle_ext, = HOMEBREW_BOTTLES_EXTNAME_REGEX.match(basename).to_a
    return bottle_ext if bottle_ext

    archive_ext = basename[/(\.(tar|cpio|pax)\.(gz|bz2|lz|xz|zst|Z))\Z/, 1]
    return archive_ext if archive_ext

    # Don't treat version numbers as extname.
    return "" if basename.match?(/\b\d+\.\d+[^.]*\Z/) && !basename.end_with?(".7z")

    File.extname(basename)
  end

  # For filetypes we support, returns basename without extension.
  #
  # @api public
  sig { returns(String) }
  def stem
    File.basename(self, extname)
  end

  # I don't trust the children.length == 0 check particularly, not to mention
  # it is slow to enumerate the whole directory just to see if it is empty,
  # instead rely on good ol' libc and the filesystem
  sig { returns(T::Boolean) }
  def rmdir_if_possible
    rmdir
    true
  rescue Errno::ENOTEMPTY
    if (ds_store = join(".DS_Store")).exist? && children.length == 1
      ds_store.unlink
      retry
    else
      false
    end
  rescue Errno::EACCES, Errno::ENOENT, Errno::EBUSY, Errno::EPERM
    false
  end

  sig { returns(Version) }
  def version
    require "version"
    Version.parse(basename)
  end

  sig { returns(T::Boolean) }
  def text_executable?
    /\A#!\s*\S+/.match?(open("r") { |f| f.read(1024) })
  end

  sig { returns(String) }
  def sha256
    require "digest/sha2"
    Digest::SHA256.file(self).hexdigest
  end

  sig { params(expected: T.nilable(Checksum)).void }
  def verify_checksum(expected)
    raise ChecksumMissingError if expected.blank?

    actual = Checksum.new(sha256.downcase)
    raise ChecksumMismatchError.new(self, expected, actual) if expected != actual
  end

  alias to_str to_s

  # Change to this directory, optionally executing the given block.
  #
  # @api public
  sig {
    type_parameters(:U).params(
      _block: T.proc.params(path: Pathname).returns(T.type_parameter(:U)),
    ).returns(T.type_parameter(:U))
  }
  def cd(&_block)
    Dir.chdir(self) { yield self }
  end

  # Get all sub-directories of this directory.
  #
  # @api public
  sig { returns(T::Array[Pathname]) }
  def subdirs
    children.select(&:directory?)
  end

  sig { returns(Pathname) }
  def resolved_path
    symlink? ? dirname.join(readlink) : self
  end

  sig { returns(T::Boolean) }
  def resolved_path_exists?
    link = readlink
  rescue ArgumentError
    # The link target contains NUL bytes
    false
  else
    dirname.join(link).exist?
  end

  def make_relative_symlink(src)
    dirname.mkpath
    File.symlink(src.relative_path_from(dirname), self)
  end

  def ensure_writable
    saved_perms = nil
    unless writable?
      saved_perms = stat.mode
      FileUtils.chmod "u+rw", to_path
    end
    yield
  ensure
    chmod saved_perms if saved_perms
  end

  def which_install_info
    @which_install_info ||=
      if File.executable?("/usr/bin/install-info")
        "/usr/bin/install-info"
      elsif Formula["texinfo"].any_version_installed?
        Formula["texinfo"].opt_bin/"install-info"
      end
  end

  def install_info
    quiet_system(which_install_info, "--quiet", to_s, "#{dirname}/dir")
  end

  def uninstall_info
    quiet_system(which_install_info, "--delete", "--quiet", to_s, "#{dirname}/dir")
  end

  # Writes an exec script in this folder for each target pathname.
  def write_exec_script(*targets)
    targets.flatten!
    if targets.empty?
      opoo "Tried to write exec scripts to #{self} for an empty list of targets"
      return
    end
    mkpath
    targets.each do |target|
      target = Pathname.new(target) # allow pathnames or strings
      join(target.basename).write <<~SH
        #!/bin/bash
        exec "#{target}" "$@"
      SH
    end
  end

  # Writes an exec script that sets environment variables.
  def write_env_script(target, args, env = nil)
    unless env
      env = args
      args = nil
    end
    env_export = +""
    env.each { |key, value| env_export << "#{key}=\"#{value}\" " }
    dirname.mkpath
    write <<~SH
      #!/bin/bash
      #{env_export}exec "#{target}" #{args} "$@"
    SH
  end

  # Writes a wrapper env script and moves all files to the dst.
  def env_script_all_files(dst, env)
    dst.mkpath
    Pathname.glob("#{self}/*") do |file|
      next if file.directory?

      dst.install(file)
      new_file = dst.join(file.basename)
      file.write_env_script(new_file, env)
    end
  end

  # Writes an exec script that invokes a Java jar.
  sig {
    params(
      target_jar:   T.any(String, Pathname),
      script_name:  T.any(String, Pathname),
      java_opts:    String,
      java_version: T.nilable(String),
    ).returns(Integer)
  }
  def write_jar_script(target_jar, script_name, java_opts = "", java_version: nil)
    mkpath
    (self/script_name).write <<~EOS
      #!/bin/bash
      export JAVA_HOME="#{Language::Java.overridable_java_home_env(java_version)[:JAVA_HOME]}"
      exec "${JAVA_HOME}/bin/java" #{java_opts} -jar "#{target_jar}" "$@"
    EOS
  end

  def install_metafiles(from = Pathname.pwd)
    require "metafiles"

    Pathname(from).children.each do |p|
      next if p.directory?
      next if File.empty?(p)
      next unless Metafiles.copy?(p.basename.to_s)

      # Some software symlinks these files (see help2man.rb)
      filename = p.resolved_path
      # Some software links metafiles together, so by the time we iterate to one of them
      # we may have already moved it. libxml2's COPYING and Copyright are affected by this.
      next unless filename.exist?

      filename.chmod 0644
      install(filename)
    end
  end

  sig { returns(T::Boolean) }
  def ds_store?
    basename.to_s == ".DS_Store"
  end

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

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

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

  sig { params(_wanted_arch: Symbol).returns(T::Boolean) }
  def arch_compatible?(_wanted_arch)
    true
  end

  sig { returns(T::Array[String]) }
  def rpaths
    []
  end

  sig { returns(String) }
  def magic_number
    @magic_number ||= if directory?
      ""
    else
      # Length of the longest regex (currently Tar).
      max_magic_number_length = 262
      binread(max_magic_number_length) || ""
    end
  end

  sig { returns(String) }
  def file_type
    @file_type ||= system_command("file", args: ["-b", self], print_stderr: false)
                   .stdout.chomp
  end

  sig { returns(T::Array[String]) }
  def zipinfo
    @zipinfo ||= system_command("zipinfo", args: ["-1", self], print_stderr: false)
                 .stdout
                 .encode(Encoding::UTF_8, invalid: :replace)
                 .split("\n")
  end

  # Like regular `rmtree`, except it never ignores errors.
  #
  # This was the default behaviour in Ruby 3.1 and earlier.
  #
  # @api public
  def rmtree(noop: nil, verbose: nil, secure: nil)
    # Ideally we'd odeprecate this but probably can't given gems so let's
    # create a RuboCop autocorrect instead soon.
    # This is why monkeypatching is non-ideal (but right solution to get
    # Ruby 3.3 over the line).
    odisabled "rmtree", "FileUtils#rm_r"
    FileUtils.rm_r(T.must(@path), noop:, verbose:, secure:)
    nil
  end
end
require "extend/os/pathname"
