#
# Copyright:: Copyright (c) 2017 GitLab Inc.
# License:: Apache License, Version 2.0
#
# 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 'mixlib/config'
require 'chef/json_compat'
require 'chef/mixin/deep_merge'
require 'securerandom'
require 'uri'

require_relative 'config_mash.rb'
require_relative 'gitlab_cluster'

module SettingsDSL
  def self.extended(base)
    # Setup getter/setters for roles and settings
    class << base
      attr_accessor :available_roles, :settings
    end

    base.available_roles = {}
    base.settings = {}
  end

  # Change the default root location for node attributes
  # Pass in the root (ie 'gitlab') and a block containing the attributes that should
  # use that root.
  # ex:
  #   attribute_block('example') do
  #     attribute('some_attribute')
  #   end
  #   This will convert Gitlab['some_attribute'] to node['example']['some-attribute']
  def attribute_block(root = nil)
    return unless block_given?

    begin
      @_default_parent = root
      yield
    ensure
      @_default_parent = nil
    end
  end

  # Create a new role with the given 'name' config
  # config options are:
  #  manage_services - Boolean to indicate whether the role enables/disables services. Defaults to enabled.
  #                    If enabled, the default service role is disabled when using a different role that manages services
  # Roles are configured as Gitlab['<name>_role'] and are added to the node as node['roles']['<name>']
  # ex: some_specific_role['enable'] = true
  #     will result in Gitlab['some_specific_role']['enable'] = true
  #     and node['roles']['some-specific']['enable'] = true
  def role(name, **config)
    @available_roles[name] = HandledHash.new.merge!(
      { manage_services: true }
    ).merge(config)
    send("#{name}_role", Gitlab::ConfigMash.new)
    @available_roles[name]
  end

  # Create a new attribute with the given 'name' and config
  #
  # config options are:
  #  parent   - String name for the root node attribute, default can be specified using the attribute_block method
  #  priority - Integer used to sort the settings when applying them, defaults to 20, similar to sysvinit startups. Lower numbers are loaded first.
  #  ee       - Boolean to indicate that the variable should only be used in GitLab EE
  #  default  - Default value to set for the Gitlab Config. Defaults to Gitlab::ConfigMash.new, should be set to nil config expecting non hash values
  #
  # ex: attribute('some_attribute', parent: 'gitlab', sequence: 10, default: nil)
  #     will right away set Gitlab['some_attribute'] = nil
  #     and when the config is generated it will set node['gitlab']['some-attribute'] = nil
  def attribute(name, **config)
    @settings[name] = HandledHash.new.merge!(
      { parent: @_default_parent, priority: 20, ee: false, default: Gitlab::ConfigMash.new }
    ).merge(config)

    send(name.to_sym, @settings[name][:default])
    @settings[name]
  end

  # Same as 'attribute' but defaults 'enable' to false if the GitlabEE module is unavailable
  def ee_attribute(name, **config)
    config = { ee: true }.merge(config)
    attribute(name, **config)
  end

  def from_file(_file_path)
    # Throw errors for unrecognized top level calls (usually spelling mistakes)
    config_strict_mode true
    # Turn on node deprecation messages
    Gitlab::Deprecations::NodeAttribute.log_deprecations = true
    # Allow auto mash creation during from_file call
    Gitlab::ConfigMash.auto_vivify { super }
  ensure
    config_strict_mode false
    Gitlab::Deprecations::NodeAttribute.log_deprecations = false
  end

  # Enhance set so strict mode errors aren't thrown as long as the setting is witin our defined config
  def internal_set(symbol, value)
    if configuration.key?(symbol)
      configuration[symbol] = value
    else
      super
    end
  end

  # Enhance get so strict mode errors aren't thrown as long as the setting is witin our defined config
  def internal_get(symbol)
    if configuration.key?(symbol)
      configuration[symbol]
    else
      super
    end
  end

  def sanitized_config
    results = { "gitlab" => {}, "roles" => {}, "monitoring" => {} }

    # Add the settings to the results
    sorted_settings.each do |key, value|
      raise "Attribute parent value invalid for key: #{key} (#{value})" if value[:parent] && !results.key?(value[:parent])

      target = value[:parent] ? results[value[:parent]] : results

      target[Utils.node_attribute_key(key)] = Gitlab[key]
    end

    # Add the roles the the results
    @available_roles.each do |key, value|
      results['roles'][Utils.node_attribute_key(key)] = Gitlab["#{key}_role"]
    end

    results
  end

  def load_roles
    # System services are enabled by default
    Services.enable_group(Services::SYSTEM_GROUP)
    RolesHelper.parse_enabled

    # Roles defined in the cluster configuration file overrides roles from /etc/gitlab/gitlab.rb
    GitlabCluster.config.load_roles!

    # Load our roles
    DefaultRole.load_role
    @available_roles.each do |key, value|
      handler = value.handler
      handler.load_role if handler.respond_to?(:load_role)
    end
  end

  def generate_secrets(node_name, path = SecretsHelper::SECRETS_FILE)
    # Gitlab['node'][SecretsHelper::SKIP_GENERATE_SECRETS_CHEF_ATTR] is set to
    # true  if we are calling from the 'gitlab-ctrl generate-secrets' command
    # and we are running the 'config(-ee)' recipe in which case we will generate
    # secrets in the 'generate_secrets' recipe where it will then be set to
    # false.
    return if Gitlab['node'][SecretsHelper::SKIP_GENERATE_SECRETS_CHEF_ATTR] == true

    force_write_secrets = !Gitlab['node'][SecretsHelper::SECRETS_FILE_CHEF_ATTR].nil?

    # guards against creating secrets on non-bootstrap node
    SecretsHelper.read_gitlab_secrets(path)
    generate_default_secrets = Gitlab['package']['generate_default_secrets'] != false

    Chef::Log.info("Generating default secrets") if generate_default_secrets
    # Parse secrets using the handlers
    sorted_settings.each do |_key, value|
      handler = value.handler
      handler.parse_secrets if handler.respond_to?(:parse_secrets) && generate_default_secrets
      handler.validate_secrets if handler.respond_to?(:validate_secrets)
    end

    if Gitlab['package']['generate_secrets_json_file'] == false && !force_write_secrets
      return unless generate_default_secrets

      warning_message = <<~EOS
        You've enabled generating default secrets but have disabled writing them to #{path} file.
        This results in secrets not persisting across `gitlab-ctl reconfigure` runs and can cause issues with functionality.
      EOS

      LoggingHelper.warning(warning_message)
    else
      Chef::Log.info("Generating #{path} file")
      SecretsHelper.write_to_gitlab_secrets(path)
    end
  end

  def generate_config(node_name)
    generate_secrets(node_name)
    load_roles
    # Parse all our variables using the handlers
    sorted_settings.each do |_key, value|
      handler = value.handler
      handler.parse_variables if handler.respond_to?(:parse_variables)
    end

    strip_nils(sanitized_config)
  end

  def strip_nils(attributes)
    results = {}
    attributes.each_pair do |key, value|
      next if value.nil?

      recursive_classes = [Hash, Gitlab::ConfigMash, ChefUtils::Mash]
      results[key] = if recursive_classes.include?(value.class)
                       strip_nils(value)
                     else
                       value
                     end
    end
    results
  end

  # Merge provided role and value set if value is defined
  #
  # @param [String] role
  # @param [String] value
  def override_role!(role, value)
    return if value.nil?

    GitlabCluster.log_overriding_message(role, value) unless dig(role, 'enable').nil?

    Gitlab::ConfigMash.auto_vivify do
      self[role]['enable'] = value
    end
  end

  private

  # Sort settings by their sequence value
  def sorted_settings
    @settings.select { |_k, value| !value[:ee] || Gitlab['edition'] == :ee }.sort_by { |_k, value| value[:priority] }
  end

  # Custom Hash object used to add a handler as a block to the attribute
  class HandledHash < Hash
    attr_writer :handler

    def use(&block)
      @handler = block
      self
    end

    def handler
      @handler = @handler.call if @handler.respond_to?(:call)
      @handler
    end
  end

  class Utils
    class << self
      # In service names, words are seperated with a hyphen
      def service_name(service)
        service.tr('_', '-')
      end

      # Node attributes corresponding to a service are formatted by replacing
      # hyphens in the service names with underscores
      def node_attribute_key(service)
        service.tr('-', '_')
      end
    end
  end
end
