# encoding: ascii-8bit
# frozen_string_literal: true

require 'elftools/elf_file'
require 'objspace'

require 'patchelf/exceptions'
require 'patchelf/helper'
require 'patchelf/logger'
require 'patchelf/saver'

module PatchELF
  # Class to handle all patching things.
  class Patcher
    # @!macro [new] note_apply
    #   @note This setting will be saved after {#save} being invoked.

    attr_reader :elf # @return [ELFTools::ELFFile] ELF parser object.

    # Instantiate a {Patcher} object.
    # @param [String] filename
    #   Filename of input ELF.
    # @param [Boolean] logging
    #   *deprecated*: use +on_error+ instead
    # @param [:log, :silent, :exception] on_error
    #   action when the desired segment/tag field isn't present
    #     :log = logs to stderr
    #     :exception = raise exception related to the error
    #     :silent = ignore the errors
    def initialize(filename, on_error: :log, logging: true)
      @in_file = filename
      f = File.open(filename) # rubocop:disable Style/FileOpen
      @elf = ELFTools::ELFFile.new(f)
      @set = {}
      @rpath_sym = :runpath
      @on_error = logging ? on_error : :exception

      on_error_syms = %i[exception log silent]
      raise ArgumentError, "on_error must be one of #{on_error_syms}" unless on_error_syms.include?(@on_error)

      # Ensure file is closed when the {Patcher} object is garbage collected.
      ObjectSpace.define_finalizer(self, Helper.close_file_proc(f))
    end

    # @return [String?]
    #   Get interpreter's name.
    # @example
    #   PatchELF::Patcher.new('/bin/ls').interpreter
    #   #=> "/lib64/ld-linux-x86-64.so.2"
    def interpreter
      @set[:interpreter] || interpreter_
    end

    # Set interpreter's name.
    #
    # If the input ELF has no existent interpreter,
    # this method will show a warning and has no effect.
    # @param [String] interp
    # @macro note_apply
    def interpreter=(interp)
      return if interpreter_.nil? # will also show warning if there's no interp segment.

      @set[:interpreter] = interp
    end

    # Get needed libraries.
    # @return [Array<String>]
    # @example
    #   patcher = PatchELF::Patcher.new('/bin/ls')
    #   patcher.needed
    #   #=> ["libselinux.so.1", "libc.so.6"]
    def needed
      @set[:needed] || needed_
    end

    # Set needed libraries.
    # @param [Array<String>] needs
    # @macro note_apply
    def needed=(needs)
      @set[:needed] = needs
    end

    # Add the needed library.
    # @param [String] need
    # @return [void]
    # @macro note_apply
    def add_needed(need)
      @set[:needed] ||= needed_
      @set[:needed] << need
    end

    # Remove the needed library.
    # @param [String] need
    # @return [void]
    # @macro note_apply
    def remove_needed(need)
      @set[:needed] ||= needed_
      @set[:needed].delete(need)
    end

    # Replace needed library +src+ with +tar+.
    #
    # @param [String] src
    #   Library to be replaced.
    # @param [String] tar
    #   Library replace with.
    # @return [void]
    # @macro note_apply
    def replace_needed(src, tar)
      @set[:needed] ||= needed_
      @set[:needed].map! { |v| v == src ? tar : v }
    end

    # Get the soname of a shared library.
    # @return [String?] The name.
    # @example
    #   patcher = PatchELF::Patcher.new('/bin/ls')
    #   patcher.soname
    #   # [WARN] Entry DT_SONAME not found, not a shared library?
    #   #=> nil
    # @example
    #   PatchELF::Patcher.new('/lib/x86_64-linux-gnu/libc.so.6').soname
    #   #=> "libc.so.6"
    def soname
      @set[:soname] || soname_
    end

    # Set soname.
    #
    # If the input ELF is not a shared library with a soname,
    # this method will show a warning and has no effect.
    # @param [String] name
    # @macro note_apply
    def soname=(name)
      return if soname_.nil?

      @set[:soname] = name
    end

    # Get runpath.
    # @return [String?]
    def runpath
      @set[@rpath_sym] || runpath_(@rpath_sym)
    end

    # Get rpath
    # return [String?]
    def rpath
      @set[:rpath] || runpath_(:rpath)
    end

    # Set rpath
    #
    # Modify / set DT_RPATH of the given ELF.
    # similar to runpath= except DT_RPATH is modifed/created in DYNAMIC segment.
    # @param [String] rpath
    # @macro note_apply
    def rpath=(rpath)
      @set[:rpath] = rpath
    end

    # Set runpath.
    #
    # If DT_RUNPATH is not presented in the input ELF,
    # a new DT_RUNPATH attribute will be inserted into the DYNAMIC segment.
    # @param [String] runpath
    # @macro note_apply
    def runpath=(runpath)
      @set[@rpath_sym] = runpath
    end

    # Set all operations related to DT_RUNPATH to use DT_RPATH.
    # @return [self]
    def use_rpath!
      @rpath_sym = :rpath
      self
    end

    # Save the patched ELF as +out_file+.
    # @param [String?] out_file
    #   If +out_file+ is +nil+, the original input file will be modified.
    # @param [Boolean] patchelf_compatible
    #   When +patchelf_compatible+ is true, tries to produce same ELF as the one produced by NixOS/patchelf.
    # @return [void]
    def save(out_file = nil, patchelf_compatible: false)
      # If nothing is modified, return directly.
      return if out_file.nil? && !dirty?

      out_file ||= @in_file
      saver = if patchelf_compatible
                require 'patchelf/alt_saver'
                PatchELF::AltSaver.new(@in_file, out_file, @set)
              else
                PatchELF::Saver.new(@in_file, out_file, @set)
              end

      saver.save!
    end

    private

    def log_or_raise(msg, exception = PatchELF::PatchError)
      raise exception, msg if @on_error == :exception

      PatchELF::Logger.warn(msg) if @on_error == :log
    end

    def interpreter_
      segment = @elf.segment_by_type(:interp)
      return log_or_raise 'No interpreter found.', PatchELF::MissingSegmentError if segment.nil?

      segment.interp_name
    end

    # @return [Array<String>]
    def needed_
      segment = dynamic_or_log
      return if segment.nil?

      segment.tags_by_type(:needed).map(&:name)
    end

    # @return [String?]
    def runpath_(rpath_sym = :runpath)
      tag_name_or_log(rpath_sym, "Entry DT_#{rpath_sym.to_s.upcase} not found.")
    end

    # @return [String?]
    def soname_
      tag_name_or_log(:soname, 'Entry DT_SONAME not found, not a shared library?')
    end

    # @return [Boolean]
    def dirty?
      @set.any?
    end

    def tag_name_or_log(type, log_msg)
      segment = dynamic_or_log
      return if segment.nil?

      tag = segment.tag_by_type(type)
      return log_or_raise log_msg, PatchELF::MissingTagError if tag.nil?

      tag.name
    end

    def dynamic_or_log
      @elf.segment_by_type(:dynamic).tap do |s|
        if s.nil?
          log_or_raise 'DYNAMIC segment not found, might be a statically-linked ELF?', PatchELF::MissingSegmentError
        end
      end
    end
  end
end
