#
# Copyright 2012-2018 Chef Software, Inc.
# Copyright 2014 Noah Kantrowitz
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "time"
require "ffi_yajl"
require "omnibus/manifest"
require "omnibus/manifest_entry"
require "omnibus/reports"

module Omnibus
  class Project
    class << self
      #
      # @param [String] name
      #   the name to the project definition to load from disk
      #
      # @return [Project]
      #
      def load(name, manifest = nil)
        loaded_projects[name] ||= begin
          filepath = Omnibus.project_path(name)

          if filepath.nil?
            raise MissingProject.new(name)
          else
            log.internal(log_key) do
              "Loading project `#{name}' from `#{filepath}'."
            end
          end

          instance = new(filepath, manifest)
          instance.evaluate_file(filepath)
          instance.load_dependencies
          instance
        end
      end

      #
      # Reset cached project information.
      #
      def reset!
        @loaded_projects = nil
      end

      private

      #
      # The list of projects loaded thus far.
      #
      # @return [Hash<String, Project>]
      #
      def loaded_projects
        @loaded_projects ||= {}
      end
    end

    attr_reader :manifest

    include Cleanroom
    include Digestable
    include Logging
    include NullArgumentable
    include Sugarable
    include Util

    def initialize(filepath = nil, manifest = nil)
      @filepath = filepath
      @manifest = manifest
    end

    #
    # @!group DSL methods
    #
    # The following DSL methods are available from within the project
    # definitions.
    # --------------------------------------------------

    #
    # **[Required]** Set or retrieve the name of the project.
    #
    # @example
    #   name 'chef'
    #
    # @raise [MissingRequiredAttribute] if a value was not set before being
    #   subsequently retrieved
    #
    # @param [String] val
    #   the name to set
    #
    # @return [String]
    #
    def name(val = NULL)
      if null?(val)
        @name || raise(MissingRequiredAttribute.new(self, :name, "hamlet"))
      else
        @name = val
      end
    end
    expose :name

    #
    # Set or retrieve a friendly name for the project. This defaults to the
    # capitalized name if not specified.
    #
    # @example
    #   friendly_name 'Chef'
    #
    # @param [String] val
    #   the name to set
    #
    # @return [String]
    #
    def friendly_name(val = NULL)
      if null?(val)
        @friendly_name || name.capitalize
      else
        @friendly_name = val
      end
    end
    expose :friendly_name

    #
    # Set or retrieve the package name of the project. Defaults to the package
    # name defaults to the project name.
    #
    # @example
    #   package_name 'com.chef.project'
    #
    # @param [String] val
    #   the package name to set
    #
    # @return [String]
    #
    def package_name(val = NULL)
      if null?(val)
        @package_name || name
      else
        @package_name = val
      end
    end
    expose :package_name

    #
    # **[Required]** Set or retrieve the path at which the project should be
    # installed by the generated package.
    #
    # Even on Windows-based systems, this path should be the Unix-like path,
    # since that's what Ruby expects. In the event +\+ is used as a file
    # separator, it will be replaced with +/+. This method also attempts to
    # remove duplicate slashes which might be caused as a result of string
    # interpolation.
    #
    # @example
    #   install_dir '/opt/chef'
    #
    # @raise [MissingRequiredAttribute] if a value was not set before being
    #   subsequently retrieved
    #
    # @param [String] val
    #   the install path to set
    #
    # @return [String]
    #
    def install_dir(val = NULL)
      if null?(val)
        @install_dir || raise(MissingRequiredAttribute.new(self, :install_dir, "/opt/chef"))
      else
        @install_dir = val.tr('\\', "/").squeeze("/").chomp("/")
      end
    end
    expose :install_dir

    #
    # The default root where a project should be installed. On Windows-based
    # systems, this value defaults to +C:+. On non-Windows systems, this value
    # defaults to +/opt+.
    #
    # @example
    #   install_dir "#{default_root}/chef" #=> Produces +C:/chef+ on Windows and
    #                                      #=> +/opt/chef+ on Linux
    #
    # @return [String]
    #
    def default_root
      if windows?
        "C:"
      else
        "/opt"
      end
    end
    expose :default_root

    #
    # Path to the +/files+ directory in the omnibus project. This directory can
    # contain arbitrary files used by the project.
    #
    # @example
    #   patch = File.join(files_path, 'rubygems', 'patch.rb')
    #
    # @return [String]
    #   path to the files directory
    #
    def files_path
      File.expand_path("#{Config.project_root}/files")
    end
    expose :files_path

    #
    # **[Required]** Set or retrieve the the package maintainer.
    #
    # @example
    #   maintainer 'Chef Software, Inc.'
    #
    # @raise [MissingRequiredAttribute] if a value was not set before being
    #   subsequently retrieved
    #
    # @param [String] val
    #   the name of the maintainer
    #
    # @return [String]
    #
    def maintainer(val = NULL)
      if null?(val)
        @maintainer || raise(MissingRequiredAttribute.new(self, :maintainer, "Chef Software, Inc."))
      else
        @maintainer = val
      end
    end
    expose :maintainer

    #
    # **[Required]** Set or retrive the package homepage.
    #
    # @example
    #   homepage 'https://www.getchef.com'
    #
    # @raise [MissingRequiredAttribute] if a value was not set before being
    #   subsequently retrieved
    #
    # @param [String] val
    #   the homepage for the project
    #
    # @return [String]
    #
    def homepage(val = NULL)
      if null?(val)
        @homepage || raise(MissingRequiredAttribute.new(self, :homepage, "https://www.getchef.com"))
      else
        @homepage = val
      end
    end
    expose :homepage

    #
    # Set or retrieve the project description.
    #
    # @example
    #   description 'This is my description'
    #
    # @param [String] val
    #   the project description
    #
    # @return [String]
    #
    def description(val = NULL)
      if null?(val)
        @description || "The full stack of #{name}"
      else
        @description = val
      end
    end
    expose :description

    #
    # Add to the list of packages this one replaces.
    #
    # This should only be used when renaming a package and obsoleting the old
    # name of the package. **Setting this to the same name as package_name will
    # cause RPM upgrades to fail.**
    #
    # @example
    #   replace 'the-old-package'
    #
    # @param [String] val
    #   the name of the package to replace
    #
    # @return [String]
    #
    def replace(val = NULL)
      replaces << val
      replaces.dup
    end
    expose :replace

    #
    # Add to the list of packages this one conflicts with.
    #
    # @example
    #   conflicts 'foo'
    #   conflicts 'bar'
    #
    # @param [String] val
    #   the conflict to add
    #
    # @return [Array<String>]
    #   the list of conflicts
    #
    def conflict(val)
      conflicts << val
      conflicts.dup
    end
    expose :conflict

    #
    # Set or retrieve the version of the project.
    #
    # @example Using a string
    #   build_version '1.0.0'
    #
    # @example From git
    #   build_version do
    #     source :git
    #   end
    #
    # @example From the version of a dependency
    #   build_version do
    #     source :version, from_dependency: 'chef'
    #   end
    #
    # @example From git of a dependency
    #   build_version do
    #     source :git, from_dependency: 'chef'
    #   end
    #
    # When using the +:git+ source, by default the output format of the
    # +build_version+ is semver. This can be modified using the +:output_format+
    # parameter to any of the methods of +BuildVersion+. For example:
    #
    #   build version do
    #     source :git, from_dependency: 'chef'
    #     output_format :git_describe
    #   end
    #
    # @see Omnibus::BuildVersion
    # @see Omnibus::BuildVersionDSL
    #
    # @param [String] val
    #   the build version to set
    # @param [Proc] block
    #   the block to run when constructing the +build_version+
    #
    # @return [String]
    #
    def build_version(val = NULL, &block)
      if block && !null?(val)
        raise Error, "You cannot specify additional parameters to " \
          "#build_version when a block is given!"
      end

      if block
        @build_version_dsl = BuildVersionDSL.new(&block)
      else
        if null?(val)
          @build_version_dsl.build_version
        else
          @build_version_dsl = BuildVersionDSL.new(val)
        end
      end
    end
    expose :build_version

    #
    # Set or retrieve the git revision of the omnibus
    # project being built.
    #
    # If not set by the user, and the current workding directory is a
    # git directory, it will return the revision of the current
    # working directory.
    #
    def build_git_revision(val = NULL)
      if null?(val)
        @build_git_revision ||= get_local_revision
      else
        @build_git_revision = val
      end
    end
    expose :build_git_revision

    #
    # Set or retrieve the build iteration of the project. Defaults to +1+ if not
    # otherwise set.
    #
    # @example
    #   build_iteration 5
    #
    # @param [Fixnum] val
    #   the build iteration number
    #
    # @return [Fixnum]
    #
    def build_iteration(val = NULL)
      if null?(val)
        @build_iteration || 1
      else
        @build_iteration = val
      end
    end
    expose :build_iteration

    #
    # Add or override a customization for the packager with the given +id+. When
    # given multiple blocks with the same +id+, they are evaluated _in order_,
    # so the last block evaluated will take precedence over the previous ones.
    #
    # @example
    #   package :id do
    #     key 'value'
    #   end
    #
    # @param [Symbol] id
    #   the id of the packager to customize
    #
    def package(id, &block)
      unless block
        raise InvalidValue.new(:package, "have a block")
      end

      packagers[id] << block
    end
    expose :package

    #
    # Add or override a customization for the compressor with the given +id+.
    # When given multiple blocks with the same +id+, they are evaluated
    # _in order_, so the last block evaluated will take precedence over the
    # previous ones.
    #
    # @example With customization
    #   compress :dmg do
    #     window_bounds '10, 20, 30, 40'
    #   end
    #
    # @example Without customization
    #   compress :tgz
    #
    # If multiple +compress+ blocks are specified, the "most prefered" one for
    # the current system will be used.
    #
    # @param [Symbol] id
    #   the id of the compressor to customize
    #
    def compress(id, &block)
      if block
        compressors[id] << block
      else
        compressors[id] << Proc.new {}
      end
    end
    expose :compress

    #
    # Set or retrieve the user the package should install as. This varies with
    # operating system, and may be ignored if the underlying packager does not
    # support it.
    #
    # Defaults to +"root"+.
    #
    # @example
    #   package_user 'build'
    #
    # @param [String] val
    #   the user to use for the package build
    #
    # @return [String]
    #
    def package_user(val = NULL)
      if null?(val)
        @package_user || "root"
      else
        @package_user = val
      end
    end
    expose :package_user

    #
    # Set or retrieve the overrides hash for one piece of software being
    # overridden. Calling it as a setter does not merge hash entries and it will
    # set all the overrides for a given software definition.
    #
    # @example
    #   override 'chef', version: '1.2.3'
    #
    # @param [Hash] val
    #   the value to override
    #
    # @return [Hash]
    #
    def override(name, val = NULL)
      if null?(val)
        overrides[name.to_sym]
      else
        overrides[name.to_sym] = val
      end
    end
    expose :override

    #
    # Set or retrieve the group the package should install as. This varies with
    # operating system and may  be ignored if the underlying packager does not
    # support it.
    #
    # Defaults to +Ohai['root_group']+. If +Ohai['root_group']+ is +nil+, it
    # defaults to +"root"+.
    #
    # @example
    #   package_group 'build'
    #
    # @param [String] val
    #   the group to use for the package build
    #
    # @return [String]
    #
    def package_group(val = NULL)
      if null?(val)
        @package_group || Ohai["root_group"] || "root"
      else
        @package_group = val
      end
    end
    expose :package_group

    #
    # Set or retrieve the path to the resources on disk for use in packagers.
    #
    # @example
    #   resources_path '/path/to/resources'
    #
    # @param [String] val
    #   the path where resources live
    #
    # @return [String]
    #
    def resources_path(val = NULL)
      if null?(val)
        @resources_path || "#{Config.project_root}/resources/#{name}"
      else
        @resources_path = File.expand_path(val)
      end
    end
    expose :resources_path

    #
    # The path to the package scripts directory for this project. These are
    # optional scripts that can be bundled into the resulting package for
    # running at various points in the package management lifecycle.
    #
    # These scripts and their names vary with operating system.
    #
    # @return [String]
    #
    def package_scripts_path(arg = NULL)
      if null?(arg)
        @package_scripts_path || "#{Config.project_root}/package-scripts/#{name}"
      else
        @package_scripts_path = File.expand_path(arg)
      end
    end
    expose :package_scripts_path

    #
    # Add a software dependency.
    #
    # Note that this is a *build time* dependency. If you need to specify an
    # external dependency that is required at runtime, see {#runtime_dependency}
    # instead.
    #
    # @example
    #   dependency 'foo'
    #   dependency 'bar'
    #
    # @param [String] val
    #   the name of a Software dependency
    #
    # @return [Array<String>]
    #   the list of dependencies
    #
    def dependency(val)
      dependencies << val
      dependencies.dup
    end
    expose :dependency

    #
    # Add a package that is a runtime dependency of this project.
    #
    # This is distinct from a build-time dependency, which should correspond to
    # a software definition.
    #
    # @example
    #   runtime_dependency 'foo'
    #
    # @param [String] val
    #   the name of the runtime dependency
    #
    # @return [Array<String>]
    #   the list of runtime dependencies
    #
    def runtime_dependency(val)
      runtime_dependencies << val
      runtime_dependencies.dup
    end
    expose :runtime_dependency

    #
    # Add a new exclusion pattern for a list of files or folders to exclude
    # when making the package.
    #
    # @example
    #   exclude '.git'
    #
    # @param [String] pattern
    #   the thing to exclude
    #
    # @return [Array<String>]
    #   the list of current exclusions
    #
    def exclude(pattern)
      exclusions << pattern
      exclusions.dup
    end
    expose :exclude

    #
    # Add a config file.
    #
    # @example
    #   config_file '/path/to/config.rb'
    #
    # @param [String] val
    #   the name of a config file of your software
    #
    # @return [Array<String>]
    #   the list of current config files
    #
    def config_file(val)
      config_files << val
      config_files.dup
    end
    expose :config_file

    #
    # Add other files or dirs outside of +install_dir+. These files retain their
    # relative paths inside the scratch directory:
    #
    #   /path/to/foo.txt #=> /tmp/package/path/to/foo.txt
    #
    #
    # @example
    #   extra_package_file '/path/to/file'
    #
    # @param [String] val
    #   the name of a dir or file to include in build
    #
    # @return [Array<String>]
    #   the list of current extra package files
    #
    def extra_package_file(val)
      extra_package_files << val
      extra_package_files.dup
    end
    expose :extra_package_file

    #
    # A proxy method to the underlying Ohai system.
    #
    # @example
    #   ohai['platform_family']
    #
    # @return [Ohai]
    #
    def ohai
      Ohai
    end
    expose :ohai

    #
    # Set or retrieve the {#license} of the project.
    #
    # @example
    #   license 'Apache 2.0'
    #
    # @param [String] val
    #   the license to set for the project.
    #
    # @return [String]
    #
    def license(val = NULL)
      if null?(val)
        @license || "Unspecified"
      else
        @license = val
      end
    end
    expose :license

    #
    # Set or retrieve the location of the {#license_file}
    # of the project.  It can either be a relative path inside
    # the project source directory or a URL.
    #
    #
    # @example
    #   license_file 'LICENSES/artistic.txt'
    #
    # @param [String] val
    #   the location of the license file for the project.
    #
    # @return [String]
    #
    def license_file(val = NULL)
      if null?(val)
        @license_file
      else
        @license_file = val
      end
    end
    expose :license_file

    #
    # Location of license file that omnibus will create and that will contain
    # the information about the license of the project plus the details about
    # the licenses of the software components included in the project.
    #
    # If no path is specified  install_dir/LICENSE is used.
    #
    # @example
    #   license_file_path
    #
    # @return [String]
    #
    def license_file_path(path = NULL)
      if null?(path)
        @license_file_path || File.join(install_dir, "LICENSE")
      else
        @license_file_path = File.join(install_dir, path)
      end
    end
    expose :license_file_path

    #
    # Location of json-formated version manifest, written at at the
    # end of the build. If no path is specified
    # +install_dir+/version-manifest.json is used.
    #
    # @example
    #   json_manifest_path
    #
    # @return [String]
    #
    def json_manifest_path(path = NULL)
      if null?(path)
        @json_manifest_path || File.join(install_dir, "version-manifest.json")
      else
        @json_manifest_path = path
      end
    end
    expose :json_manifest_path

    #
    # Location of text-formatted manifest.
    # (+install_dir+/version-manifest.txt if none is provided)
    #
    # This manifest uses the same format used by the
    # 'version-manifest' software definition in omnibus-software.
    #
    # @example
    #   text_manifest_path
    #
    # @return [String]
    #
    def text_manifest_path(path = NULL)
      if null?(path)
        @text_manifest_path || File.join(install_dir, "version-manifest.txt")
      else
        @text_manifest_path = path
      end
    end
    expose :text_manifest_path

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @!group Public API
    #
    # In addition to the DSL methods, the following methods are considered to
    # be the "public API" for a project.
    # --------------------------------------------------

    #
    # Recursively load all the dependencies for this project.
    #
    # @return [true]
    #
    def load_dependencies
      dependencies.each do |dependency|
        Software.load(self, dependency, manifest)
      end

      true
    end

    #
    # The list of software dependencies for this project. These is the software
    # that comprises your project, and is distinct from runtime dependencies.
    #
    # @see #dependency
    #
    # @param [Array<String>]
    #
    # @return [Array<String>]
    #
    def dependencies
      @dependencies ||= []
    end

    #
    # The path (on disk) where this project came from. Warning: this can be
    # +nil+ if a project was dynamically created!
    #
    # @return [String, nil]
    #
    def filepath
      @filepath
    end

    #
    #
    # The list of config files for this software.
    #
    # @return [Array<String>]
    #
    def config_files
      @config_files ||= []
    end

    #
    # The list of files and directories used to build this project.
    #
    # @return [Array<String>]
    #
    def extra_package_files(val = NULL)
      @extra_package_files ||= []
    end

    #
    # The list of software dependencies for this project.
    #
    # These is the software that is used at runtime for your project.
    #
    # @return [Array<String>]
    #
    def runtime_dependencies
      @runtime_dependencies ||= []
    end

    #
    # The list of things this project conflicts with.
    #
    # @return [Array<String>]
    #
    def conflicts
      @conflicts ||= []
    end

    #
    # The list of things this project replaces with.
    #
    # @return [Array<String>]
    #
    def replaces
      @replaces ||= []
    end

    #
    # The list of exclusions for this project.
    #
    # @return [Array<String>]
    #
    def exclusions
      @exclusions ||= []
    end

    #
    # Retrieve the list of overrides for all software being overridden.
    #
    # @return [Hash]
    #
    def overrides
      @overrides ||= {}
    end

    #
    # The list of packagers, in the following format:
    #
    #     {
    #       id: [#<Proc:0x001>, #<Proc:0x002>],
    #       # ...
    #     }
    #
    # @return [Hash<Symbol, Array<Proc>>]
    #   the packager blocks, indexed by key
    #
    def packagers
      @packagers ||= Hash.new { |h, k| h[k] = [] }
    end

    #
    # Instantiate new instances of the best packagers for this system.
    #
    # @return [[~Packager::Base]]
    #
    def packagers_for_system
      @packagers_for_system ||= Packager.for_current_system.map { |p| p.new(self) }
    end

    #
    # The list of compressors, in the following format:
    #
    #     {
    #       id: [#<Proc:0x001>, #<Proc:0x002>],
    #       # ...
    #     }
    #
    # @return [Hash<Symbol, Array<Proc>>]
    #   the compressor blocks, indexed by key
    #
    def compressors
      @compressors ||= Hash.new { |h, k| h[k] = [] }
    end

    #
    # Instantiate a new instance of the best compressor for this system.
    #
    # @return [~Compressor::Base]
    #
    def compressor
      @compressor ||= Compressor.for_current_system(compressors.keys).new(self)
    end

    #
    # The DSL for this build version.
    #
    # @return [BuildVersionDSL]
    #
    def build_version_dsl
      @build_version_dsl
    end

    #
    # Indicates whether the given  +software+ is defined as a software component
    # of this project.
    #
    # @param [String, Software] software
    #   the software or name of the software to find
    #
    # @return [true, false]
    #
    def dependency?(software)
      name = software.is_a?(Software) ? software.name : software
      dependencies.include?(name)
    end

    #
    # The library for this Omnibus project.
    #
    # @return [Library]
    #
    def library
      @library ||= Library.new(self)
    end

    #
    # Dirty the cache for this project. This can be called by other projects,
    # install path cache, or software definitions to invalidate the cache for
    # this project.
    #
    # @param [Software] software
    #   the software that dirtied the cache
    #
    # @return [true, false]
    #
    def dirty!(software)
      raise ProjectAlreadyDirty.new(self) if culprit
      @culprit = software
    end

    #
    # The software definition which dirtied this project.
    #
    # @return [Software, nil]
    #
    def culprit
      @culprit
    end

    #
    # Determine if the cache for this project is dirty.
    #
    # @return [true, false]
    #
    def dirty?
      !!culprit
    end

    #
    # Comparator for two projects (+name+)
    #
    # @return [1, 0, -1]
    #
    def <=>(other)
      name <=> other.name
    end

    #
    # Cache the build order so we don't re-compute
    #
    # @return [Array<Omnibus::Software>]
    #
    def softwares
      @softwares ||= library.build_order
    end

    #
    # Generate a version manifest of the loaded software sources.
    #
    # @returns [Omnibus::Manifest]
    #
    def built_manifest
      log.info(log_key) { "Building version manifest" }
      m = Omnibus::Manifest.new(build_version, build_git_revision, license)
      softwares.each do |software|
        m.add(software.name, software.manifest_entry)
      end
      m
    end

    def download
      ThreadPool.new(Config.workers) do |pool|
        softwares.each do |software|
          pool.schedule { software.fetch }
        end
      end
    end

    def build
      FileUtils.rm_rf(install_dir)
      FileUtils.mkdir_p(install_dir)

      Licensing.create_incrementally(self) do |license_collector|
        softwares.each do |software|
          software.build_me([license_collector])
        end

        # If nothing has dirtied the cache, checkout the last cache dir
        restore_complete_build unless dirty?
      end

      write_json_manifest
      write_text_manifest
      HealthCheck.run!(self)
      package_me
      compress_me
    end

    def restore_complete_build
      if Config.use_git_caching
        log.info(log_key) { "Cache not dirtied, restoring last marker" }
        GitCache.new(softwares.last).restore_from_cache
      end
    end

    def write_json_manifest
      File.open(json_manifest_path, "w") do |f|
        f.write(FFI_Yajl::Encoder.encode(built_manifest.to_hash, pretty: true))
      end
    end

    #
    # Writes a text manifest to the text_manifest_path.  This uses the
    # same method as the "version-manifest" software definition in
    # omnibus-software.
    #
    def write_text_manifest
      File.open(text_manifest_path, "w") do |f|
        f.puts "#{name} #{build_version}"
        f.puts ""
        f.puts Omnibus::Reports.pretty_version_map(self)
      end
    end

    #
    # Download and build the project. Preserved for backwards
    # compatibility.
    #
    def build_me
      # Download all softwares in parallel
      download
      build
    end

    #
    #
    #
    def package_me
      destination = File.expand_path("pkg", Config.project_root)

      # Create the destination directory
      unless File.directory?(destination)
        FileUtils.mkdir_p(destination)
      end

      packagers_for_system.each do |packager|
        # Evaluate any packager-specific blocks, in order.
        packagers[packager.id].each do |block|
          packager.evaluate(&block)
        end

        if packager.skip_packager
          log.info(log_key) { "Skipping #{packager.id} per project configuration." }
          next
        end
        # Run the actual packager
        packager.run!

        # Copy the generated package and metadata back into the workspace
        package_path = File.join(Config.package_dir, packager.package_name)
        FileUtils.cp(package_path, destination, preserve: true)
        FileUtils.cp("#{package_path}.metadata.json", destination, preserve: true)
      end
    end

    #
    #
    #
    def compress_me
      destination = File.expand_path("pkg", Config.project_root)

      # Create the destination directory
      unless File.directory?(destination)
        FileUtils.mkdir_p(destination)
      end

      # Evaluate any compressor-specific blocks, in order.
      compressors[compressor.id].each do |block|
        compressor.evaluate(&block)
      end

      # Run the actual compressor
      compressor.run!

      # Copy the compressed package and metadata back into the workspace, if it
      # exists
      package_path = File.join(Config.package_dir, compressor.package_name)

      if File.file?(package_path)
        FileUtils.cp(package_path, destination)
        FileUtils.cp("#{package_path}.metadata.json", destination)
      end
    end

    #
    # The unique "hash" for this project.
    #
    # @see (#shasum)
    #
    # @return [Fixnum]
    #
    def hash
      shasum.hash
    end

    #
    # Determine if two projects are identical.
    #
    # @param [Project] other
    #
    # @return [true, false]
    #
    def ==(other)
      hash == other.hash
    end
    alias_method :eql?, :==

    #
    # The unique SHA256 for this project.
    #
    # A project is defined by its name, its build_version, its install_dir,
    # and any overrides (as JSON). Additionally, if provided, the actual file
    # contents are included in the SHA to ensure uniqueness.
    #
    # @return [String]
    #
    def shasum
      @shasum ||= begin
        digest = Digest::SHA256.new

        update_with_string(digest, name)
        update_with_string(digest, install_dir)
        update_with_string(digest, FFI_Yajl::Encoder.encode(overrides))

        if filepath && File.exist?(filepath)
          update_with_file_contents(digest, filepath)
        else
          update_with_string(digest, "<DYNAMIC>")
        end

        digest.hexdigest
      end
    end

    #
    # @!endgroup
    # --------------------------------------------------

    private

    def get_local_revision
      GitRepository.new("./").revision
    rescue StandardError
      "unknown"
    end

    #
    # The log key for this project, overriden to include the name of the
    # project for build output.
    #
    # @return [String]
    #
    def log_key
      @log_key ||= "#{super}: #{name}"
    end
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Packager::DEB < Packager::Base
    id :deb

    setup do
      # Copy the full-stack installer into our scratch directory, accounting for
      # any excluded files.
      #
      # /opt/hamlet => /tmp/daj29013/opt/hamlet
      destination = File.join(staging_dir, project.install_dir)
      FileSyncer.sync(project.install_dir, destination, exclude: exclusions)

      # Copy over any user-specified extra package files.
      #
      # Files retain their relative paths inside the scratch directory, so
      # we need to grab the dirname of the file, create that directory, and
      # then copy the file into that directory.
      #
      # extra_package_file '/path/to/foo.txt' #=> /tmp/scratch/path/to/foo.txt
      project.extra_package_files.each do |file|
        parent      = File.dirname(file)
        destination = File.join(staging_dir, parent)

        create_directory(destination)
        copy_file(file, destination)
      end

      # Create the Debain file directory
      create_directory(debian_dir)
    end

    build do
      # Render the Debian +control+ file
      write_control_file

      # Write the conffiles
      write_conffiles_file

      # Write the scripts
      write_scripts

      # Render the md5 sums
      write_md5_sums

      # Create the deb
      create_deb_file

      # Sign the deb
      sign_deb_file
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # Set or return the signing passphrase. If this value is provided,
    # Omnibus will attempt to sign the DEB.
    #
    # @example
    #   signing_passphrase "foo"
    #
    # @param [String] val
    #   the passphrase to use when signing the DEB
    #
    # @return [String]
    #   the DEB-signing passphrase
    #
    def signing_passphrase(val = NULL)
      if null?(val)
        @signing_passphrase
      else
        @signing_passphrase = val
      end
    end
    expose :signing_passphrase

    #
    # Set or return the vendor who made this package.
    #
    # @example
    #   vendor "Seth Vargo <sethvargo@gmail.com>"
    #
    # @param [String] val
    #   the vendor who make this package
    #
    # @return [String]
    #   the vendor who make this package
    #
    def vendor(val = NULL)
      if null?(val)
        @vendor || "Omnibus <omnibus@getchef.com>"
      else
        unless val.is_a?(String)
          raise InvalidValue.new(:vendor, "be a String")
        end

        @vendor = val
      end
    end
    expose :vendor

    #
    # Set or return the license for this package.
    #
    # @example
    #   license "Apache 2.0"
    #
    # @param [String] val
    #   the license for this package
    #
    # @return [String]
    #   the license for this package
    #
    def license(val = NULL)
      if null?(val)
        @license || project.license
      else
        unless val.is_a?(String)
          raise InvalidValue.new(:license, "be a String")
        end

        @license = val
      end
    end
    expose :license

    #
    # Set or return the priority for this package.
    #
    # @example
    #   priority "extra"
    #
    # @param [String] val
    #   the priority for this package
    #
    # @return [String]
    #   the priority for this package
    #
    def priority(val = NULL)
      if null?(val)
        @priority || "extra"
      else
        unless val.is_a?(String)
          raise InvalidValue.new(:priority, "be a String")
        end

        @priority = val
      end
    end
    expose :priority

    #
    # Set or return the section for this package.
    #
    # @example
    #   section "databases"
    #
    # @param [String] val
    #   the section for this package
    #
    # @return [String]
    #   the section for this package
    #
    def section(val = NULL)
      if null?(val)
        @section || "misc"
      else
        unless val.is_a?(String)
          raise InvalidValue.new(:section, "be a String")
        end

        @section = val
      end
    end
    expose :section

    #
    # Compression algorithm (gzip, xz, none) to use (-Z).
    #
    # @example
    #   compression_type :xz
    #
    # @param [Symbol] val
    #   type of compression (:gzip, :xz, :none)
    #
    # @return [Symbol]
    #   type of compression for this package
    #
    def compression_type(val = NULL)
      if null?(val)
        @compression_type || :gzip
      else
        unless val.is_a?(Symbol) && [:gzip, :xz, :none].member?(val)
          raise InvalidValue.new(:compression_type, "be a Symbol (:gzip, :xz, or :none)")
        end

        @compression_type = val
      end
    end
    expose :compression_type

    #
    # Compression level (1-9) to use (-Z).
    #
    # @example
    #   compression_level 1
    #
    # @param [Integer] val
    #   level of compression (1, .., 9)
    #
    # @return [Integer]
    #   level of compression for this package
    #
    def compression_level(val = NULL)
      if null?(val)
        @compression_level || 9
      else
        unless val.is_a?(Integer) && 1 <= val && 9 >= val
          raise InvalidValue.new(:compression_level, "be an Integer between 1 and 9")
        end

        @compression_level = val
      end
    end
    expose :compression_level

    #
    # Compression strategy to use (-Z).
    # For gzip: :filtered, :huffman, :rle, or :fixed;
    # for xz: :extreme
    # (nil means parameter will not be passsed to dpkg-deb)
    #
    # @example
    #   compression_strategy :extreme
    #
    # @param [Symbol] val
    #   compression strategy
    #
    # @return [Symbol]
    #   compression strategy for this package
    #
    def compression_strategy(val = NULL)
      if null?(val)
        @compression_strategy
      else
        unless val.is_a?(Symbol) &&
            [:filtered, :huffman, :rle, :fixed, :extreme].member?(val)
          raise InvalidValue.new(:compression_strategy, "be a Symbol (:filtered, "\
                                                        ":huffman, :rle, :fixed, or :extreme)")
        end

        @compression_strategy = val
      end
    end
    expose :compression_strategy

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # The name of the package to create. Note, this does **not** include the
    # extension.
    #
    def package_name
      "#{safe_base_package_name}_#{safe_version}-#{safe_build_iteration}_#{safe_architecture}.deb"
    end

    #
    # The path where Debian-specific files will live.
    #
    # @example
    #   /var/.../chef-server_11.12.4/DEBIAN
    #
    # @return [String]
    #
    def debian_dir
      @debian_dir ||= File.join(staging_dir, "DEBIAN")
    end

    #
    # Render a control file in +#{debian_dir}/control+ using the supplied ERB
    # template.
    #
    # @return [void]
    #
    def write_control_file
      render_template(resource_path("control.erb"),
        destination: File.join(debian_dir, "control"),
        variables: {
          name: safe_base_package_name,
          version: safe_version,
          iteration: safe_build_iteration,
          vendor: vendor,
          license: license,
          architecture: safe_architecture,
          maintainer: project.maintainer,
          installed_size: package_size,
          homepage: project.homepage,
          description: project.description,
          priority: priority,
          section: section,
          conflicts: project.conflicts,
          replaces: project.replaces,
          dependencies: project.runtime_dependencies,
        }
      )
    end

    #
    # Render the list of config files into the conffile.
    #
    # @return [void]
    #
    def write_conffiles_file
      return if project.config_files.empty?

      render_template(resource_path("conffiles.erb"),
        destination: File.join(debian_dir, "conffiles"),
        variables: {
          config_files: project.config_files,
        }
      )
    end

    #
    # Copy all scripts in {Project#package_scripts_path} to the control
    # directory of this repo.
    #
    # @return [void]
    #
    def write_scripts
      %w{preinst postinst prerm postrm}.each do |script|
        path = File.join(project.package_scripts_path, script)

        if File.file?(path)
          log.debug(log_key) { "Adding script `#{script}' to `#{debian_dir}' from #{path}" }
          copy_file(path, debian_dir)
          log.debug(log_key) { "SCRIPT FILE:  #{debian_dir}/#{script}" }
          FileUtils.chmod(0755, File.join(debian_dir, script))
        end
      end
    end

    #
    # Generate a list of the md5 sums of every file in the package and write it
    # to +#{debian_dir}/control/md5sums+.
    #
    # @return [void]
    #
    def write_md5_sums
      path = "#{staging_dir}/**/*"
      hash = FileSyncer.glob(path).inject({}) do |hash, path|
        if File.file?(path) && !File.symlink?(path) && !(File.dirname(path) == debian_dir)
          relative_path = path.gsub("#{staging_dir}/", "")
          hash[relative_path] = digest(path, :md5)
        end

        hash
      end

      render_template(resource_path("md5sums.erb"),
        destination: File.join(debian_dir, "md5sums"),
        variables: {
          md5sums: hash,
        }
      )
    end

    #
    # Create the +.deb+ file, compressing at gzip level 9. The use of the
    # +fakeroot+ command is required so that the package is owned by
    # +root:root+, but the build user does not need to have sudo permissions.
    #
    # @return [void]
    #
    def create_deb_file
      log.info(log_key) { "Creating .deb file" }

      # Execute the build command
      Dir.chdir(Config.package_dir) do
        shellout!("fakeroot dpkg-deb #{compression_params} -D --build #{staging_dir} #{package_name}")
      end
    end

    #
    # Return the parameters passed to dpkg-deb for setting the compression
    # according to configuration.
    #
    # @return [String]
    #
    def compression_params
      if compression_strategy
        "-z#{compression_level} -Z#{compression_type} -S#{compression_strategy}"
      else
        "-z#{compression_level} -Z#{compression_type}"
      end
    end

    #
    # Sign the  +.deb+ file with gpg. This has to be done as separate steps
    # from creating the +.deb+ file. See +debsigs+ source for behavior
    # replicated here. +https://gitlab.com/debsigs/debsigs/blob/master/debsigs.txt#L103-124+
    #
    # @return [void]
    def sign_deb_file
      if !signing_passphrase
        log.info(log_key) { "Signing not enabled for .deb file" }
        return
      end

      log.info(log_key) { "Signing enabled for .deb file" }

      # Check our dependencies and determine command for GnuPG. +Omnibus.which+ returns the path, or nil.
      gpg = nil
      if Omnibus.which("gpg2")
        gpg = "gpg2"
      elsif Omnibus.which("gpg")
        gpg = "gpg"
      end

      if gpg && Omnibus.which("ar")
        # Create a directory that will be cleaned when we leave the block
        Dir.mktmpdir do |tmp_dir|
          Dir.chdir(tmp_dir) do
            # Extract the deb file contents
            shellout!("ar x #{Config.package_dir}/#{package_name}")
            # Concatenate contents, in order per +debsigs+ documentation.
            shellout!("cat debian-binary control.tar.* data.tar.* > complete")
            # Create signature (as +root+)
            gpg_command =  "#{gpg} --armor --sign --detach-sign"
            gpg_command << " --local-user '#{project.maintainer}'"
            gpg_command << " --homedir #{ENV['HOME']}/.gnupg" # TODO: Make this configurable
            ## pass the +signing_passphrase+ via +STDIN+
            gpg_command << " --batch --no-tty"
            ## Check `gpg` for the compatibility/need of pinentry-mode
            # - We're calling gpg with the +--pinentry-mode+ argument, and +STDIN+ of +/dev/null+
            # - This _will_ fail with exit code 2 no matter what. We want to check the +STDERR+
            #   for the error message about the parameter. If it is _not present_ in the
            #   output, then we _do_ want to add it. (If +grep -q+ is +1+, add parameter)
            if shellout("#{gpg} --pinentry-mode loopback </dev/null 2>&1 | grep -q pinentry-mode").exitstatus == 1
              gpg_command << " --pinentry-mode loopback"
            end
            gpg_command << " --passphrase-fd 0"
            gpg_command << " -o _gpgorigin complete"
            shellout!("fakeroot #{gpg_command}", input: signing_passphrase)
            # Append +_gpgorigin+ to the +.deb+ file (as +root+)
            shellout!("fakeroot ar rc #{Config.package_dir}/#{package_name} _gpgorigin")
          end
        end
      else
        log.info(log_key) { "Signing not possible. Ensure that GnuPG and GNU AR are available" }
      end
    end

    #
    # The size of this Debian package. This is dynamically calculated.
    #
    # @return [Fixnum]
    #
    def package_size
      @package_size ||= begin
        path  = "#{project.install_dir}/**/*"
        total = FileSyncer.glob(path).inject(0) do |size, path|
          unless File.directory?(path) || File.symlink?(path)
            size += File.size(path)
          end

          size
        end

        # Per http://www.debian.org/doc/debian-policy/ch-controlfields.html, the
        # disk space is given as the integer value of the estimated installed
        # size in bytes, divided by 1024 and rounded up.
        total / 1024
      end
    end

    #
    # Return the Debian-ready base package name, converting any invalid characters to
    # dashes (+-+).
    #
    # @return [String]
    #
    def safe_base_package_name
      if project.package_name =~ /\A[a-z0-9\.\+\-]+\z/
        project.package_name.dup
      else
        converted = project.package_name.downcase.gsub(/[^a-z0-9\.\+\-]+/, "-")

        log.warn(log_key) do
          "The `name' component of Debian package names can only include " \
          "lower case alphabetical characters (a-z), numbers (0-9), dots (.), " \
          "plus signs (+), and dashes (-). Converting `#{project.package_name}' to " \
          "`#{converted}'."
        end

        converted
      end
    end

    #
    # This is actually just the regular build_iteration, but it felt lonely
    # among all the other +safe_*+ methods.
    #
    # @return [String]
    #
    def safe_build_iteration
      project.build_iteration
    end

    #
    # Return the Debian-ready version, replacing all dashes (+-+) with tildes
    # (+~+) and converting any invalid characters to underscores (+_+).
    #
    # @return [String]
    #
    def safe_version
      version = project.build_version.dup

      if version =~ /\-/
        converted = version.tr("-", "~")

        log.warn(log_key) do
          "Dashes hold special significance in the Debian package versions. " \
          "Versions that contain a dash and should be considered an earlier " \
          "version (e.g. pre-releases) may actually be ordered as later " \
          "(e.g. 12.0.0-rc.6 > 12.0.0). We'll work around this by replacing " \
          "dashes (-) with tildes (~). Converting `#{project.build_version}' " \
          "to `#{converted}'."
        end

        version = converted
      end

      if version =~ /\A[a-zA-Z0-9\.\+\:\~]+\z/
        version
      else
        converted = version.gsub(/[^a-zA-Z0-9\.\+\:\~]+/, "_")

        log.warn(log_key) do
          "The `version' component of Debian package names can only include " \
          "alphabetical characters (a-z, A-Z), numbers (0-9), dots (.), " \
          "plus signs (+), dashes (-), tildes (~) and colons (:). Converting " \
          "`#{project.build_version}' to `#{converted}'."
        end

        converted
      end
    end

    #
    # Debian does not follow the standards when naming 64-bit packages.
    #
    # @return [String]
    #
    def safe_architecture
      @safe_architecture ||= shellout!("dpkg --print-architecture").stdout.split("\n").first || "noarch"
    end
  end
