require 'pdk'
require 'uri'
require 'puppet/modulebuilder'

module PDK
  module Module
    class Release
      def self.invoke(module_path, options = {})
        new(module_path, options).run
      end

      attr_reader :options, :module_path

      def initialize(module_path, options = {})
        @options = options

        # TODO: Currently the release process can ONLY be run if the working directory IS the module root. However, in the future
        # this WILL change, so we have the API arguments for it, but only accept `nil` for the first parameter
        raise PDK::CLI::ExitWithError, 'Running the release process outside of the working directory is not supported' unless module_path.nil?

        if module_path.nil?
          module_path = PDK::Util.module_root
          raise PDK::CLI::ExitWithError, 'The module release process requires a valid module path' if module_path.nil?
        end
        raise PDK::CLI::ExitWithError, format('%{module_path} is not a valid module', module_path: module_path) unless PDK::Util.in_module_root?(module_path)

        @module_path = module_path
      end

      def run
        # Pre-release checks
        unless force?
          raise PDK::CLI::ExitWithError, 'The module is not PDK compatible' if requires_pdk_compatibility? && !pdk_compatible?
          raise PDK::CLI::ExitWithError, 'The module is not Forge compatible' if requires_forge_compatibility? && !forge_compatible?
        end

        # Note that these checks are duplicated in the run_publish method, however it's a much better
        # experience to fail early, than going through the whole process, only to error at the end knowing full well
        # it'll fail anyway.
        validate_publish_options!

        run_validations(options) unless skip_validation?

        PDK.logger.info format('Releasing %{module_name} - from version %{module_version}', module_name: module_metadata.data['name'], module_version: module_metadata.data['version'])

        PDK::Util::ChangelogGenerator.generate_changelog unless skip_changelog?

        # Calculate the new module version
        new_version = specified_version
        new_version = PDK::Util::ChangelogGenerator.compute_next_version(module_metadata.data['version']) if new_version.nil? && !skip_changelog?
        new_version = module_metadata.data['version'] if new_version.nil?

        if new_version != module_metadata.data['version']
          PDK.logger.info format('Updating version to %{module_version}', module_version: new_version)

          # Set the new version in metadata file
          module_metadata.data['version'] = new_version
          write_module_metadata!

          # Update the changelog with the correct version
          PDK::Util::ChangelogGenerator.generate_changelog unless skip_changelog?

          # Check if the versions match
          latest_version = PDK::Util::ChangelogGenerator.latest_version
          if !latest_version && (new_version != latest_version)
            raise PDK::CLI::ExitWithError, format('%{new_version} does not match %{latest_version}', new_version: new_version, latest_version: latest_version)
          end
        end

        run_documentation(options) unless skip_documentation?

        run_dependency_checker(options) unless skip_dependency?

        if skip_build?
          # Even if we're skipping the build, we still need the name of the tarball
          # Use the specified package path if set
          package_file = specified_package if package_file.nil?
          # Use the default as a last resort
          package_file = default_package_filename if package_file.nil?
        else
          package_file = run_build
        end

        run_publish(options.dup, package_file) unless skip_publish?
      end

      def module_metadata
        @module_metada ||= PDK::Module::Metadata.from_file(File.join(module_path, 'metadata.json'))
      end

      def write_module_metadata!
        module_metadata.write!(File.join(module_path, 'metadata.json'))
        clear_cached_data
      end

      def default_package_filename
        return @default_tarball_filename unless @default_tarball_filename.nil?

        builder = Puppet::Modulebuilder::Builder.new(module_path, nil, PDK.logger)
        @default_tarball_filename = builder.package_file
      end

      def run_validations(opts)
        # TODO: Surely I can use a pre-existing class for this?
        PDK::CLI::Util.validate_puppet_version_opts(opts)

        PDK::CLI::Util.module_version_check

        puppet_env = PDK::CLI::Util.puppet_from_opts_or_env(opts)
        PDK::Util::PuppetVersion.fetch_puppet_dev if opts[:'puppet-dev']
        PDK::Util::RubyVersion.use(puppet_env[:ruby_version])

        PDK::Util::Bundler.ensure_bundle!(puppet_env[:gemset])

        validator_exit_code, = PDK::Validate.invoke_validators_by_name(PDK.context, PDK::Validate.validator_names, false, options)
        raise PDK::CLI::ExitWithError, 'An error occured during validation' unless validator_exit_code.zero?
      end

      def run_documentation(_opts)
        PDK.logger.info 'Updating documentation using puppet strings'
        docs_command = PDK::CLI::Exec::InteractiveCommand.new(PDK::CLI::Exec.bundle_bin, 'exec', 'puppet', 'strings', 'generate', '--format', 'markdown', '--out', 'REFERENCE.md')
        docs_command.context = :module
        result = docs_command.execute!
        raise PDK::CLI::ExitWithError, format('An error occured generating the module documentation: %{stdout}', stdout: result[:stdout]) unless result[:exit_code].zero?
      end

      def run_dependency_checker(_opts)
        # run dependency-checker and output dependent modules list
        PDK.logger.info 'Running dependency checks'

        dep_command = PDK::CLI::Exec::Command.new('dependency-checker', 'metadata.json')
        dep_command.context = :module
        result = dep_command.execute!

        raise PDK::CLI::ExitWithError, format('An error occured checking the module dependencies: %{stdout}', stdout: result[:stdout]) unless result[:exit_code].zero?
      end

      # @return [String] Path to the built tarball
      def run_build
        module_dir = PDK::Util::Filesystem.expand_path(module_path || Dir.pwd)
        target_dir = File.join(module_dir, 'pkg')
        builder = Puppet::Modulebuilder::Builder.new(module_dir, target_dir, PDK.logger)
        builder.build
      end

      def run_publish(_opts, tarball_path)
        validate_publish_options!
        raise PDK::CLI::ExitWithError, format('Module tarball %{tarball_path} does not exist', tarball_path: tarball_path) unless PDK::Util::Filesystem.file?(tarball_path)

        PDK.logger.info 'Uploading tarball to puppet forge...'
        begin
          require 'puppet_forge'
          PuppetForge.host = forge_upload_url
          PuppetForge::Connection.authorization = forge_token
          PuppetForge::V3::Release.upload(tarball_path)
        rescue StandardError => e
          raise PDK::CLI::ExitWithError, format('Error uploading to Puppet Forge: %{result}', result: e.message)
        end

        PDK.logger.info 'Publish to Forge was successful'
      end

      def validate_publish_options!
        return if skip_publish?
        raise PDK::CLI::ExitWithError, 'Missing forge-upload-url option' unless forge_upload_url
        raise PDK::CLI::ExitWithError, 'Missing forge-token option' unless forge_token
      end

      def force?
        options[:force]
      end

      def skip_build?
        options[:'skip-build']
      end

      def skip_changelog?
        options[:'skip-changelog']
      end

      def skip_dependency?
        options[:'skip-dependency']
      end

      def skip_documentation?
        options[:'skip-documentation']
      end

      def skip_publish?
        options[:'skip-publish']
      end

      def skip_validation?
        options[:'skip-validation']
      end

      def specified_version
        options[:version]
      end

      def specified_package
        options[:file]
      end

      def forge_token
        options[:'forge-token']
      end

      def forge_upload_url
        options[:'forge-upload-url']
      end

      def requires_pdk_compatibility?
        # Validation, Changelog and Dependency checks require the
        # module to be PDK Compatible
        !(skip_validation? && skip_changelog? && skip_dependency?)
      end

      def requires_forge_compatibility?
        # Pushing to the for requires the metadata to be forge compatible
        !skip_publish?
      end

      # :nocov:
      # These are just convenience methods and are tested elsewhere
      def forge_compatible?
        module_metadata.forge_ready?
      end

      def pdk_compatible?
        return @pdk_compatible unless @pdk_compatible.nil?

        @pdk_compatible = PDK::Util.module_pdk_compatible?(module_path)
      end
      # :nocov:

      private

      def clear_cached_data
        @module_metadata = nil
        @pdk_compatible = nil
        @default_tarball_filename = nil
      end
    end
  end
end
