# frozen_string_literal: true

# Hey there! With our use of the "contracts" module, load order is important.
# Load third party dependencies first.
require "concurrent"
require "ruby_version_check"

# contracts.ruby has two specific ruby-version specific libraries, which we have vendored into lib/

# :nocov:
if RubyVersionCheck.ruby_version2?
  $LOAD_PATH.unshift(File.expand_path(File.join(__dir__, "contracts-ruby2/lib")))
else
  $LOAD_PATH.unshift(File.expand_path(File.join(__dir__, "contracts-ruby3/lib")))
end
# :nocov:

require "contracts"
require "erb"
require "logger"
require "ostruct"
require "stringio"
require "uri"
require "yaml"

# Next, pre-declare any classes that are referenced from contracts.
module Entitlements
  class Auditor
    class Base; end
  end
  class Data
    class Groups
      class Cached; end
      class Calculated
        class Base; end
        class Ruby < Base; end
        class Text < Base; end
        class YAML < Base; end
      end
    end
    class People
      class Combined; end
      class Dummy; end
      class LDAP; end
      class YAML; end
    end
  end
  module Extras; end
  class Models
    class Action; end
    class Group; end
    class Person; end
    class RuleSet
      class Base; end
      class Ruby < Base; end
      class YAML < Base; end
    end
  end
  class Service
    class GitHub; end
    class LDAP; end
  end
end