end


#
# Copyright 2012-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class GitFetcher < Fetcher
    #
    # A fetch is required if the git repository is not cloned or if the local
    # revision does not match the desired revision.
    #
    # @return [true, false]
    #
    def fetch_required?
      !(cloned? && contains_revision?(resolved_version))
    end

    #
    # The version identifier for this git location. This is computed using the
    # current revision on disk.
    #
    # @return [String]
    #
    def version_guid
      "git:#{current_revision}"
    end

    #
    # Clean the project directory by resetting the current working tree to
    # the required revision.
    #
    # @return [true, false]
    #   true if the project directory was cleaned, false otherwise.
    #   In our case, we always return true because we always call
    #   git checkout/clean.
    #
    def clean
      log.info(log_key) { "Cleaning existing clone" }
      git_checkout
      git("clean -fdx")
      true
    end

    #
    # Fetch (clone) or update (fetch) the remote git repository.
    #
    # @return [void]
    #
    def fetch
      log.info(log_key) { "Fetching from `#{source_url}'" }
      create_required_directories

      if cloned?
        git_fetch
      else
        force_recreate_project_dir! unless dir_empty?(project_dir)
        git_clone
      end
    end

    #
    # The version for this item in the cache.
    #
    # This method is called *before* clean but *after* fetch. Do not ever
    # use the contents of the project_dir here.
    #
    # We aren't including the source/repo path here as there could be
    # multiple branches/tags that all point to the same commit. We're
    # assuming that we won't realistically ever get two git commits
    # that are unique but share sha1s.
    #
    # TODO: Does this work with submodules?
    #
    # @return [String]
    #
    def version_for_cache
      "revision:#{resolved_version}"
    end

    private

    #
    # The URL where the git source should be downloaded from.
    #
    # @return [String]
    #
    def source_url
      source[:git]
    end

    #
    # Determine if submodules should be cloned.
    #
    # @return [true, false]
    #
    def clone_submodules?
      source[:submodules] || false
    end

    #
    # Determine if a directory is empty
    #
    # @return [true, false]
    #
    def dir_empty?(dir)
      Dir.entries(dir).reject { |d| [".", ".."].include?(d) }.empty?
    end

    #
    # Forcibly remove and recreate the project directory
    #
    def force_recreate_project_dir!
      log.warn(log_key) { "Removing existing directory #{project_dir} before cloning" }
      FileUtils.rm_rf(project_dir)
      Dir.mkdir(project_dir)
    end

    #
    # Determine if the clone exists.
    #
    # @return [true, false]
    #
    def cloned?
      File.exist?("#{project_dir}/.git")
    end

    #
    # Clone the +source_url+ into the +project_dir+.
    #
    # @return [void]
    #
    def git_clone
      retry_block("git clone", [CommandTimeout, CommandFailed]) do
        git("clone#{" --recursive" if clone_submodules?} #{source_url} .")
      end
    end

    #
    # Checkout the +resolved_version+.
    #
    # @return [void]
    #
    def git_checkout
      # We are hoping to perform a checkout with detached HEAD (that's the
      # default when a sha1 is provided).  git older than 1.7.5 doesn't
      # support the --detach flag.
      git("checkout #{resolved_version} -f -q")
      if clone_submodules?
        retry_block("git submodule update", [CommandTimeout, CommandFailed]) do
          git("submodule update --recursive")
        end
      end
    end

    #
    # Fetch the remote tags and refs, and reset to +resolved_version+.
    #
    # @return [void]
    #
    def git_fetch
      fetch_cmd = "fetch #{source_url} #{described_version}"
      fetch_cmd << " --recurse-submodules=on-demand" if clone_submodules?
      retry_block("git fetch", [CommandTimeout, CommandFailed]) do
        git(fetch_cmd)
      end
    end

    #
    # The current revision for the cloned checkout.
    #
    # @return [String]
    #
    def current_revision
      cmd = git("rev-parse HEAD")
      cmd.stdout.strip
    rescue CommandFailed
      log.debug(log_key) { "unable to determine current revision" }
      nil
    end

    #
    # Check if the current clone has the requested commit id.
    #
    # @return [true, false]
    #
    def contains_revision?(rev)
      cmd = git("cat-file -t #{rev}")
      cmd.stdout.strip == "commit"
    rescue CommandFailed
      log.debug(log_key) { "unable to determine presence of commit #{rev}" }
      false
    end

    #
    # Execute the given git command, inside the +project_dir+.
    #
    # autcrlf is a hack to help support windows and posix clients using the
    # same repository but canonicalizing files as they are committed to the
    # repo but converting line endings when they are actually checked out
    # into a working tree. We do not want to change the on-disk representation
    # of our sources regardless of the platform we are building on unless
    # explicitly asked for. Hence, we disable autocrlf.
    #
    # @see Util#shellout!
    #
    # @return [Mixlib::ShellOut]
    #   the shellout object
    #
    def git(command)
      shellout!("git -c core.autocrlf=false #{command}", cwd: project_dir)
    end

    # Class methods

    # Return the SHA1 corresponding to a ref as determined by the remote source.
    #
    # @return [String]
    #
    def self.resolve_version(ref, source)
      if sha_hash?(ref)
        # A git server negotiates in terms of refs during the info-refs phase
        # of a fetch. During upload-pack, the client is not allowed to specify
        # any sha1s in the "wants" unless the server has publicized them during
        # info-refs. Hence, the server is allowed to drop requests to fetch
        # particular sha1s, even if it is an otherwise reachable commit object.
        # Only when the service is specifically configured with
        # uploadpack.allowReachableSHA1InWant is there any guarantee that it
        # considers "naked" wants.
        log.warn(log_key) { "git fetch on a sha1 is not guaranteed to work" }
        log.warn(log_key) { "Specify a ref name instead of #{ref} on #{source}" }
        ref
      else
        revision_from_remote_reference(ref, source)
      end
    end

    #
    # Determine if the given revision is a SHA
    #
    # @return [true, false]
    #
    def self.sha_hash?(rev)
      rev =~ /^[0-9a-f]{4,40}$/i
    end

    #
    # Return the SHA corresponding to ref.
    #
    # If ref is an annotated tag, return the SHA that was tagged not the SHA of
    # the tag itself.
    #
    # @return [String]
    #
    def self.revision_from_remote_reference(ref, source)
      # execute `git ls-remote` the trailing '*' does globbing. This
      # allows us to return the SHA of the tagged commit for annotated
      # tags. We take care to only return exact matches in
      # process_remote_list.
      remote_list = retry_block("git ls-remote", [CommandTimeout, CommandFailed]) do
        shellout!("git ls-remote \"#{source[:git]}\" \"#{ref}*\"").stdout
      end

      commit_ref = dereference_annotated_tag(remote_list, ref)

      unless commit_ref
        raise UnresolvableGitReference.new(ref)
      end
      commit_ref
    end

    #
    # Dereference annotated tags.
    #
    # The +remote_list+ parameter is assumed to look like this:
    #
    #   a2ed66c01f42514bcab77fd628149eccb4ecee28        refs/tags/rel-0.11.0
    #   f915286abdbc1907878376cce9222ac0b08b12b8        refs/tags/rel-0.11.0^{}
    #
    # The SHA with ^{} is the commit pointed to by an annotated
    # tag. If ref isn't an annotated tag, there will not be a line
    # with trailing ^{}.
    #
    # @param [String] remote_list
    #   output from `git ls-remote origin` command
    # @param [String] ref
    #   the target git ref
    #
    # @return [String]
    #
    def self.dereference_annotated_tag(remote_list, ref)
      # We'll return the SHA corresponding to the ^{} which is the
      # commit pointed to by an annotated tag. If no such commit
      # exists (not an annotated tag) then we return the SHA of the
      # ref.  If nothing matches, return "".
      lines = remote_list.split("\n")
      matches = lines.map { |line| line.split("\t") }
      # First try for ^{} indicating the commit pointed to by an
      # annotated tag.
      tagged_commit = matches.find { |m| m[1].end_with?("#{ref}^{}") }
      if tagged_commit
        tagged_commit.first
      else
        found = matches.find { |m| m[1].end_with?("#{ref}") }
        if found
          found.first
        else
          nil
        end
      end
    end
  end
