require 'pdk'
require 'json'
require 'forwardable'

module PDK
  module Util
    class PuppetVersion
      class << self
        extend Forwardable

        def_delegators :instance, :puppet_dev_env, :puppet_dev_path, :fetch_puppet_dev, :find_gem_for, :from_pe_version, :from_module_metadata, :latest_available

        attr_writer :instance

        def instance
          @instance ||= new
        end
      end

      PE_VERSIONS_URL = 'https://forgeapi.puppet.com/private/versions/pe'.freeze
      DEFAULT_PUPPET_DEV_URL = 'https://github.com/puppetlabs/puppet'.freeze
      DEFAULT_PUPPET_DEV_BRANCH = 'main'.freeze

      def puppet_dev_env
        require 'pdk/util/ruby_version'

        {
          gem_version: format('file://%{path}', path: puppet_dev_path),
          ruby_version: PDK::Util::RubyVersion.latest_ruby_version
        }
      end

      def puppet_dev_path
        require 'pdk/util'

        File.join(PDK::Util.cachedir, 'src', 'puppet')
      end

      def latest_available
        latest = find_gem(Gem::Requirement.create('>= 0'))

        raise ArgumentError, 'Unable to find a Puppet gem in current Ruby environment or from Rubygems.org.' if latest.nil?

        latest
      end

      def puppet_dev_fetched?
        !@puppet_dev_fetched.nil?
      end

      def fetch_puppet_dev(options = {})
        return if options[:run] == :once && puppet_dev_fetched?

        require 'pdk/util/git'

        # Check if the source is cloned and is a readable git repo
        unless PDK::Util::Git.remote_repo? puppet_dev_path
          # Check if the path has something in it already. Delete it and prepare for clone if so.
          if PDK::Util::Filesystem.exist? puppet_dev_path
            PDK::Util::Filesystem.delete(puppet_dev_path) if PDK::Util::Filesystem.file? puppet_dev_path
            PDK::Util::Filesystem.rm_rf(puppet_dev_path) if PDK::Util::Filesystem.directory? puppet_dev_path
          end

          PDK::Util::Filesystem.mkdir_p puppet_dev_path
          clone_result = PDK::Util::Git.git('clone', DEFAULT_PUPPET_DEV_URL, puppet_dev_path)
          return if clone_result[:exit_code].zero?

          PDK.logger.error clone_result[:stdout]
          PDK.logger.error clone_result[:stderr]
          raise PDK::CLI::FatalError, format("Unable to clone git repository from '%{repo}'.", repo: DEFAULT_PUPPET_DEV_URL)
        end

        # Fetch Updates from remote repository
        fetch_result = PDK::Util::Git.git('-C', puppet_dev_path, 'fetch', 'origin')

        unless fetch_result[:exit_code].zero?
          PDK.logger.error fetch_result[:stdout]
          PDK.logger.error fetch_result[:stderr]
          raise PDK::CLI::FatalError, format("Unable to fetch from git remote at '%{repo}'.", repo: DEFAULT_PUPPET_DEV_URL)
        end

        # Reset local repo to latest
        reset_result = PDK::Util::Git.git('-C', puppet_dev_path, 'reset', '--hard', "origin/#{DEFAULT_PUPPET_DEV_BRANCH}")

        @puppet_dev_fetched = true
        return if reset_result[:exit_code].zero?

        PDK.logger.error reset_result[:stdout]
        PDK.logger.error reset_result[:stderr]
        raise PDK::CLI::FatalError, format("Unable to update git repository at '%{cachedir}'.", cachedir: puppet_dev_path)
      end

      def find_gem_for(version_str)
        version = parse_specified_version(version_str)

        # Look for a gem matching exactly the version passed in.
        if version.segments.length == 3
          exact_match_gem = find_gem(Gem::Requirement.create(version))
          return exact_match_gem unless exact_match_gem.nil?
        end

        # Construct a pessimistic version constraint to find the latest
        # available gem matching the level of specificity of version_str.
        requirement_string = version.approximate_recommendation
        requirement_string += '.0' unless version.segments.length == 1
        latest_requirement = Gem::Requirement.create(requirement_string)
        latest_available_gem = find_gem(latest_requirement)

        raise ArgumentError, format('Unable to find a Puppet gem matching %{requirement}.', requirement: latest_requirement) if latest_available_gem.nil?

        # Only issue this warning if they requested an exact version that isn't available.
        if version.segments.length == 3
          PDK.logger.warn(format('Puppet %{requested_version} is not available, activating %{found_version} instead.', requested_version: version_str,
                                                                                                                       found_version: latest_available_gem[:gem_version].version))
        end

        latest_available_gem
      end

      def from_pe_version(version_str)
        version = parse_specified_version(version_str)

        # Due to the issue with concurrent ruby in older puppet gems
        # we are locking the pe to puppet version mapping to the latest
        # puppet version that is compatible with the pe version.
        safe_versions = {
          2023 => '7.23.0',
          2021 => '7.23.0',
          2019 => '6.29.0'
        }

        gem_version = safe_versions[version.segments[0]]
        raise ArgumentError, format('Unable to map Puppet Enterprise version %{pe_version} to a Puppet version.', pe_version: version_str) if gem_version.nil?

        PDK.logger.info format('Puppet Enterprise %{pe_version} maps to Puppet %{puppet_version}.', pe_version: version_str, puppet_version: gem_version)

        find_gem_for(gem_version)
      end

      def from_module_metadata(metadata = nil)
        require 'pdk/module/metadata'
        require 'pdk/util'

        if metadata.nil?
          metadata_file = PDK::Util.find_upwards('metadata.json')

          unless metadata_file
            PDK.logger.warn 'Unable to determine Puppet version for module: no metadata.json present in module.'
            return
          end

          metadata = PDK::Module::Metadata.from_file(metadata_file)
        end

        metadata.validate_puppet_version_requirement!
        metadata_requirement = metadata.puppet_requirement

        # Split combined requirements like ">= 4.7.0 < 6.0.0" into their
        # component requirements [">= 4.7.0", "< 6.0.0"]
        pattern = /#{Gem::Requirement::PATTERN_RAW}/o
        requirement_strings = metadata_requirement['version_requirement'].scan(pattern).map do |req|
          req.compact.join(' ')
        end

        gem_requirement = Gem::Requirement.create(requirement_strings)
        find_gem(gem_requirement)
      end

      private

      def parse_specified_version(version_str)
        Gem::Version.new(version_str)
      rescue ArgumentError
        raise ArgumentError, format('%{version} is not a valid version number.', version: version_str)
      end

      def requirement_from_forge_range(range_str)
        Gem::Requirement.create("~> #{range_str.gsub(/\.x\Z/, '.0')}")
      end

      def rubygems_puppet_versions
        @rubygems_puppet_versions ||= begin
          fetcher = Gem::SpecFetcher.fetcher
          puppet_tuples = fetcher.detect(:released) do |spec_tuple|
            spec_tuple.name == 'puppet' && Gem::Platform.match_spec?(spec_tuple)
          end
          puppet_versions = puppet_tuples.map { |name, _| name.version }.uniq
          puppet_versions.sort.reverse
        end
      end

      def find_gem(requirement)
        require 'pdk/util'

        if PDK::Util.package_install?
          find_in_package_cache(requirement)
        else
          find_in_rubygems(requirement)
        end
      end

      def find_in_rubygems(requirement)
        require 'pdk/util/ruby_version'

        version = rubygems_puppet_versions.find { |r| requirement.satisfied_by?(r) }
        version.nil? ? nil : { gem_version: version, ruby_version: PDK::Util::RubyVersion.default_ruby_version }
      end

      # Finds the specified requirement in the package cache.
      #
      # @param requirement [Gem::Requirement] The requirement to search for.
      # @return [Hash] A hash containing the gem version and ruby version if found, or nil if not found.
      def find_in_package_cache(requirement)
        require 'pdk/util/ruby_version'

        PDK::Util::RubyVersion.versions.each_key do |ruby_version|
          PDK::Util::RubyVersion.use(ruby_version)
          version = PDK::Util::RubyVersion.available_puppet_versions.find { |r| requirement.satisfied_by?(r) }
          return { gem_version: version, ruby_version: ruby_version } unless version.nil?
        end

        nil
      end
    end
  end
end