module Entitlements
  include ::Contracts::Core
  C = ::Contracts

  IGNORED_FILES = Set.new(%w[README.md PR_TEMPLATE.md])

  # Allows interpretation of ERB for the configuration file to make things less hokey.
  class ERB < OpenStruct
    def self.render_from_hash(template, hash)
      new(hash).render(template)
    end

    def render(template)
      ::ERB.new(template, trim_mode: "-").result(binding)
    end
  end

  # Reset all Entitlements state
  #
  # Takes no arguments
  def self.reset!
    @cache = nil
    @child_classes = nil
    @config = nil
    @config_file = nil
    @config_path_override = nil
    @person_extra_methods = {}

    reset_extras!
    Entitlements::Data::Groups::Calculated.reset!
  end

  def self.reset_extras!
    extras_loaded = @extras_loaded
    if extras_loaded
      extras_loaded.each { |clazz| clazz.reset! if clazz.respond_to?(:reset!) }
    end
    @extras_loaded = nil
  end

  # Set up a dummy logger.
  #
  # Returns a Logger.
  Contract C::None => Logger
  def self.dummy_logger
    # :nocov:
    Logger.new(StringIO.new)
    # :nocov:
  end

  # Read the configuration file and return it as a hash.
  #
  # Takes no arguments.
  #
  # Returns a Hash.
  Contract C::None => C::HashOf[String => C::Any]
  def self.config
    @config ||= begin
      content = ERB.render_from_hash(File.read(config_file), {})
      ::YAML.safe_load(content)
    end
  end

  # Set the configuration directly to a Hash.
  #
  # config_hash - Desired value for the configuration.
  #
  # Returns the supplied configuration.
  Contract C::HashOf[String => C::Any] => C::HashOf[String => C::Any]
  def self.config=(config_hash)
    @config = config_hash
  end

  # Determine the configuration file location. Gets the default if
  # it is called before explicitly set.
  #
  # Returns a String.
  Contract C::None => String
  def self.config_file
    @config_file || File.expand_path("../config/entitlements/config.yaml", File.dirname(__FILE__))
  end

  # Allow an alternate configuration file to be set. When this is set, it
  # clears @config so it gets read upon the next invocation.
  #
  # path - Path to config file.
  Contract String => C::Any
  def self.config_file=(path)
    unless File.file?(path)
      raise "Specified config file = #{path.inspect} but it does not exist!"
    end

    @config_file = path
    @config = nil
  end

  # Get the configuration path for the groups. This is based on the relative
  # location to the configuration file if it doesn't start with a "/".
  #
  # Takes no arguments.
  #
  # Returns a String with the config path.
  def self.config_path
    return @config_path_override if @config_path_override
    base = config.fetch("configuration_path")
    return base if base.start_with?("/")
    File.expand_path(base, File.dirname(config_file))
  end

  # Set the configuration path for the groups. This will override the automatically
  # calculated config_path that respects the algorithm noted above.
  #
  # path - Path to the base directory of groups.
  #
  # Returns the config_path that was set.

  Contract String => C::Any
  def self.config_path=(path)
    unless path.start_with?("/")
      raise ArgumentError, "Path must be absolute when setting config_path!"
    end

    unless File.directory?(path)
      raise Errno::ENOENT, "config_path #{path.inspect} is not a directory!"
    end

    @config["configuration_path"] = path if @config
    @config_path_override = path
  end

  # Keep track of backends that are registered when backends are loaded.
  #
  # identifier - A String with the identifier for the backend as it appears in the configuration file
  # clazz      - A Class reference to the backend
  # priority   - An Integer with the order of execution (smaller = first)
  #
  # Returns nothing.
  Contract String, Class, Integer, C::Maybe[C::Bool] => C::Any
  def self.register_backend(identifier, clazz, priority)
    @backends ||= {}
    @backends[identifier] = { class: clazz, priority: priority }
  end

  # Return the registered backends.
  #
  # Takes no arguments.
  #
  # Returns a Hash of backend identifier => class and priority.
  Contract C::None => C::HashOf[String => C::HashOf[Symbol, C::Any]]
  def self.backends
    @backends || {}
  end

  # Load all extras configured by the "extras" key in the entitlements configuration.
  #
  # Takes no arguments.
  #
  # Returns nothing.
  Contract C::None => nil
  def self.load_extras
    Entitlements.config.fetch("extras", {}).each do |extra_name, extra_cfg|
      path = extra_cfg.key?("path") ? Entitlements::Util::Util.absolute_path(extra_cfg["path"]) : nil
      logger.debug "Loading extra #{extra_name} (path = #{path || 'default'})"
      Entitlements::Extras.load_extra(extra_name, path)
    end
    nil
  end

  # Handle a callback from Entitlements::Extras.load_extra to add a class to the tracker of loaded extra classes.
  #
  # clazz - Class that was loaded.
  #
  # Returns nothing.
  Contract Class => C::Any
  def self.record_loaded_extra(clazz)
    @extras_loaded ||= Set.new
    @extras_loaded.add(clazz)
  end

  # Register all filters configured by the "filters" key in the entitlements configuration.
  #
  # Takes no arguments.
  #
  # Returns nothing.
  Contract C::None => nil
  def self.register_filters
    Entitlements.config.fetch("filters", {}).each do |filter_name, filter_cfg|
      filter_class = filter_cfg.fetch("class")
      filter_clazz = Kernel.const_get(filter_class)
      filter_config = filter_cfg.fetch("config", {})

      logger.debug "Registering filter #{filter_name} (class: #{filter_class})"
      Entitlements::Data::Groups::Calculated.register_filter(filter_name, { class: filter_clazz, config: filter_config })
    end
    nil
  end

  @person_extra_methods = {}

  # Register a method on the Entitlements::Models::Person objects. Methods are registered at
  # a class level by extras. This updates @person_methods with a Hash of method_name => reference.
  #
  # method_name - A String with the extra method name to register.
  # method_ref  - A reference to the method within the appropriate class.
  #
  # Returns nothing.
  Contract String, C::Any => C::Any
  def self.register_person_extra_method(method_name, method_class_ref)
    @person_extra_methods[method_name.to_sym] = method_class_ref
  end

  # Get the current entries in @person_methods as a hash.
  #
  # Takes no arguments.
  #
  # Returns a Hash of method_name => reference.
  Contract C::None => C::HashOf[Symbol => C::Any]
  def self.person_extra_methods
    @person_extra_methods
  end

  # Return array of all registered child classes.
  #
  # Takes no arguments.
  #
  # Returns a Hash of instantiated Class objects, indexed by group name, sorted by priority.
  Contract C::None => C::HashOf[C::Or[Symbol, String] => Object]
  def self.child_classes
    @child_classes ||= begin
      backend_obj = Entitlements.config["groups"].map do |group_name, data|
        [group_name, Entitlements.backends[data["type"]][:class].new(group_name)]
      end.compact.to_h

      # Sort first by priority, then by whether this is a mirror or not (mirrors go last), and
      # finally by the length of the OU name from shortest to longest.
      backend_obj.sort_by do |k, v|
        [
          v.priority,
          Entitlements.config["groups"][k] && Entitlements.config["groups"][k].key?("mirror") ? 1 : 0,
          k.length
        ]
      end.to_h
    end
  end

  # Method to access the configured auditors.
  #
  # Takes no arguments.
  #
  # Returns an Array of Entitlements::Auditor::* objects.
  Contract C::None => C::ArrayOf[Entitlements::Auditor::Base]
  def self.auditors
    @auditors ||= begin
      if Entitlements.config.key?("auditors")
        Entitlements.config["auditors"].map do |auditor|
          unless auditor.is_a?(Hash)
            # :nocov:
            raise ArgumentError, "Configuration error: Expected auditor to be a hash, got #{auditor.inspect}!"
            # :nocov:
          end

          auditor_class = auditor.fetch("auditor_class")

          begin
            clazz = Kernel.const_get("Entitlements::Auditor::#{auditor_class}")
          rescue NameError
            raise ArgumentError, "Auditor class #{auditor_class.inspect} is invalid"
          end

          clazz.new(logger, auditor)
        end
      else
        []
      end
    end
  end

  # Global logger for this run of Entitlements.
  #
  # Takes no arguments.
  #
  # Returns a Logger.
  # :nocov:
  def self.logger
    @logger ||= dummy_logger
  end

  def self.set_logger(logger)
    @logger = logger
  end
  # :nocov:

  # Calculate - This runs the entitlements logic to calculate the differences, ultimately
  # populating a cache and returning a list of actions. The cache and actions can then be
  # consumed by `execute` to implement the changes.
  #
  # Takes no arguments.
  #
  # Returns the array of actions.
  Contract C::None => C::ArrayOf[Entitlements::Models::Action]
  def self.calculate
    # Load extras that are configured.
    Entitlements.load_extras if Entitlements.config.key?("extras")

    # Pre-fetch people from configured people data sources.
    Entitlements.prefetch_people

    # Register filters that are configured.
    Entitlements.register_filters if Entitlements.config.key?("filters")

    # Keep track of the total change count.
    cache[:change_count] = 0

    max_parallelism = Entitlements.config["max_parallelism"] || 1

    # Calculate old and new membership in each group.
    thread_pool = Concurrent::FixedThreadPool.new(max_parallelism)
    logger.debug("Begin prefetch and validate for all groups")
    prep_start = Time.now
    futures = Entitlements.child_classes.map do |group_name, obj|
      Concurrent::Future.execute({ executor: thread_pool }) do
        group_start = Time.now
        logger.debug("Begin prefetch and validate for #{group_name}")
        obj.prefetch
        obj.validate
        logger.debug("Finished prefetch and validate for #{group_name} in #{Time.now - group_start}")
      end
    end

    futures.each(&:value!)
    logger.debug("Finished all prefetch and validate in #{Time.now - prep_start}")

    logger.debug("Begin all calculations")
    calc_start = Time.now
    actions = []
    Entitlements.child_classes.map do |group_name, obj|
      obj.calculate
      if obj.change_count > 0
        logger.debug "Group #{group_name.inspect} contributes #{obj.change_count} change(s)."
        cache[:change_count] += obj.change_count
      end
      actions.concat(obj.actions)
    end
    logger.debug("Finished all calculations in #{Time.now - calc_start}")
    logger.debug("Finished all prefetch, validate, and calculation in #{Time.now - prep_start}")

    actions
  end

  # Method to execute all of the actions and run the auditors. Returns an Array of the exceptions
  # raised by auditors. Any exceptions raised by providers will be raised once the auditors are
  # executed.
  #
  # actions - An Array of Entitlements::Models::Action
  #
  # Returns nothing.
  Contract C::KeywordArgs[
    actions: C::ArrayOf[Entitlements::Models::Action]
  ] => nil
  def self.execute(actions:)
    # Set up auditors.
    Entitlements.auditors.each { |auditor| auditor.setup }

    # Track any raised exception to pass to the auditors.
    provider_exception = nil
    audit_exceptions = []
    successful_actions = Set.new

    # Sort the child classes by priority
    begin
      # Pre-apply changes for each class.
      Entitlements.child_classes.each do |_, obj|
        obj.preapply
      end

      # Apply changes from all actions.
      actions.each do |action|
        obj = Entitlements.child_classes.fetch(action.ou)
        obj.apply(action)
        successful_actions.add(action.dn)
      end
    rescue => e
      # Populate 'provider_exception' for the auditors and then raise the exception.
      provider_exception = e
      raise e
    ensure
      # Run the audit "commit" action for each auditor. This needs to happen despite any failures that
      # may occur when pre-applying or applying actions, because actions might have been applied despite
      # any failures raised. Run each audit, even if one fails, and batch up the exceptions for the end.
      # If there was an original exception from one of the providers, this block will be executed and then
      # that original exception will be raised.
      if Entitlements.auditors.any?
        logger.debug "Recording data to #{Entitlements.auditors.size} audit provider(s)"
        Entitlements.auditors.each do |audit|
          begin
            audit.commit(
              actions: actions,
              successful_actions: successful_actions,
              provider_exception: provider_exception
            )
            logger.debug "Audit #{audit.description} completed successfully"
          rescue => e
            logger.error "Audit #{audit.description} failed: #{e.class} #{e.message}"
            e.backtrace.each { |line| logger.error line }
            audit_exceptions << e
          end
        end
      end
    end

    # If we get here there were no provider exceptions. If there were audit exceptions raise them here.
    # If there were multiple exceptions we can only raise the first one, but log a message indicating this.
    return if audit_exceptions.empty?

    if audit_exceptions.size > 1
      logger.warn "There were #{audit_exceptions.size} audit exceptions. Only the first one is raised."
    end
    raise audit_exceptions.first
  end

  # Validate the configuration file.
  #
  # Takes no input.
  #
  # Returns nothing.
  Contract C::None => nil
  def self.validate_configuration_file!
    # Required attributes
    spec = {
      "configuration_path" => { required: true, type: String },
      "backends"           => { required: false, type: Hash },
      "people"             => { required: true, type: Hash },
      "people_data_source"  => { required: true, type: String },
      "groups"             => { required: true, type: Hash },
      "auditors"           => { required: false, type: Array },
      "filters"            => { required: false, type: Hash },
      "extras"             => { required: false, type: Hash },
      "max_parallelism"    => { required: false, type: Integer },
    }

    Entitlements::Util::Util.validate_attr!(spec, Entitlements.config, "Entitlements configuration file")

    # Make sure each group has a valid type, and then forward the validator to the child class.
    # If a named backend is chosen, merge the parameters from the backend with the parameters given
    # for the class configuration, and then remove all indication that a backend was used.
    Entitlements.config["groups"].each do |key, data|
      if data.key?("backend")
        unless Entitlements.config["backends"] && Entitlements.config["backends"].key?(data["backend"])
          raise "Entitlements configuration group #{key.inspect} references non-existing backend #{data['backend'].inspect}!"
        end

        backend = Entitlements.config["backends"].fetch(data["backend"])
        unless backend.key?("type")
          raise "Entitlements backend #{data['backend'].inspect} is missing a type!"
        end

        # Priority in the merge is given to the specific OU configured. Backend data is filled
        # in only as default values when not otherwise defined.
        Entitlements.config["groups"][key] = backend.merge(data)
        Entitlements.config["groups"][key].delete("backend")
        data = Entitlements.config["groups"][key]
      end

      unless data["type"].is_a?(String)
        raise "Entitlements configuration group #{key.inspect} does not properly declare a type!"
      end

      unless Entitlements.backends.key?(data["type"])
        raise "Entitlements configuration group #{key.inspect} has invalid type (#{data['type'].inspect})"
      end
    end

    # Good if nothing is raised by here.
    nil
  end

  # Method to go through each person data source and retrieve the list of people from it. Populates
  # Entitlements.cache[:people][<datasource>] with the objects that can be subsequently `read` from
  # with no penalty.
  #
  # Takes no arguments.
  #
  # Returns the Entitlements::Data::People::* object.
  Contract C::None => C::Any
  def self.prefetch_people
    Entitlements.cache[:people_obj] ||= begin
      people_data_sources = Entitlements.config.fetch("people", [])
      if people_data_sources.empty?
        raise ArgumentError, "At least one data source for people must be specified in the Entitlements configuration!"
      end

      # TODO: In the future, have separate data sources per group.
      people_data_source_name = Entitlements.config.fetch("people_data_source", "")
      if people_data_source_name.empty?
        raise ArgumentError, "The Entitlements configuration must define a people_data_source!"
      end
      unless people_data_sources.key?(people_data_source_name)
        raise ArgumentError, "The people_data_source #{people_data_source_name.inspect} is invalid!"
      end

      objects = people_data_sources.map do |ds_name, ds_config|
        people_obj = Entitlements::Data::People.new_from_config(ds_config)
        people_obj.read
        [ds_name, people_obj]
      end.to_h

      objects.fetch(people_data_source_name)
    end
  end

  # This is a global cache for the whole run of entitlements. To avoid passing objects around, since Entitlements
  # by its nature is a run-once-upon-demand application.
  #
  # Takes no arguments.
  #
  # Returns a Hash that contains the cache.
  #
  # Note: Since this is hit a lot, to avoid the performance penalty, Contracts is not used here.
  # :nocov:
  def self.cache
    @cache ||= {
      calculated: {},
      file_objects: {}
    }
  end
  # :nocov:
end

# Finally, load everything else. Order should be unimportant here.
require_relative "entitlements/auditor/base"
require_relative "entitlements/backend/base_controller"
require_relative "entitlements/backend/base_provider"
require_relative "entitlements/backend/dummy"
require_relative "entitlements/backend/ldap"
require_relative "entitlements/backend/member_of"
require_relative "entitlements/cli"
require_relative "entitlements/data/groups"
require_relative "entitlements/data/people"
require_relative "entitlements/extras"
require_relative "entitlements/extras/base"
require_relative "entitlements/models/action"
require_relative "entitlements/models/group"
require_relative "entitlements/models/person"
require_relative "entitlements/plugins"
require_relative "entitlements/plugins/dummy"
require_relative "entitlements/plugins/group_of_names"
require_relative "entitlements/plugins/posix_group"
require_relative "entitlements/rule/base"
require_relative "entitlements/service/ldap"
require_relative "entitlements/util/mirror"
require_relative "entitlements/util/override"
require_relative "entitlements/util/util"