end


require 'openssl'
require 'net/ssh/authentication/pub_key_fingerprint'

module OpenSSL

  # This class is originally defined in the OpenSSL module. As needed, methods
  # have been added to it by the Net::SSH module for convenience in dealing with
  # SSH functionality.
  class BN
    # Converts a BN object to a string. The format used is that which is
    # required by the SSH2 protocol.
    def to_ssh
      if zero?
        return [0].pack("N")
      else
        buf = to_s(2)
        if buf.getbyte(0)[7] == 1
          return [buf.length + 1, 0, buf].pack("NCA*")
        else
          return [buf.length, buf].pack("NA*")
        end
      end
    end
  end

  module PKey

    class PKey
      include Net::SSH::Authentication::PubKeyFingerprint
    end

    # This class is originally defined in the OpenSSL module. As needed, methods
    # have been added to it by the Net::SSH module for convenience in dealing
    # with SSH functionality.
    class DH
      # Determines whether the pub_key for this key is valid. (This algorithm
      # lifted more-or-less directly from OpenSSH, dh.c, dh_pub_is_valid.)
      def valid?
        return false if pub_key.nil? || pub_key < 0
        bits_set = 0
        pub_key.num_bits.times { |i| bits_set += 1 if pub_key.bit_set?(i) }
        return (bits_set > 1 && pub_key < p)
      end
    end

    # This class is originally defined in the OpenSSL module. As needed, methods
    # have been added to it by the Net::SSH module for convenience in dealing
    # with SSH functionality.
    class RSA
      # Returns "ssh-rsa", which is the description of this key type used by the
      # SSH2 protocol.
      def ssh_type
        "ssh-rsa"
      end

      def ssh_signature_type
        ssh_type
      end

      # Converts the key to a blob, according to the SSH2 protocol.
      def to_blob
        @blob ||= Net::SSH::Buffer.from(:string, ssh_type, :bignum, e, :bignum, n).to_s
      end

      # Verifies the given signature matches the given data.
      def ssh_do_verify(sig, data)
        verify(OpenSSL::Digest::SHA1.new, sig, data)
      end

      # Returns the signature for the given data.
      def ssh_do_sign(data)
        sign(OpenSSL::Digest::SHA1.new, data)
      end
    end

    # This class is originally defined in the OpenSSL module. As needed, methods
    # have been added to it by the Net::SSH module for convenience in dealing
    # with SSH functionality.
    class DSA
      # Returns "ssh-dss", which is the description of this key type used by the
      # SSH2 protocol.
      def ssh_type
        "ssh-dss"
      end

      def ssh_signature_type
        ssh_type
      end

      # Converts the key to a blob, according to the SSH2 protocol.
      def to_blob
        @blob ||= Net::SSH::Buffer.from(:string, ssh_type,
          :bignum, p, :bignum, q, :bignum, g, :bignum, pub_key).to_s
      end

      # Verifies the given signature matches the given data.
      def ssh_do_verify(sig, data)
        sig_r = sig[0,20].unpack("H*")[0].to_i(16)
        sig_s = sig[20,20].unpack("H*")[0].to_i(16)
        a1sig = OpenSSL::ASN1::Sequence([
           OpenSSL::ASN1::Integer(sig_r),
           OpenSSL::ASN1::Integer(sig_s)
        ])
        return verify(OpenSSL::Digest::SHA1.new, a1sig.to_der, data)
      end

      # Signs the given data.
      def ssh_do_sign(data)
        sig = sign(OpenSSL::Digest::SHA1.new, data)
        a1sig = OpenSSL::ASN1.decode(sig)

        sig_r = a1sig.value[0].value.to_s(2)
        sig_s = a1sig.value[1].value.to_s(2)

        raise OpenSSL::PKey::DSAError, "bad sig size" if sig_r.length > 20 || sig_s.length > 20

        sig_r = "\0" * (20 - sig_r.length) + sig_r if sig_r.length < 20
        sig_s = "\0" * (20 - sig_s.length) + sig_s if sig_s.length < 20

        return sig_r + sig_s
      end
    end

    if defined?(OpenSSL::PKey::EC)
      # This class is originally defined in the OpenSSL module. As needed, methods
      # have been added to it by the Net::SSH module for convenience in dealing
      # with SSH functionality.
      class EC
        CurveNameAlias = {
          "nistp256" => "prime256v1",
          "nistp384" => "secp384r1",
          "nistp521" => "secp521r1"
        }

        CurveNameAliasInv = {
          "prime256v1" => "nistp256",
          "secp384r1" => "nistp384",
          "secp521r1" => "nistp521"
        }

        def self.read_keyblob(curve_name_in_type, buffer)
          curve_name_in_key = buffer.read_string
          raise Net::SSH::Exception, "curve name mismatched (`#{curve_name_in_key}' with `#{curve_name_in_type}')" unless curve_name_in_type == curve_name_in_key
          public_key_oct = buffer.read_string
          begin
            key = OpenSSL::PKey::EC.new(OpenSSL::PKey::EC::CurveNameAlias[curve_name_in_key])
            group = key.group
            point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(public_key_oct, 2))
            key.public_key = point

            return key
          rescue OpenSSL::PKey::ECError
            raise NotImplementedError, "unsupported key type `#{type}'"
          end
        end

        # Returns the description of this key type used by the
        # SSH2 protocol, like "ecdsa-sha2-nistp256"
        def ssh_type
          "ecdsa-sha2-#{CurveNameAliasInv[self.group.curve_name]}"
        end

        def ssh_signature_type
          ssh_type
        end

        def digester
          if self.group.curve_name =~ /^[a-z]+(\d+)\w*\z/
            curve_size = $1.to_i
            if curve_size <= 256
              OpenSSL::Digest::SHA256.new
            elsif curve_size <= 384
              OpenSSL::Digest::SHA384.new
            else
              OpenSSL::Digest::SHA512.new
            end
          else
            OpenSSL::Digest::SHA256.new
          end
        end
        private :digester

        # Converts the key to a blob, according to the SSH2 protocol.
        def to_blob
          @blob ||= Net::SSH::Buffer.from(:string, ssh_type,
                                          :string, CurveNameAliasInv[self.group.curve_name],
                                          :mstring, self.public_key.to_bn.to_s(2)).to_s
          @blob
        end

        # Verifies the given signature matches the given data.
        def ssh_do_verify(sig, data)
          digest = digester.digest(data)
          a1sig = nil

          begin
            sig_r_len = sig[0,4].unpack("H*")[0].to_i(16)
            sig_l_len = sig[4 + sig_r_len,4].unpack("H*")[0].to_i(16)

            sig_r = sig[4,sig_r_len].unpack("H*")[0]
            sig_s = sig[4 + sig_r_len + 4,sig_l_len].unpack("H*")[0]

            a1sig = OpenSSL::ASN1::Sequence([
              OpenSSL::ASN1::Integer(sig_r.to_i(16)),
              OpenSSL::ASN1::Integer(sig_s.to_i(16))
            ])
          rescue StandardError
          end

          if a1sig == nil
            return false
          else
            dsa_verify_asn1(digest, a1sig.to_der)
          end
        end

        # Returns the signature for the given data.
        def ssh_do_sign(data)
          digest = digester.digest(data)
          sig = dsa_sign_asn1(digest)
          a1sig = OpenSSL::ASN1.decode(sig)

          sig_r = a1sig.value[0].value
          sig_s = a1sig.value[1].value

          return Net::SSH::Buffer.from(:bignum, sig_r, :bignum, sig_s).to_s
        end

        class Point
          # Returns the description of this key type used by the
          # SSH2 protocol, like "ecdsa-sha2-nistp256"
          def ssh_type
            "ecdsa-sha2-#{CurveNameAliasInv[self.group.curve_name]}"
          end

          # Converts the key to a blob, according to the SSH2 protocol.
          def to_blob
            @blob ||= Net::SSH::Buffer.from(:string, ssh_type,
                                            :string, CurveNameAliasInv[self.group.curve_name],
                                            :mstring, self.to_bn.to_s(2)).to_s
            @blob
          end
        end
      end
    else
      class OpenSSL::PKey::ECError < RuntimeError
        # for compatibility with interpreters
        # without EC support (i.e. JRuby)
      end
    end
  end
