# frozen_string_literal: true

require 'yaml'

require_relative '../facts'
require_relative 'enc'
require_relative '../util/util'

module OctocatalogDiff
  module CatalogUtil
    # Represents a directory that is created such that a catalog can be compiled
    # in it. This has the following major functions:
    # - Create the temporary directory that will serve as the puppet configuration directory
    # - Register a handler to remove the temporary directory upon exit
    # - Install needed configuration files within the directory (e.g. puppetdb.conf)
    # - Install the facts into the directory
    # - Install 'environments/(environment)' which is a symlink to the checkout of the puppet code
    class BuildDir
      # Allow the path to the temporary directory to be read
      attr_reader :tempdir, :enc, :fact_file

      # Constructor
      # Options for constructor:
      # :puppetdb_url [String] PuppetDB Server URLs
      # :puppetdb_server_url_timeout [Integer] Timeout (seconds) for puppetdb.conf
      # :facts [OctocatalogDiff::Facts] Facts object
      # :fact_file [String] File from which to read facts
      # :node [String] Node name
      # :basedir [String] Directory containing puppet code
      # :enc [String] ENC script file (can be relative or absolute path)
      # :pe_enc_url [String] ENC URL (for Puppet Enterprise node classification service)
      # :hiera_config [String] hiera configuration file (relative to base directory)
      # :hiera_path [String] relative path to hiera data files (mutually exclusive with :hiera_path_strip)
      # :hiera_path_strip [String] string to strip off the beginning of :datadir
      # :puppetdb_ssl_ca [String] Path to SSL CA certificate
      # :puppetdb_ssl_crl [String] Path to Certificate Revocation List
      # :puppetdb_ssl_client_key [String] String representation of SSL client key
      # :puppetdb_ssl_client_cert [String] String representation of SSL client certificate
      # :puppetdb_ssl_client_password [String] Password to unlock SSL private key
      # @param options [Hash] Options for class; see above description
      def initialize(options = {}, logger = nil)
        @options = options.dup
        @tempdir = OctocatalogDiff::Util::Util.temp_dir('ocd-builddir-')
        @factdir = nil
        @enc = nil
        @fact_file = nil
        @node = options[:node]
        @facts_terminus = options.fetch(:facts_terminus, 'yaml')

        create_structure
        create_symlinks(logger)

        # These configurations are optional. Don't call the methods if parameters are nil.
        unless options[:puppetdb_url].nil?
          install_puppetdb_conf(logger, options[:puppetdb_url], options[:puppetdb_server_url_timeout])
          install_routes_yaml(logger)
        end
        install_hiera_config(logger, options) unless options[:hiera_config].nil?

        @fact_file = install_fact_file(logger, options) if @facts_terminus == 'yaml'
        @enc = install_enc(logger) unless options[:enc].nil? && options[:pe_enc_url].nil?
        install_ssl(logger, options) if options[:puppetdb_ssl_ca] || options[:puppetdb_ssl_client_cert]
      end

      # Create common structure
      def create_structure
        %w(facts var var/ssl var/yaml var/yaml/facts).each do |dir|
          Dir.mkdir(File.join(@tempdir, dir))
          FileUtils.chmod 0o755, File.join(@tempdir, dir)
        end
      end

      # Create symlinks.
      #
      # If the `--preserve-environments` option is used, the `environments` directory, plus `modules` and
      # `manifests` symlinks are created. Otherwise, `environments/production` is pointed at the base
      # directory.
      #
      # @param logger [Logger] Logger object
      def create_symlinks(logger = nil)
        if @options[:preserve_environments]
          install_directory_symlink(logger, File.join(@options[:basedir], 'environments'), 'environments')
          @options.fetch(:create_symlinks, %w(modules manifests)).each do |x|
            install_directory_symlink(logger, File.join(@options[:basedir], x), x)
          end
        else
          if @options[:create_symlinks] && @options[:environment]
            unless logger.nil?
              logger.warn '--create-symlinks with --environment ignored unless --preserve-environments is used'
            end
          elsif @options[:create_symlinks]
            logger.warn '--create-symlinks is ignored unless --preserve-environments is used' unless logger.nil?
          elsif @options[:environment]
            return install_directory_symlink(logger, @options[:basedir], "environments/#{@options[:environment]}")
          end
          install_directory_symlink(logger, @options[:basedir])
        end
      end

      # Install puppetdb.conf file in temporary directory
      # @param server_urls [String] String for server_urls in puppetdb.conf
      # @param server_url_timeout [Integer] Value for server_url_timeout in puppetdb.conf
      def install_puppetdb_conf(logger, server_urls, server_url_timeout = 30)
        unless server_urls.is_a?(String)
          raise ArgumentError, "server_urls must be a string, got a: #{server_urls.class}"
        end

        server_url_timeout ||= 30 # If called with nil argument, supply default
        unless server_url_timeout.is_a?(Integer)
          raise ArgumentError, "server_url_timeout must be a fixnum, got a: #{server_url_timeout.class}"
        end

        puppetdb_conf = File.join(@tempdir, 'puppetdb.conf')
        File.open(puppetdb_conf, 'w') do |f|
          f.write "[main]\n"
          f.write "server_urls = #{server_urls}\n"
          f.write "server_url_timeout = #{server_url_timeout}\n"
        end
        logger.debug("Installed puppetdb.conf file at #{puppetdb_conf}")
      end

      # Install routes.yaml file in temporary directory
      # No parameters or return - thus just writes a file (and notes it to debugging log)
      # Note: catalog cache => json avoids sending the compiled catalog to PuppetDB
      # even if storeconfigs is enabled.
      def install_routes_yaml(logger)
        routes_yaml = File.join(@tempdir, 'routes.yaml')
        routes_hash = {
          'master' => {
            'facts' => {
              'terminus' => @facts_terminus,
              'cache' => 'yaml'
            },
            'catalog' => {
              'cache' => 'json'
            }
          }
        }
        File.open(routes_yaml, 'w') { |f| f.write(routes_hash.to_yaml) }
        logger.debug("Installed routes.yaml file at #{routes_yaml}")
      end

      # Install the fact file in temporary directory
      # @param options [Hash] Options
      def install_fact_file(logger, options)
        unless @facts_terminus == 'yaml'
          raise ArgumentError, "Called install_fact_file but :facts_terminus = #{@facts_terminus}"
        end
        unless options[:node].is_a?(String) && !options[:node].empty?
          raise ArgumentError, 'Called install_fact_file without node, or with an empty node'
        end

        facts = if options[:facts].is_a?(OctocatalogDiff::Facts)
          options[:facts].dup
        elsif options[:fact_file]
          raise Errno::ENOENT, "Fact file #{options[:fact_file]} does not exist" unless File.file?(options[:fact_file])
          fact_file_opts = { fact_file_string: File.read(options[:fact_file]) }
          fact_file_opts[:backend] = Regexp.last_match(1).to_sym if options[:fact_file] =~ /.{1,1000}\.(\w+)$/
          OctocatalogDiff::Facts.new(fact_file_opts)
        else
          raise ArgumentError, 'No facts passed to "install_fact_file" method'
        end

        if options[:fact_override].is_a?(Array)
          options[:fact_override].each do |override|
            keys = override.key.is_a?(Regexp) ? facts.matching(override.key) : [override.key]
            keys.each do |key|
              old_value = facts.fact(key)
              facts.override(key, override.value)
              logger.debug("Override #{key} from #{old_value.inspect} to #{override.value.inspect}")
            end
          end
        end

        fact_file_out = File.join(@tempdir, 'var', 'yaml', 'facts', "#{options[:node]}.yaml")
        File.open(fact_file_out, 'w') { |f| f.write(facts.facts_to_yaml(options[:node])) }
        logger.debug("Installed fact file at #{fact_file_out}")
        fact_file_out
      end

      # Install symbolic link to puppet environment
      # @param dir [String] Directory to link to
      # @param target [String] Where the symlink is created, relative to tempdir
      def install_directory_symlink(logger, dir, target = 'environments/production')
        raise ArgumentError, "Called install_directory_symlink with #{dir.class} argument" unless dir.is_a?(String)
        raise Errno::ENOENT, "Specified directory #{dir} doesn't exist" unless File.directory?(dir)

        symlink_target = File.join(@tempdir, target)

        if target =~ %r{/}
          parent_dir = File.dirname(symlink_target)
          FileUtils.mkdir_p parent_dir
        end

        FileUtils.rm_f symlink_target if File.exist?(symlink_target)
        FileUtils.symlink dir, symlink_target
        logger.debug("Symlinked #{symlink_target} -> #{dir}")
      end

      # Install ENC
      # @param enc [String] Path to ENC script, relative to checkout
      def install_enc(logger)
        raise ArgumentError, 'A node must be specified when using an ENC' unless @node.is_a?(String)
        enc_obj = OctocatalogDiff::CatalogUtil::ENC.new(@options.merge(tempdir: @tempdir))
        enc_obj.execute(logger)
        raise "Failed ENC: #{enc_obj.error_message}" if enc_obj.error_message

        enc_path = File.join(@tempdir, 'enc.sh')
        File.open(enc_path, 'w') do |f|
          f.write "#!/bin/sh\n"
          f.write "cat <<-'EOF'\n"
          f.write enc_obj.content
          f.write "\nEOF\n"
        end
        FileUtils.chmod 0o755, enc_path

        logger.debug("Installed ENC to echo content, #{enc_obj.content.length} bytes")
        enc_path
      end

      # Install hiera config file
      # @param options [Hash] Options hash
      def install_hiera_config(logger, options)
        # Validate hiera config file
        hiera_config = options[:hiera_config]
        unless hiera_config.is_a?(String)
          raise ArgumentError, "Called install_hiera_config with a #{hiera_config.class} argument"
        end
        file_src = if hiera_config.start_with? '/'
          hiera_config
        elsif hiera_config =~ %r{^environments/#{Regexp.escape(environment)}/}
          File.join(@tempdir, hiera_config)
        else
          File.join(@tempdir, 'environments', environment, hiera_config)
        end
        raise Errno::ENOENT, "hiera.yaml (#{file_src}) wasn't found" unless File.file?(file_src)

        # Munge datadir in hiera config file
        obj = YAML.load_file(file_src)
        version = obj['version'] || obj[:version] || 3
        if version.to_i == 5
          update_hiera_config_v5(logger, options, obj)
        else
          update_hiera_config_v3(logger, options, obj)
        end

        # Write properly formatted hiera config file into temporary directory
        File.open(File.join(@tempdir, 'hiera.yaml'), 'w') { |f| f.write(obj.to_yaml.gsub('!ruby/sym ', ':')) }
        logger.debug("Installed hiera.yaml from #{file_src} to #{File.join(@tempdir, 'hiera.yaml')}")
      end

      # Install SSL certificate authority certificate, client key, and client certificate into the
      # expected locations within Puppet's SSL directory. Note that if the client key has a password,
      # this will write the key (without password) onto disk, because Puppet doesn't support unlocking
      # the private key.
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      def install_ssl(logger, options)
        return unless options[:puppetdb_ssl_client_cert] || options[:puppetdb_ssl_client_key] || options[:puppetdb_ssl_ca]

        # Create directory structure expected by Puppet
        %w(var/ssl/certs var/ssl/private var/ssl/private_keys).each do |dir|
          Dir.mkdir(File.join(@tempdir, dir))
          FileUtils.chmod 0o700, File.join(@tempdir, dir)
        end

        # SSL client auth requested?
        if options[:puppetdb_ssl_client_cert] || options[:puppetdb_ssl_client_key]
          raise ArgumentError, '--puppetdb-ssl-ca must be provided for client auth' unless options[:puppetdb_ssl_ca]
          raise ArgumentError, '--puppetdb-ssl-client-cert must be provided' unless options[:puppetdb_ssl_client_cert]
          raise ArgumentError, '--puppetdb-ssl-client-key must be provided' unless options[:puppetdb_ssl_client_key]
          install_ssl_client(logger, options)
        end

        # SSL CA provided?
        install_ssl_ca(logger, options) if options[:puppetdb_ssl_ca]

        # SSL CRL provided?
        install_ssl_crl(logger, options) if options[:puppetdb_ssl_crl]
      end

      private

      # Jump-off for hiera v3 (or earlier)
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      # @param obj [Hash] Parsed hiera.yaml file
      def update_hiera_config_v3(logger, options, obj)
        ([obj[:backends]].flatten || %w(yaml json)).each do |key|
          next unless obj.key?(key.to_sym) && !obj[key.to_sym][:datadir].nil?
          obj[key.to_sym][:datadir] = hiera_munge(options, obj[key.to_sym][:datadir])

          # Make sure the directory exists. If not, log a warning. This is *probably* a setup error, but we don't
          # want it to be fatal in case (for example) someone is doing an octocatalog-diff to verify moving this
          # directory around or even setting up Hiera for the very first time.
          unless File.directory?(obj[key.to_sym][:datadir])
            message = "WARNING: Hiera datadir for #{key} doesn't seem to exist at #{obj[key.to_sym][:datadir]}"
            logger.warn message
          end
        end
      end

      # Jump-off for hiera v5
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      # @param obj [Hash] Parsed hiera.yaml file
      def update_hiera_config_v5(_logger, options, obj)
        defaults_key = obj.key?(:defaults) ? :defaults : 'defaults'
        hierarchy_key = obj.key?(:hierarchy) ? :hierarchy : 'hierarchy'

        # Fix defaults:datadir
        if obj[defaults_key].is_a?(Hash)
          [:datadir, 'datadir'].each do |key|
            next unless obj[defaults_key].key?(key)
            obj[defaults_key][key] = hiera_munge(options, obj[defaults_key][key])
          end
        end

        # Fix hierarchy:datadir
        if obj[hierarchy_key].is_a?(Array)
          obj[hierarchy_key].each do |level|
            [:datadir, 'datadir'].each do |key|
              next unless level.key?(key)
              if options[:hiera_path_strip].is_a?(String)
                level[key] = hiera_munge(options, level[key])
              elsif options[:hiera_path].is_a?(String)
                message = [
                  "Hierarchy item #{level.inspect} has a datadir.",
                  '--hiera-path is not supported in this situation.',
                  'Please use --hiera-path-strip.'
                ].join(' ')
                raise ArgumentError, message
              end
            end
          end
        end
      end

      # Hiera munge - shared method to apply :hiera_path_strip and :hiera_path
      def hiera_munge(options, current_value)
        return if current_value.nil?

        if options[:hiera_path_strip].is_a?(String)
          rexp1 = Regexp.new('^' + options[:hiera_path_strip])
          current_value.sub!(rexp1, @tempdir)
        elsif options[:hiera_path].is_a?(String)
          current_value = File.join(@tempdir, 'environments', environment, options[:hiera_path])
        end
        rexp2 = Regexp.new('%{(::)?environment}')
        current_value.sub!(rexp2, environment)

        current_value
      end

      # Install SSL certificate authority certificate
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      def install_ssl_ca(logger, options)
        ca_file = options[:puppetdb_ssl_ca]
        raise Errno::ENOENT, 'SSL CA file does not exist' unless File.file?(ca_file)
        ca_content = File.read(ca_file)
        ca_outfile = File.join(@tempdir, 'var', 'ssl', 'certs', 'ca.pem')
        File.open(ca_outfile, 'w') { |f| f.write(ca_content) }
        logger.debug "Installed CA certificate in #{ca_outfile}"
      end

      # Install SSL Certificate Revocation List
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      def install_ssl_crl(logger, options)
        crl_file = options[:puppetdb_ssl_crl]
        raise Errno::ENOENT, 'SSL CRL file does not exist' unless File.file?(crl_file)
        crl_content = File.read(crl_file)
        crl_outfile = File.join(@tempdir, 'var', 'ssl', 'crl.pem')
        File.open(crl_outfile, 'w') { |f| f.write(crl_content) }
        logger.debug "Installed Certificate Revocation List in #{crl_outfile}"
      end

      # Install SSL keypair for client certificate authentication
      # @param logger [Logger] Logger object
      # @param options [Hash] Options hash
      def install_ssl_client(logger, options)
        # Since Puppet always looks for the key and cert in a file named after the hostname, determine the
        # hostname here for the purposes of naming the files.
        require 'socket'
        host = Socket.gethostname
        install_ssl_client_cert(logger, host, options[:puppetdb_ssl_client_cert])
        install_ssl_client_key(logger, host, options[:puppetdb_ssl_client_key])
        install_ssl_client_password(logger, options[:puppetdb_ssl_client_password])
      end

      def install_ssl_client_cert(logger, host, content)
        cert_outfile = File.join(@tempdir, 'var', 'ssl', 'certs', "#{host}.pem")
        File.open(cert_outfile, 'w') { |f| f.write(content) }
        logger.debug "Installed SSL client certificate in #{cert_outfile}"
      end

      def install_ssl_client_key(logger, host, content)
        key_outfile = File.join(@tempdir, 'var', 'ssl', 'private_keys', "#{host}.pem")
        File.open(key_outfile, 'w') { |f| f.write(content) }
        logger.debug "Installed SSL client key in #{key_outfile}"
      end

      def install_ssl_client_password(logger, password)
        return unless password
        password_outfile = File.join(@tempdir, 'var', 'ssl', 'private', 'password')
        File.open(password_outfile, 'w') { |f| f.write(password) }
        logger.debug "Installed SSL client key password in #{password_outfile}"
      end

      def environment
        @options.fetch(:environment, 'production')
      end
    end
  end
end
