# frozen_string_literal: true

require 'bolt/apply_target'
require 'bolt/config'
require 'bolt/error'
require 'bolt/inventory'
require 'bolt/apply_inventory'
require 'bolt/pal'
require 'bolt/puppetdb'
require 'bolt/util'

Bolt::PAL.load_puppet

require 'bolt/catalog/logging'

module Bolt
  class Catalog
    def initialize(log_level = 'debug')
      @log_level = log_level
    end

    def with_puppet_settings(hiera_config = {})
      Dir.mktmpdir('bolt') do |dir|
        cli = []
        Puppet::Settings::REQUIRED_APP_SETTINGS.each do |setting|
          cli << "--#{setting}" << dir
        end
        Puppet.settings.send(:clear_everything_for_tests)
        # Override module locations, Bolt includes vendored modules in its internal modulepath.
        Puppet.settings.override_default(:basemodulepath, '')
        Puppet.settings.override_default(:vendormoduledir, '')

        Puppet.initialize_settings(cli)
        Puppet.settings[:hiera_config] = hiera_config

        # Use a special logdest that serializes all log messages and their level to stderr.
        Puppet::Util::Log.newdestination(:stderr)
        Puppet.settings[:log_level] = @log_level
        yield
      end
    end

    def generate_ast(code, filename = nil)
      with_puppet_settings do
        Puppet::Pal.in_tmp_environment("bolt_parse") do |pal|
          pal.with_catalog_compiler do |compiler|
            ast = compiler.parse_string(code, filename)
            Puppet::Pops::Serialization::ToDataConverter.convert(ast,
                                                                 rich_data: true,
                                                                 symbol_to_string: true)
          end
        end
      end
    end

    def compile_catalog(request)
      pal_main = request['code_ast'] || request['code_string']
      target = request['target']
      pdb_client = Bolt::PuppetDB::Client.new(Bolt::PuppetDB::Config.new(request['pdb_config']))
      options = request['puppet_config'] || {}
      with_puppet_settings(request['hiera_config']) do
        Puppet[:rich_data] = true
        Puppet[:node_name_value] = target['name']
        env_conf = { modulepath: request['modulepath'] || [],
                     facts: target['facts'] || {} }
        env_conf[:variables] = {}
        Puppet::Pal.in_tmp_environment('bolt_catalog', env_conf) do |pal|
          inv = Bolt::ApplyInventory.new(request['config'])
          Puppet.override(bolt_pdb_client: pdb_client,
                          bolt_inventory: inv) do
            Puppet.lookup(:pal_current_node).trusted_data = target['trusted']
            pal.with_catalog_compiler do |compiler|
              # Deserializing needs to happen inside the catalog compiler so
              # loaders are initialized for loading
              plan_vars = Puppet::Pops::Serialization::FromDataConverter.convert(request['plan_vars'])

              # Facts will be set by the catalog compiler, so we need to ensure
              # that any plan or target variables with the same name are not
              # passed into the apply block to avoid a redefinition error.
              # Filter out plan and target vars separately and raise a Puppet
              # warning if there are any collisions for either. Puppet warning
              # is the only way to log a message that will make it back to Bolt
              # to be printed.
              pv_collisions, pv_filtered = plan_vars.partition do |k, _|
                target['facts'].keys.include?(k)
              end.map(&:to_h)
              unless pv_collisions.empty?
                print_pv = pv_collisions.keys.map { |k| "$#{k}" }.join(', ')
                plural = pv_collisions.keys.length == 1 ? '' : 's'
                Puppet.warning("Plan variable#{plural} #{print_pv} will be overridden by fact#{plural} " \
                               "of the same name in the apply block")
              end

              tv_collisions, tv_filtered = target['variables'].partition do |k, _|
                target['facts'].keys.include?(k)
              end.map(&:to_h)
              unless tv_collisions.empty?
                print_tv = tv_collisions.keys.map { |k| "$#{k}" }.join(', ')
                plural = tv_collisions.keys.length == 1 ? '' : 's'
                Puppet.warning("Target variable#{plural} #{print_tv} " \
                               "will be overridden by fact#{plural} of the same name in the apply block")
              end

              pal.send(:add_variables, compiler.send(:topscope), tv_filtered.merge(pv_filtered))

              # Configure language strictness in the CatalogCompiler. We want Bolt to be able
              # to compile most Puppet 4+ manifests, so we default to allowing deprecated functions.
              Puppet[:strict] = options['strict'] || :warning
              Puppet[:strict_variables] = options['strict_variables'] || false
              ast = Puppet::Pops::Serialization::FromDataConverter.convert(pal_main)
              # This will be a Program when running via `bolt apply`, but will
              # only be a subset of the AST when compiling an apply block in a
              # plan. In that case, we need to discover the definitions (which
              # would ordinarily be stored on the Program) and construct a Program object.
              unless ast.is_a?(Puppet::Pops::Model::Program)
                # Node definitions must be at the top level of the apply block.
                # That means the apply body either a) consists of just a
                # NodeDefinition, b) consists of a BlockExpression which may
                # contain NodeDefinitions, or c) doesn't contain NodeDefinitions.
                definitions = if ast.is_a?(Puppet::Pops::Model::BlockExpression)
                                ast.statements.select { |st| st.is_a?(Puppet::Pops::Model::NodeDefinition) }
                              elsif ast.is_a?(Puppet::Pops::Model::NodeDefinition)
                                [ast]
                              else
                                []
                              end
                ast = Puppet::Pops::Model::Factory.PROGRAM(ast, definitions, ast.locator).model
              end
              compiler.evaluate(ast)
              compiler.instance_variable_get(:@internal_compiler).send(:evaluate_ast_node)
              compiler.compile_additions
              compiler.with_json_encoding(&:encode)
            end
          end
        end
      end
    end
  end
end