end


require 'net/ssh/errors'
require 'net/ssh/transport/constants'
require 'net/ssh/transport/kex/diffie_hellman_group1_sha1'

module Net::SSH::Transport::Kex

  # A key-exchange service implementing the
  # "diffie-hellman-group-exchange-sha1" key-exchange algorithm.
  class DiffieHellmanGroupExchangeSHA1 < DiffieHellmanGroup1SHA1
    MINIMUM_BITS      = 1024
    MAXIMUM_BITS      = 8192

    KEXDH_GEX_GROUP   = 31
    KEXDH_GEX_INIT    = 32
    KEXDH_GEX_REPLY   = 33
    KEXDH_GEX_REQUEST = 34

    private

    # Compute the number of bits needed for the given number of bytes.
    def compute_need_bits
      # for Compatibility: OpenSSH requires (need_bits * 2 + 1) length of parameter
      need_bits = data[:need_bytes] * 8 * 2 + 1

      data[:minimum_dh_bits] ||= MINIMUM_BITS

      if need_bits < data[:minimum_dh_bits]
        need_bits = data[:minimum_dh_bits]
      elsif need_bits > MAXIMUM_BITS
        need_bits = MAXIMUM_BITS
      end

      data[:need_bits] = need_bits
      data[:need_bytes] = need_bits / 8
    end

    # Returns the DH key parameters for the given session.
    def get_parameters
      compute_need_bits

      # request the DH key parameters for the given number of bits.
      buffer = Net::SSH::Buffer.from(:byte, KEXDH_GEX_REQUEST, :long, data[:minimum_dh_bits],
        :long, data[:need_bits], :long, MAXIMUM_BITS)
      connection.send_message(buffer)

      buffer = connection.next_message
      raise Net::SSH::Exception, "expected KEXDH_GEX_GROUP, got #{buffer.type}" unless buffer.type == KEXDH_GEX_GROUP

      p = buffer.read_bignum
      g = buffer.read_bignum

      [p, g]
    end

    # Returns the INIT/REPLY constants used by this algorithm.
    def get_message_types
      [KEXDH_GEX_INIT, KEXDH_GEX_REPLY]
    end

    # Build the signature buffer to use when verifying a signature from
    # the server.
    def build_signature_buffer(result)
      response = Net::SSH::Buffer.new
      response.write_string data[:client_version_string],
                            data[:server_version_string],
                            data[:client_algorithm_packet],
                            data[:server_algorithm_packet],
                            result[:key_blob]
      response.write_long MINIMUM_BITS,
                          data[:need_bits],
                          MAXIMUM_BITS
      response.write_bignum dh.p, dh.g, dh.pub_key,
                            result[:server_dh_pubkey],
                            result[:shared_secret]
      response
    end
  end

