# Copyright 2008-2023  Sutou Kouhei <kou@cozmixng.org>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA

begin
  require_relative "pkg-config/version"
rescue LoadError
end

require "English"
require "pathname"
require "rbconfig"
require "shellwords"

class PackageConfig
  class Error < StandardError
  end

  class NotFoundError < Error
  end

  SEPARATOR = File::PATH_SEPARATOR

  class << self
    @native_pkg_config = nil
    def native_pkg_config
      @native_pkg_config ||= guess_native_pkg_config
    end

    @native_pkg_config_prefix = nil
    def native_pkg_config_prefix
      @native_pkg_config_prefix ||= compute_native_pkg_config_prefix
    end

    @default_path = nil
    def default_path
      @default_path ||= compute_default_path
    end

    @custom_override_variables = nil
    def custom_override_variables
      @custom_override_variables ||= with_config("override-variables", "")
    end

    def clear_configure_args_cache
      @native_pkg_config = nil
      @native_pkg_config_prefix = nil
      @default_path = nil
      @custom_override_variables = nil
    end

    private
    def with_config(config, default=nil)
      if defined?(super)
        super
      else
        default
      end
    end

    def guess_native_pkg_config
      exeext = RbConfig::CONFIG["EXEEXT"]
      candidates = [
        with_config("pkg-config"),
        ENV["PKG_CONFIG"],
        "pkgconf#{exeext}",
        "pkg-config#{exeext}",
      ].compact
      candidates.each do |pkg_config|
        pkg_config = Pathname.new(pkg_config)
        return pkg_config if pkg_config.absolute? and pkg_config.exist?
        unless pkg_config.absolute?
          found_pkg_config = search_executable_from_path(pkg_config)
          return found_pkg_config if found_pkg_config
        end
        unless pkg_config.absolute?
          found_pkg_config = search_pkg_config_by_dln_find_exe(pkg_config)
          return found_pkg_config if found_pkg_config
        end
      end
      Pathname.new(candidates[0])
    end

    def search_executable_from_path(name)
      (ENV["PATH"] || "").split(SEPARATOR).each do |path|
        try_name = Pathname(path) + name
        return try_name if try_name.executable?
      end
      nil
    end

    def search_pkg_config_by_dln_find_exe(pkg_config)
      begin
        require "dl/import"
      rescue LoadError
        return nil
      end
      dln = Module.new
      dln.module_eval do
        if DL.const_defined?(:Importer)
          extend DL::Importer
        else
          extend DL::Importable
        end
        begin
          dlload RbConfig::CONFIG["LIBRUBY"]
        rescue RuntimeError
          return nil if $!.message == "unknown error"
          return nil if /: image not found\z/ =~ $!.message
          raise
        rescue DL::DLError
          return nil
        end
        begin
          extern "const char *dln_find_exe(const char *, const char *)"
        rescue DL::DLError
          return nil
        end
      end
      path = dln.dln_find_exe(pkg_config.to_s, nil)
      if path.nil? or path.size.zero?
        nil
      else
        Pathname(path.to_s)
      end
    end

    def compute_native_pkg_config_prefix
      pkg_config = native_pkg_config
      return nil unless pkg_config.absolute?
      return nil unless pkg_config.exist?

      pkg_config_prefix = pkg_config.parent.parent
      if File::ALT_SEPARATOR
        normalized_pkg_config_prefix =
          pkg_config_prefix.to_s.split(File::ALT_SEPARATOR).join(File::SEPARATOR)
        Pathname(normalized_pkg_config_prefix)
      else
        pkg_config_prefix
      end
    end

    def compute_default_path
      default_paths = nil
      if native_pkg_config
        pc_path = run_command(native_pkg_config.to_s,
                              "--variable=pc_path",
                              "pkg-config")
        if pc_path
          default_paths = pc_path.strip.split(SEPARATOR)
          default_paths = nil if default_paths.empty?
        end
      end
      if default_paths.nil?
        arch_depended_path = Dir.glob("/usr/lib/*/pkgconfig")
        default_paths = []
        pkg_config_prefix = native_pkg_config_prefix
        if pkg_config_prefix
          pkg_config_arch_depended_paths =
            Dir.glob((pkg_config_prefix + "lib/*/pkgconfig").to_s)
          default_paths.concat(pkg_config_arch_depended_paths)
          default_paths << (pkg_config_prefix + "lib64/pkgconfig").to_s
          default_paths << (pkg_config_prefix + "libx32/pkgconfig").to_s
          default_paths << (pkg_config_prefix + "lib/pkgconfig").to_s
          default_paths << (pkg_config_prefix + "libdata/pkgconfig").to_s
          default_paths << (pkg_config_prefix + "share/pkgconfig").to_s
        end
        conda_prefix = ENV["CONDA_PREFIX"]
        if conda_prefix
          default_paths << File.join(conda_prefix, "lib", "pkgconfig")
          default_paths << File.join(conda_prefix, "share", "pkgconfig")
        end
        default_paths << "/usr/local/lib64/pkgconfig"
        default_paths << "/usr/local/libx32/pkgconfig"
        default_paths << "/usr/local/lib/pkgconfig"
        default_paths << "/usr/local/libdata/pkgconfig"
        default_paths << "/usr/local/share/pkgconfig"
        default_paths << "/opt/local/lib/pkgconfig"
        default_paths.concat(arch_depended_path)
        default_paths << "/usr/lib64/pkgconfig"
        default_paths << "/usr/libx32/pkgconfig"
        default_paths << "/usr/lib/pkgconfig"
        default_paths << "/usr/libdata/pkgconfig"
        default_paths << "/usr/X11R6/lib/pkgconfig"
        default_paths << "/usr/X11R6/share/pkgconfig"
        default_paths << "/usr/X11/lib/pkgconfig"
        default_paths << "/opt/X11/lib/pkgconfig"
        default_paths << "/usr/share/pkgconfig"
      end
      if Object.const_defined?(:RubyInstaller)
        mingw_bin_path = RubyInstaller::Runtime.msys2_installation.mingw_bin_path
        mingw_pkgconfig_path = Pathname.new(mingw_bin_path) + "../lib/pkgconfig"
        default_paths.unshift(mingw_pkgconfig_path.cleanpath.to_s)
      end
      libdir = ENV["PKG_CONFIG_LIBDIR"]
      default_paths.unshift(libdir) if libdir

      paths = []
      if /-darwin\d[\d\.]*\z/ =~ RUBY_PLATFORM and
        /\A(\d+\.\d+)/ =~ run_command("sw_vers", "-productVersion")
        mac_os_version = $1
        homebrew_repository_candidates = []
        if pkg_config_prefix
          brew_path = pkg_config_prefix + "bin" + "brew"
          if brew_path.exist?
            homebrew_repository = run_command(brew_path.to_s, "--repository")
            if homebrew_repository
              homebrew_repository_candidates <<
                Pathname.new(homebrew_repository.strip)
            end
          else
            homebrew_repository_candidates << pkg_config_prefix + "Homebrew"
            homebrew_repository_candidates << pkg_config_prefix
          end
        end
        brew = search_executable_from_path("brew")
        if brew
          homebrew_repository = run_command("brew", "--repository")
          if homebrew_repository
          homebrew_repository_candidates <<
            Pathname(homebrew_repository.to_s)
          end
        end
        homebrew_repository_candidates.uniq.each do |candidate|
          pkgconfig_base_path = candidate + "Library/Homebrew/os/mac/pkgconfig"
          path = pkgconfig_base_path + mac_os_version
          unless path.exist?
            path = pkgconfig_base_path + mac_os_version.gsub(/\.\d+\z/, "")
          end
          paths << path.to_s if path.exist?
        end
      end
      paths.concat(default_paths)
      paths.join(SEPARATOR)
    end

    def run_command(*command_line)
      IO.pipe do |input, output|
        begin
          pid = spawn(*command_line,
                      out: output,
                      err: File::NULL)
          output.close
          _, status = Process.waitpid2(pid)
          return nil unless status.success?
          input.read
        rescue SystemCallError
          nil
        end
      end
    end
  end

  attr_reader :name
  attr_reader :paths
  attr_accessor :msvc_syntax
  def initialize(name, options={})
    if Pathname(name).absolute?
      @pc_path = name
      @path_position = 0
      @name = File.basename(@pc_path, ".*")
    else
      @pc_path = nil
      @path_position = nil
      @name = name
    end
    @options = options
    path = @options[:path] || ENV["PKG_CONFIG_PATH"]
    @paths = [path, self.class.default_path].compact.join(SEPARATOR).split(SEPARATOR)
    @paths.unshift(*(@options[:paths] || []))
    @paths = normalize_paths(@paths)
    @msvc_syntax = @options[:msvc_syntax]
    @variables = @declarations = nil
    override_variables = self.class.custom_override_variables
    @override_variables = parse_override_variables(override_variables)
    default_override_variables = @options[:override_variables] || {}
    @override_variables = default_override_variables.merge(@override_variables)
  end

  def exist?
    not pc_path.nil?
  end

  def requires
    parse_requires(declaration("Requires"))
  end

  def requires_private
    parse_requires(declaration("Requires.private"))
  end

  def cflags
    path_flags, other_flags = collect_cflags
    (other_flags + path_flags).join(" ")
  end

  def cflags_only_I
    collect_cflags[0].join(" ")
  end

  def cflags_only_other
    collect_cflags[1].join(" ")
  end

  def libs
    path_flags, other_flags = collect_libs
    (path_flags + other_flags).join(" ")
  end

  def libs_only_l
    collect_libs[1].find_all do |arg|
      if @msvc_syntax
        /\.lib\z/ =~ arg
      else
        /\A-l/ =~ arg
      end
    end.join(" ")
  end

  def libs_only_L
    collect_libs[0].find_all do |arg|
      if @msvc_syntax
        /\A\/libpath:/ =~ arg
      else
        /\A-L/ =~ arg
      end
    end.join(" ")
  end

  def version
    declaration("Version")
  end

  def description
    declaration("Description")
  end

  def variable(name)
    parse_pc if @variables.nil?
    expand_value(@override_variables[name] || @variables[name])
  end

  def declaration(name)
    parse_pc if @declarations.nil?
    expand_value(@declarations[name])
  end

  def pc_path
    if @pc_path
      return @pc_path if File.exist?(@pc_path)
    else
      @paths.each_with_index do |path, i|
        _pc_path = File.join(path, "#{@name}.pc")
        if File.exist?(_pc_path)
          @path_position = i + 1
          return _pc_path
        end
      end
    end
    nil
  end

  protected
  def path_position
    @path_position
  end

  def collect_requires(processed_packages={}, &block)
    packages = []
    targets = yield(self)
    targets.each do |name|
      next if processed_packages.key?(name)
      package = self.class.new(name, @options)
      processed_packages[name] = package
      packages << package
      packages.concat(package.collect_requires(processed_packages, &block))
    end
    packages_without_self = packages.reject do |package|
      package.name == @name
    end
    packages_without_self.uniq do |package|
      package.name
    end
  end

  private
  def sort_packages(packages)
    packages.sort_by.with_index do |package, i|
      [package.path_position, i]
    end
  end

  def collect_cflags
    target_packages = sort_packages([self, *all_required_packages])
    cflags_set = []
    target_packages.each do |package|
      cflags_set << package.declaration("Cflags")
    end
    all_cflags = normalize_cflags(Shellwords.split(cflags_set.join(" ")))
    path_flags, other_flags = all_cflags.partition {|flag| /\A-I/ =~ flag}
    path_flags = normalize_path_flags(path_flags, "-I")
    path_flags = path_flags.reject do |flag|
      flag == "-I/usr/include"
    end
    path_flags = path_flags.uniq
    if @msvc_syntax
      path_flags = path_flags.collect do |flag|
        flag.gsub(/\A-I/, "/I")
      end
    end
    [path_flags, other_flags]
  end

  def normalize_path_flags(path_flags, flag_option)
    return path_flags unless /-mingw(?:32|-ucrt)\z/ === RUBY_PLATFORM

    pkg_config_prefix = self.class.native_pkg_config_prefix
    return path_flags unless pkg_config_prefix

    mingw_dir = pkg_config_prefix.basename.to_s
    path_flags.collect do |path_flag|
      path = path_flag.sub(/\A#{Regexp.escape(flag_option)}/, "")
      path = path.sub(/\A\/#{Regexp.escape(mingw_dir)}/i) do
        pkg_config_prefix.to_s
      end
      "#{flag_option}#{path}"
    end
  end

  def normalize_cflags(cflags)
    normalized_cflags = []
    enumerator = cflags.to_enum
    begin
      loop do
        cflag = enumerator.next
        normalized_cflags << cflag.dup
        case cflag
        when "-I"
          normalized_cflags.last << enumerator.next
        end
      end
    rescue StopIteration
    end
    normalized_cflags
  end

  def collect_libs
    target_packages = sort_packages(required_packages + [self])
    libs_set = []
    target_packages.each do |package|
      libs_set << package.declaration("Libs")
    end
    all_flags = split_lib_flags(libs_set.join(" "))
    path_flags, other_flags = all_flags.partition {|flag| /\A-L/ =~ flag}
    path_flags = normalize_path_flags(path_flags, "-L")
    path_flags = path_flags.reject do |flag|
      /\A-L\/usr\/lib(?:64|x32)?\z/ =~ flag
    end
    path_flags = path_flags.uniq
    if @msvc_syntax
      path_flags = path_flags.collect do |flag|
        flag.gsub(/\A-L/, "/libpath:")
      end
      other_flags = other_flags.collect do |flag|
        if /\A-l/ =~ flag
          "#{$POSTMATCH}.lib"
        else
          flag
        end
      end
    end
    [path_flags, other_flags]
  end

  def split_lib_flags(libs_command_line)
    all_flags = {}
    flags = []
    in_option = false
    libs_command_line.gsub(/-([Ll]) /, "\\1").split.each do |arg|
      if in_option
        flags << arg
        in_option = false
      else
        case arg
        when /-[lL]/
          next if all_flags.key?(arg)
          all_flags[arg] = true
          flags << arg
          in_option = true
        else
          flags << arg
        end
      end
    end
    flags
  end

  IDENTIFIER_RE = /[a-zA-Z\d_\.]+/
  def parse_pc
    raise NotFoundError, ".pc doesn't exist: <#{@name}>" unless exist?
    @variables = {}
    @declarations = {}
    File.open(pc_path) do |input|
      input.each_line do |line|
        if line.dup.force_encoding("UTF-8").valid_encoding?
          line.force_encoding("UTF-8")
        end
        line = line.gsub(/#.*/, "").strip
        next if line.empty?
        case line
        when /^(#{IDENTIFIER_RE})\s*=\s*/
          @variables[$1] = $POSTMATCH.strip
        when /^(#{IDENTIFIER_RE})\s*:\s*/
          @declarations[$1] = $POSTMATCH.strip
        end
      end
    end
  end

  def parse_requires(requires)
    return [] if requires.nil?
    requires_without_version = requires.gsub(/(?:<|>|<=|>=|=)\s*[\d.a-zA-Z_-]+\s*/, "")
    requires_without_version.split(/[,\s]+/)
  end

  def parse_override_variables(override_variables)
    variables = {}
    override_variables.split(",").each do |variable|
      name, value = variable.split("=", 2)
      variables[name] = value
    end
    variables
  end

  def expand_value(value)
    return nil if value.nil?
    value.gsub(/\$\{(#{IDENTIFIER_RE})\}/) do
      variable($1)
    end
  end

  def required_packages
    collect_requires do |package|
      package.requires
    end
  end

  def all_required_packages
    collect_requires do |package|
      package.requires_private + package.requires
    end
  end

  def normalize_paths(paths)
    paths.reject do |path|
      path.empty? or !File.exist?(path)
    end
  end
end

module PKGConfig
  @@paths = []
  @@override_variables = {}

  module_function
  def add_path(path)
    @@paths << path
  end

  def set_override_variable(key, value)
    @@override_variables[key] = value
  end

  def msvc?
    /mswin/.match(RUBY_PLATFORM) and /^cl\b/.match(RbConfig::CONFIG["CC"])
  end

  def package_config(package)
    PackageConfig.new(package,
                      :msvc_syntax => msvc?,
                      :override_variables => @@override_variables,
                      :paths => @@paths)
  end

  def exist?(pkg)
    package_config(pkg).exist?
  end

  def libs(pkg)
    package_config(pkg).libs
  end

  def libs_only_l(pkg)
    package_config(pkg).libs_only_l
  end

  def libs_only_L(pkg)
    package_config(pkg).libs_only_L
  end

  def cflags(pkg)
    package_config(pkg).cflags
  end

  def cflags_only_I(pkg)
    package_config(pkg).cflags_only_I
  end

  def cflags_only_other(pkg)
    package_config(pkg).cflags_only_other
  end

  def modversion(pkg)
    package_config(pkg).version
  end

  def description(pkg)
    package_config(pkg).description
  end

  def variable(pkg, name)
    package_config(pkg).variable(name)
  end

  def check_version?(pkg, major=0, minor=0, micro=0)
    return false unless exist?(pkg)
    ver = modversion(pkg).split(".").collect {|item| item.to_i}
    (0..2).each {|i| ver[i] = 0 unless ver[i]}

    (ver[0] > major ||
     (ver[0] == major && ver[1] > minor) ||
     (ver[0] == major && ver[1] == minor &&
      ver[2] >= micro))
  end

  def have_package(pkg, major=nil, minor=0, micro=0)
    message = "#{pkg}"
    unless major.nil?
      message << " version (>= #{major}.#{minor}.#{micro})"
    end
    major ||= 0
    result = checking_for(checking_message(message), "%s") do
      if check_version?(pkg, major, minor, micro)
        "yes (#{modversion(pkg)})"
      else
        if exist?(pkg)
          "no (#{modversion(pkg)})"
        else
          "no (nonexistent)"
        end
      end
    end
    enough_version = result.start_with?("yes")
    if enough_version
      libraries = libs_only_l(pkg)
      dldflags = libs(pkg)
      dldflags = (Shellwords.shellwords(dldflags) -
                  Shellwords.shellwords(libraries))
      dldflags = dldflags.map {|s| /\s/ =~ s ? "\"#{s}\"" : s }.join(" ")
      $libs   += " " + libraries
      if /mswin/ =~ RUBY_PLATFORM
        $DLDFLAGS += " " + dldflags
      else
        $LDFLAGS += " " + dldflags
      end
      $CFLAGS += " " + cflags_only_other(pkg)
      if defined?($CXXFLAGS)
        $CXXFLAGS += " " + cflags_only_other(pkg)
      end
      $INCFLAGS += " " + cflags_only_I(pkg)
    end
    enough_version
  end
end