end


# frozen_string_literal: true

module SitePrism
  module Loadable
    module ClassMethods
      # The list of load_validations.
      # They will be executed in the order they are defined.
      #
      # @return [Array]
      def load_validations
        if superclass.respond_to?(:load_validations)
          superclass.load_validations + _load_validations
        else
          _load_validations
        end
      end

      # Appends a load validation block to the page class.
      #
      # When `loaded?` is called, these blocks are instance_eval'd
      # against the current page instance.
      # This allows users to wait for specific events to occur on
      # the page or certain elements to be loaded before performing
      # any actions on the page.
      #
      # @param block [&block] A block which returns true if the page
      # loaded successfully, or false if it did not.
      # This block can contain up to 2 elements in an array
      # The first is the physical validation test to be truthily evaluated.
      #
      # If this does not pass, then the 2nd item (if defined), is output
      # as an error message to the +FailedLoadValidationError+ that is thrown
      def load_validation(&block)
        _load_validations << block
      end

      private

      def _load_validations
        @_load_validations ||= []
      end
    end

    def self.included(base)
      base.extend(ClassMethods)
    end

    # In certain circumstances, we cache that the page has already
    # been confirmed to be loaded so that actions which
    # call `loaded?` a second time do not need to perform
    # the load_validation queries against the page a second time.
    attr_accessor :loaded, :load_error

    # Executes the given block after the page is loaded.
    #
    # The loadable object instance is yielded into the block.
    def when_loaded
      # Get original loaded value, in case we are nested
      # inside another when_loaded block.
      previously_loaded = loaded

      # Within the block, check (and cache) loaded?, to see whether the
      # page has indeed loaded according to the rules defined by the user.
      self.loaded = loaded?

      # If the page hasn't loaded. Then crash and return the error message.
      # If one isn't defined, just return the Error code.
      raise SitePrism::FailedLoadValidationError, load_error unless loaded

      # Return the yield value of the block if one was supplied.
      yield self if block_given?
    ensure
      self.loaded = previously_loaded
    end

    # Check if the page is loaded.
    #
    # On failure, if an error was reported by a failing validation,
    # it will be available via the `load_error` accessor.
    #
    # @return [Boolean] True if the page loaded successfully; otherwise false.
    def loaded?
      self.load_error = nil

      return true if loaded

      load_validations_pass?
    end

    private

    # If any load validations from page subclasses returns false,
    # immediately return false.
    def load_validations_pass?
      self.class.load_validations.all? do |validation|
        passed, message = instance_eval(&validation)

        self.load_error = message if message && !passed
        passed
      end
    end
  end
end


# frozen_string_literal: true

module SitePrism
  module DSL
    def self.included(klass)
      klass.extend ClassMethods
    end

    private

    # The default waiting time set by Capybara's configuration settings.
    def wait_time
      Capybara.default_max_wait_time
    end

    # Prevent users from calling methods with blocks when they shouldn't be.
    #
    # Example (Triggering error):
    #
    #       class MyPage
    #         element :sample, '.css-locator' do
    #           puts "This won't be output"
    #         end
    #       end
    #
    # At runtime this will generate a `SitePrism::UnsupportedBlockError`
    #
    # The only DSL keywords that can use blocks are :section and :iframe
    def raise_if_block(obj, name, has_block, type)
      return unless has_block

      SitePrism.logger.debug("Type passed in: #{type}")
      SitePrism.logger.warn('section / iFrame can only accept blocks.')
      SitePrism.logger.error("#{obj.class}##{name} does not accept blocks")

      raise SitePrism::UnsupportedBlockError
    end

    # Sanitize method called before calling any SitePrism DSL method or
    # meta-programmed method. This ensures that the Capybara query is correct.
    #
    # Accepts any combination of arguments sent at DSL definition or runtime
    # and combines them in such a way that Capybara can operate with them.
    def merge_args(find_args, runtime_args, visibility_args = {})
      find_args = find_args.dup
      runtime_args = runtime_args.dup
      options = visibility_args.dup
      SitePrism.logger.debug("Initial args: #{find_args}, #{runtime_args}.")

      recombine_args(find_args, runtime_args, options)

      return [*find_args, *runtime_args] if options.empty?

      [*find_args, *runtime_args, options]
    end

    # Options re-combiner. This takes the original inputs and combines
    # them such that there is only one hash passed as a final argument
    # to Capybara.
    #
    # If the hash is empty, then the hash is omitted from the payload sent
    # to Capybara, and the find / runtime arguments are sent alone.
    def recombine_args(find_args, runtime_args, options)
      options.merge!(find_args.pop) if find_args.last.is_a? Hash
      options.merge!(runtime_args.pop) if runtime_args.last.is_a? Hash
      options[:wait] = wait_time unless wait_key_present?(options)
    end

    # Detect if the +wait+ key is present in the options hash.
    # Note that setting it to to false or 0, still will return true here.
    def wait_key_present?(options)
      options.key?(:wait)
    end

    module ClassMethods
      attr_reader :expected_items

      def element(name, *find_args)
        build(:element, name, *find_args) do
          define_method(name) do |*runtime_args, &element_block|
            raise_if_block(self, name, !element_block.nil?, :element)
            _find(*merge_args(find_args, runtime_args))
          end
        end
      end

      def elements(name, *find_args)
        build(:elements, name, *find_args) do
          define_method(name) do |*runtime_args, &element_block|
            raise_if_block(self, name, !element_block.nil?, :elements)
            _all(*merge_args(find_args, runtime_args))
          end
        end
      end

      def expected_elements(*elements)
        @expected_items = elements
      end

      def section(name, *args, &block)
        section_class, find_args = extract_section_options(args, &block)
        build(:section, name, *find_args) do
          define_method(name) do |*runtime_args, &runtime_block|
            section_element = _find(*merge_args(find_args, runtime_args))
            section_class.new(self, section_element, &runtime_block)
          end
        end
      end

      def sections(name, *args, &block)
        section_class, find_args = extract_section_options(args, &block)
        build(:sections, name, *find_args) do
          define_method(name) do |*runtime_args, &element_block|
            raise_if_block(self, name, !element_block.nil?, :sections)
            _all(*merge_args(find_args, runtime_args)).map do |element|
              section_class.new(self, element)
            end
          end
        end
      end

      def iframe(name, klass, *args)
        element_find_args = deduce_iframe_element_find_args(args)
        scope_find_args = deduce_iframe_scope_find_args(args)
        build(:iframe, name, *element_find_args) do
          define_method(name) do |&block|
            raise MissingBlockError unless block

            within_frame(*scope_find_args) { block.call(klass.new) }
          end
        end
      end

      def mapped_items
        @mapped_items ||= []
      end

      private

      def build(type, name, *find_args)
        if find_args.empty?
          create_error_method(name)
        else
          map_item(type, name)
          yield
        end
        add_helper_methods(name, *find_args)
      end

      def map_item(type, name)
        mapped_items << { type => name.to_sym }
      end

      def add_helper_methods(name, *find_args)
        create_existence_checker(name, *find_args)
        create_nonexistence_checker(name, *find_args)
        create_rspec_existence_matchers(name) if defined?(RSpec)
        create_visibility_waiter(name, *find_args)
        create_invisibility_waiter(name, *find_args)
      end

      def create_helper_method(proposed_method_name, *find_args)
        if find_args.empty?
          create_error_method(proposed_method_name)
        else
          yield
        end
      end

      def create_rspec_existence_matchers(element_name)
        matcher = "has_#{element_name}?"
        negated_matcher = "has_no_#{element_name}?"

        RSpec::Matchers.define "have_#{element_name}" do |*args|
          match { |actual| actual.public_send(matcher, *args) }
          match_when_negated do |actual|
            actual.public_send(negated_matcher, *args)
          end
        end
      end

      def create_existence_checker(element_name, *find_args)
        method_name = "has_#{element_name}?"
        create_helper_method(method_name, *find_args) do
          define_method(method_name) do |*runtime_args|
            args = merge_args(find_args, runtime_args)
            element_exists?(*args)
          end
        end
      end

      def create_nonexistence_checker(element_name, *find_args)
        method_name = "has_no_#{element_name}?"
        create_helper_method(method_name, *find_args) do
          define_method(method_name) do |*runtime_args|
            args = merge_args(find_args, runtime_args)
            element_does_not_exist?(*args)
          end
        end
      end

      def create_visibility_waiter(element_name, *find_args)
        method_name = "wait_until_#{element_name}_visible"
        create_helper_method(method_name, *find_args) do
          define_method(method_name) do |*runtime_args|
            args = merge_args(find_args, runtime_args, visible: true)
            return true if element_exists?(*args)

            raise SitePrism::ElementVisibilityTimeoutError
          end
        end
      end

      def create_invisibility_waiter(element_name, *find_args)
        method_name = "wait_until_#{element_name}_invisible"
        create_helper_method(method_name, *find_args) do
          define_method(method_name) do |*runtime_args|
            args = merge_args(find_args, runtime_args, visible: true)
            return true if element_does_not_exist?(*args)

            raise SitePrism::ElementInvisibilityTimeoutError
          end
        end
      end

      def create_error_method(name)
        SitePrism.logger.error("#{name} has come from an item with 0 locators.")
        define_method(name) { raise SitePrism::InvalidElementError }
      end

      def deduce_iframe_scope_find_args(args)
        warn_on_invalid_selector_input(args)
        case args[0]
        when Integer then [args[0]]
        when String  then [:css, args[0]]
        else args
        end
      end

      def deduce_iframe_element_find_args(args)
        warn_on_invalid_selector_input(args)
        case args[0]
        when Integer then "iframe:nth-of-type(#{args[0] + 1})"
        when String  then [:css, args[0]]
        else args
        end
      end

      def warn_on_invalid_selector_input(args)
        return unless looks_like_xpath?(args[0])

        SitePrism.logger.warn('The arguments passed in look like xpath. Check your locators.')
        SitePrism.logger.debug("Default locator: #{Capybara.default_selector}")
      end

      def looks_like_xpath?(arg)
        arg.is_a?(String) && arg.start_with?('/', './')
      end

      def extract_section_options(args, &block)
        if args.first.is_a?(Class)
          klass = args.shift
          section_class = klass if klass.ancestors.include?(SitePrism::Section)
        end

        section_class = deduce_section_class(section_class, &block)
        arguments = deduce_search_arguments(section_class, args)
        [section_class, arguments]
      end

      def deduce_section_class(base_class, &block)
        klass = base_class
        klass = Class.new(klass || SitePrism::Section, &block) if block_given?
        return klass if klass

        raise ArgumentError, "You should provide descendant of \
SitePrism::Section class or/and a block as the second argument."
      end

      def deduce_search_arguments(section_class, args)
        extract_search_arguments(args) ||
          extract_search_arguments(section_class.default_search_arguments) ||
          raise(ArgumentError, "You should provide search arguments \
in section creation or set_default_search_arguments within section class")
      end

      def extract_search_arguments(args)
        args if args && !args.empty?
      end
    end
  end
end


# frozen_string_literal: true

module SitePrism
  module ElementChecker
    # Runnable in the scope of any SitePrism::Page or Section.
    # Returns +true+ when "every item" that is being checked is
    # present within the current scope. See #elements_to_check
    # for how the definition of "every item" is derived.
    #
    # Example
    # @my_page.mapped_items
    # { element => :button_one, element => :button_two, section => :filters }
    # @my_page.all_there?
    # => true - If the three items above are all present
    #
    # Note that #elements_to_check will affect the hash of mapped_items
    #
    # When using the recursion parameter, one of three values is valid.
    #
    # Default: 'none' => Perform no recursion when calling #all_there?
    # Override: 'one' => Perform one recursive dive into all section
    # items and call #all_there? on those items too.
    def all_there?(recursion: 'none')
      SitePrism.logger.info('Setting for recursion is being ignored for now.')

      if %w[none one].include?(recursion)
        elements_to_check.all? { |item_name| there?(item_name) }
      else
        SitePrism.logger.error('Invalid recursion setting, Will not run.')
      end
    end

    def elements_present
      _mapped_items.select { |item_name| there?(item_name) }
    end

    private

    # If the page or section has expected_items set, return expected_items
    # that are mapped; otherwise just return the list of all mapped_items
    def elements_to_check
      if _expected_items
        SitePrism.logger.debug('Expected Items has been set.')
        _mapped_items.select { |item_name| _expected_items.include?(item_name) }
      else
        _mapped_items
      end
    end

    def _mapped_items
      mapped_items_list.map(&:values).flatten
    end

    def mapped_items_list
      self.class.mapped_items.uniq
    end

    def _expected_items
      self.class.expected_items
    end

    def there?(item_name)
      send("has_#{item_name}?")
    end
  end
end


# frozen_string_literal: true

require 'site_prism/loadable'

module SitePrism
  # rubocop:disable Metrics/ClassLength
  class Page
    include Capybara::DSL
    include ElementChecker
    include Loadable
    include DSL

    class << self
      attr_reader :url

      def set_url(page_url)
        @url = page_url.to_s
      end

      def set_url_matcher(page_url_matcher)
        @url_matcher = page_url_matcher
      end

      def url_matcher
        @url_matcher ||= url
      end
    end

    def page
      if defined?(@page)
        @page
      else
        Capybara.current_session
      end
    end

    # Loads the page.
    # @param expansion_or_html
    # @param block [&block] An optional block to run once the page is loaded.
    # The page will yield the block if defined.
    #
    # Executes the block, if given.
    # Runs load validations on the page, unless input is a string
    #
    # When calling #load, all the validations that are set will be ran in order
    def load(expansion_or_html = {}, &block)
      self.loaded = false
      SitePrism.logger.debug("Reset loaded state on #{self.class}.")

      return_yield = if expansion_or_html.is_a?(String)
                       load_html_string(expansion_or_html, &block)
                     else
                       load_html_website(expansion_or_html, &block)
                     end

      # Ensure that we represent that the page we loaded is now indeed loaded!
      # This ensures that future calls to #loaded? do not perform the
      # instance evaluations against all load validations procs another time.
      self.loaded = true

      SitePrism.logger.info("#{self.class} loaded.")
      # Return the yield from the block if there was one, otherwise return true
      return_yield || true
    end

    def displayed?(*args)
      expected_mappings = args.last.is_a?(::Hash) ? args.pop : {}
      seconds = !args.empty? ? args.first : Capybara.default_max_wait_time
      raise SitePrism::NoUrlMatcherForPageError unless url_matcher

      begin
        Waiter.wait_until_true(seconds) { url_matches?(expected_mappings) }
      rescue SitePrism::TimeoutError
        false
      end
    end

    def url_matches(seconds = Capybara.default_max_wait_time)
      return unless displayed?(seconds)

      if url_matcher.is_a?(Regexp)
        regexp_backed_matches
      else
        template_backed_matches
      end
    end

    def url(expansion = {})
      return nil if self.class.url.nil?

      Addressable::Template.new(self.class.url).expand(expansion).to_s
    end

    def url_matcher
      self.class.url_matcher
    end

    def secure?
      page.current_url.start_with?('https')
    end

    private

    def _find(*find_args)
      page.find(*find_args)
    end

    def _all(*find_args)
      page.all(*find_args)
    end

    def element_exists?(*find_args)
      page.has_selector?(*find_args)
    end

    def element_does_not_exist?(*find_args)
      page.has_no_selector?(*find_args)
    end

    def regexp_backed_matches
      url_matcher.match(page.current_url)
    end

    def template_backed_matches
      matcher_template.mappings(page.current_url)
    end

    def url_matches?(expected_mappings = {})
      if url_matcher.is_a?(Regexp)
        url_matches_by_regexp?
      elsif url_matcher.respond_to?(:to_str)
        url_matches_by_template?(expected_mappings)
      else
        raise SitePrism::InvalidUrlMatcherError
      end
    end

    def url_matches_by_regexp?
      !regexp_backed_matches.nil?
    end

    def url_matches_by_template?(expected_mappings)
      matcher_template.matches?(page.current_url, expected_mappings)
    end

    def matcher_template
      @matcher_template ||= AddressableUrlMatcher.new(url_matcher)
    end

    def load_html_string(string)
      @page = Capybara.string(string)
      yield self if block_given?
    end

    def load_html_website(html, &block)
      with_validations = html.delete(:with_validations) { true }
      expanded_url = url(html)
      raise SitePrism::NoUrlForPageError unless expanded_url

      visit expanded_url
      if with_validations
        when_loaded(&block)
      elsif block_given?
        yield self
      end
    end
  end
  # rubocop:enable Metrics/ClassLength
end


# frozen_string_literal: true

require 'digest'
require 'base64'

module SitePrism
  class AddressableUrlMatcher
    attr_reader :pattern

    def initialize(pattern)
      @pattern = pattern
    end

    # @return the hash of extracted mappings from
    # parsing the provided URL according to our pattern,
    # or nil if the URL doesn't conform to the matcher template.
    def mappings(url)
      uri = Addressable::URI.parse(url)
      result = {}
      component_names.each do |component|
        component_result = component_matches(component, uri)
        return nil unless component_result

        result.merge!(component_result)
      end
      result
    end

    # Determine whether URL matches our pattern, and
    # optionally whether the extracted mappings match
    # a hash of expected values.  You can specify values
    # as strings, numbers or regular expressions.
    def matches?(url, expected_mappings = {})
      actual_mappings = mappings(url)
      return false unless actual_mappings

      expected_mappings.empty? ||
        all_expected_mappings_match?(expected_mappings, actual_mappings)
    end

    private

    def all_expected_mappings_match?(expected_mappings, actual_mappings)
      expected_mappings.all? do |key, expected_value|
        actual_value = actual_mappings[key.to_s]
        if expected_value.is_a?(Numeric)
          actual_value == expected_value.to_s
        elsif expected_value.is_a?(Regexp)
          actual_value.match(expected_value)
        else
          expected_value == actual_value
        end
      end
    end

    def component_templates
      @component_templates ||= extract_component_templates
    end

    def extract_component_templates
      component_names.each_with_object({}) do |component, templates|
        component_url = to_substituted_uri.public_send(component).to_s

        next unless component_url && !component_url.empty?

        reverse_substitutions.each_pair do |substituted_value, template_value|
          component_url = component_url.sub(substituted_value, template_value)
        end

        templates[component] = Addressable::Template.new(component_url)
      end
    end

    # Returns empty hash if the template omits the component or a set of
    # substitutions if the provided URI component matches the template
    # component or nil if the match fails.
    def component_matches(component, uri)
      component_template = component_templates[component]
      return {} unless component_template

      component_url = uri.public_send(component).to_s
      mappings = component_template.extract(component_url)
      return mappings if mappings

      # to support Addressable's expansion of queries
      # ensure it's parsing the fragment as appropriate (e.g. {?params*})
      prefix = component_prefixes[component]
      return nil unless prefix

      component_template.extract(prefix + component_url)
    end

    # Convert the pattern into an Addressable URI by substituting
    # the template slugs with nonsense strings.
    def to_substituted_uri
      url = pattern
      substitutions.each_pair { |slug, value| url = url.sub(slug, value) }
      begin
        Addressable::URI.parse(url)
      rescue Addressable::URI::InvalidURIError
        SitePrism.logger.warn("Ensure you don't use templated port numbers.")
        raise SitePrism::InvalidUrlMatcherError
      end
    end

    def substitutions
      @substitutions ||= slugs.each_with_index.reduce({}) do |memo, slug_index|
        slug, index = slug_index
        memo.merge(slug => slug_prefix(slug) + substitution_value(index))
      end
    end

    def reverse_substitutions
      @reverse_substitutions ||=
        slugs.each_with_index.reduce({}) do |memo, slug_index|
          slug, index = slug_index
          memo.merge(
            slug_prefix(slug) + substitution_value(index) => slug,
            substitution_value(index) => slug
          )
        end
    end

    def slugs
      pattern.scan(/{[^}]+}/)
    end

    # If a slug begins with non-alpha characters, it may denote the start of
    # a new component (e.g. query or fragment). We emit thie prefix as part of
    # the substituted slug so that Addressable's URI parser can see it as such.
    def slug_prefix(slug)
      prefix = slug.match(/\A{([^A-Za-z]+)/)
      prefix && prefix[1] || ''
    end

    # Generate a repeatable 5 character uniform alphabetical nonsense string
    # to allow parsing as a URI
    def substitution_value(index)
      sha = Digest::SHA1.digest(index.to_s)
      Base64.urlsafe_encode64(sha).gsub(/[^A-Za-z]/, '')[0..5]
    end

    def component_names
      %i[scheme user password host port path query fragment]
    end

    def component_prefixes
      { query: '?', fragment: '#' }
    end
  end
end


# frozen_string_literal: true
require_relative "./active_record_enumerator"
require_relative "./csv_enumerator"
require "forwardable"

module JobIteration
  class EnumeratorBuilder
    extend Forwardable

    # These wrappers ensure we have a custom type that we can assert on in
    # Iteration. It's useful that the `wrapper` passed to EnumeratorBuilder in
    # `enumerator_builder` is _always_ the type that is returned from
    # `build_enumerator`. This prevents people from implementing custom
    # Enumerators without wrapping them in
    # `enumerator_builder.wrap(custom_enum)`. We don't do this yet for backwards
    # compatibility with raw calls to EnumeratorBuilder. Think of these wrappers
    # the way you should a middleware.
    class Wrapper < Enumerator
      def self.wrap(_builder, enum)
        new(-> { enum.size }) do |yielder|
          enum.each do |*val|
            yielder.yield(*val)
          end
        end
      end
    end

    def initialize(job, wrapper: Wrapper)
      @job = job
      @wrapper = wrapper
    end

    def_delegator :@wrapper, :wrap

    # Builds Enumerator objects that iterates once.
    def build_once_enumerator(cursor:)
      wrap(self, build_times_enumerator(1, cursor: cursor))
    end

    # Builds Enumerator objects that iterates N times and yields number starting from zero.
    def build_times_enumerator(number, cursor:)
      raise ArgumentError, "First argument must be an Integer" unless number.is_a?(Integer)
      wrap(self, build_array_enumerator(number.times.to_a, cursor: cursor))
    end

    # Builds Enumerator object from a given array, using +cursor+ as an offset.
    def build_array_enumerator(enumerable, cursor:)
      unless enumerable.is_a?(Array)
        raise ArgumentError, "enumerable must be an Array"
      end
      if enumerable.any? { |i| defined?(ActiveRecord) && i.is_a?(ActiveRecord::Base) }
        raise ArgumentError, "array cannot contain ActiveRecord objects"
      end
      drop =
        if cursor.nil?
          0
        else
          cursor + 1
        end

      wrap(self, enumerable.each_with_index.drop(drop).to_enum { enumerable.size })
    end

    # Builds Enumerator from a lock queue instance that belongs to a job.
    # The helper is only to be used from jobs that use LockQueue module.
    def build_lock_queue_enumerator(lock_queue, at_most_once:)
      unless lock_queue.is_a?(BackgroundQueue::LockQueue::RedisQueue) ||
          lock_queue.is_a?(BackgroundQueue::LockQueue::RolloutRedisQueue)
        raise ArgumentError, "an argument to #build_lock_queue_enumerator must be a LockQueue"
      end
      wrap(self, BackgroundQueue::LockQueueEnumerator.new(lock_queue, at_most_once: at_most_once).to_enum)
    end

    # Builds Enumerator from Active Record Relation. Each Enumerator tick moves the cursor one row forward.
    #
    # +columns:+ argument is used to build the actual query for iteration. +columns+: defaults to primary key:
    #
    #   1) SELECT * FROM users ORDER BY id LIMIT 100
    #
    # When iteration is resumed, +cursor:+ and +columns:+ values will be used to continue from the point
    # where iteration stopped:
    #
    #   2) SELECT * FROM users WHERE id > $CURSOR ORDER BY id LIMIT 100
    #
    # +columns:+ can also take more than one column. In that case, +cursor+ will contain serialized values
    # of all columns at the point where iteration stopped.
    #
    # Consider this example with +columns: [:created_at, :id]+. Here's the query will use on the first iteration:
    #
    #   1) SELECT * FROM `products` ORDER BY created_at, id LIMIT 100
    #
    # And the query on the next iteration:
    #
    #   2) SELECT * FROM `products`
    #        WHERE (created_at > '$LAST_CREATED_AT_CURSOR'
    #          OR (created_at = '$LAST_CREATED_AT_CURSOR' AND (id > '$LAST_ID_CURSOR')))
    #        ORDER BY created_at, id LIMIT 100
    def build_active_record_enumerator_on_records(scope, cursor:, **args)
      enum = build_active_record_enumerator(
        scope,
        cursor: cursor,
        **args
      ).records
      wrap(self, enum)
    end

    # Builds Enumerator from Active Record Relation and enumerates on batches.
    # Each Enumerator tick moves the cursor +batch_size+ rows forward.
    #
    # +batch_size:+ sets how many records will be fetched in one batch. Defaults to 100.
    #
    # For the rest of arguments, see documentation for #build_active_record_enumerator_on_records
    def build_active_record_enumerator_on_batches(scope, cursor:, **args)
      enum = build_active_record_enumerator(
        scope,
        cursor: cursor,
        **args
      ).batches
      wrap(self, enum)
    end

    alias_method :once, :build_once_enumerator
    alias_method :times, :build_times_enumerator
    alias_method :array, :build_array_enumerator
    alias_method :active_record_on_records, :build_active_record_enumerator_on_records
    alias_method :active_record_on_batches, :build_active_record_enumerator_on_batches

    private

    def build_active_record_enumerator(scope, cursor:, **args)
      unless scope.is_a?(ActiveRecord::Relation)
        raise ArgumentError, "scope must be an ActiveRecord::Relation"
      end

      JobIteration::ActiveRecordEnumerator.new(
        scope,
        cursor: cursor,
        **args
      )
    end
  end
end


# frozen_string_literal: true

module JobIteration
  # CsvEnumerator makes it possible to write an Iteration job
  # that uses CSV file as a collection to Iterate.
  # @example
  #   def build_enumerator(cursor:)
  #     csv = CSV.open('tmp/files', { converters: :integer, headers: true })
  #     JobIteration::CsvEnumerator.new(csv).rows(cursor: cursor)
  #   end
  #
  #   def each_iteration(row)
  #     ...
  #   end
  class CsvEnumerator
    # Constructs CsvEnumerator instance based on a CSV file.
    # @param [CSV] csv An instance of CSV object
    # @return [JobIteration::CsvEnumerator]
    # @example
    #   csv = CSV.open('tmp/files', { converters: :integer, headers: true })
    #   JobIteration::CsvEnumerator.new(csv).rows(cursor: cursor)
    def initialize(csv)
      unless csv.instance_of?(CSV)
        raise ArgumentError, "CsvEnumerator.new takes CSV object"
      end

      @csv = csv
    end

    # Constructs a enumerator on CSV rows
    # @return [Enumerator] Enumerator instance
    def rows(cursor:)
      @csv.lazy
        .each_with_index
        .drop(cursor.to_i)
        .to_enum { count_rows_in_file }
    end

    # Constructs a enumerator on batches of CSV rows
    # @return [Enumerator] Enumerator instance
    def batches(batch_size:, cursor:)
      @csv.lazy
        .each_slice(batch_size)
        .each_with_index
        .drop(cursor.to_i)
        .to_enum { (count_rows_in_file.to_f / batch_size).ceil }
    end

    private

    def count_rows_in_file
      begin
        filepath = @csv.path
      rescue NoMethodError
        return
      end

      count = %x(wc -l < #{filepath}).strip.to_i
      count -= 1 if @csv.headers
      count
    end
  end
end