module PandaPal
  module OrganizationConcerns
    module SettingsValidation
      extend ActiveSupport::Concern

      included do
        validate :validate_settings
      end

      class_methods do
        def define_setting(*args, &blk)
          @_injected_settings_definitions ||= []
          @_injected_settings_definitions << {
            args: args,
            block: blk,
          }
        end

        def settings_structure
          struc = if PandaPal.lti_options&.[](:settings_structure).present?
            normalize_settings_structure(PandaPal.lti_options[:settings_structure])
          else
            {
              type: Hash,
              allow_additional: true,
              properties: {},
            }
          end

          (@_injected_settings_definitions || []).each do |sub|
            args = [*sub[:args]]
            path = args.shift || []
            path = path.split('.') if path.is_a?(String)
            path = Array(path)

            if path.present?
              key = path.pop

              root = struc
              path.each do |p|
                root = root[:properties][p.to_sym]
              end

              if sub[:block]
                root[:properties][key.to_sym] = sub[:block].call
              else
                root[:properties][key.to_sym] = args.shift
              end
            else
              sub[:block].call(struc)
            end
          end

          struc
        end

        def normalize_settings_structure(struc)
          return {} unless struc.present?
          return struc if struc[:properties] || struc[:type] || struc.key?(:required)

          struc = struc.dup
          nstruc = {}

          nstruc[:type] = struc.delete(:data_type) if struc.key?(:data_type)
          nstruc[:required] = struc.delete(:is_required) if struc.key?(:is_required)
          nstruc[:properties] = struc.map { |k, sub| [k, normalize_settings_structure(sub)] }.to_h if struc.present?

          nstruc[:type] = "Hash" if !nstruc[:type] && nstruc.key?(:properties)

          nstruc
        end

        def remove_undeclared_settings(value, setting: settings_structure)
          if setting[:type] == "Hash"
            value.dup.tap do |value|
              value.keys.each do |key|
                if setting[:properties].key?(key.to_sym)
                  value[key] = remove_undeclared_settings(value[key], setting: setting[:properties][key.to_sym])
                elsif !setting[:allow_additional]
                  value.delete(key)
                end
              end
            end
          else
            value
          end
        end

        def generate_settings_ruby(indent: 0, setting: settings_structure, value: { }, exclude_extra: false)
          builder = ConsoleHelpers::CodeBuilder.new(indent: indent)

          value = setting[:default] || setting.dig(:json_schema, :default) if value == :not_given

          if setting[:type] == "Hash"
            builder << "{"
            builder << "\n"
            builder.indent!
            setting[:properties].each do |key, sub|
              if sub[:description]
                builder << sub[:description].lines.map{|l| "# #{l}"}
                builder.ensure_line
              end

              sub_val = value&.key?(key) ? value[key] : :not_given
              commented = sub_val == :not_given && !sub[:required]

              builder.indent!("# ") if commented
              builder << "#{key}: "
              builder << generate_settings_ruby(setting: sub, value: sub_val)
              builder << ",\n"
              builder.dedent! if commented
            end

            # If we're editing an existing org, we may have extra keys that aren't in the schema
            # But if we're creating a new org, we don't want to include undocumented entries from pandapalrc.yml
            unless exclude_extra
              extra_keys = (value&.keys || []) - setting[:properties].keys.map(&:to_s)
              extra_keys.each do |key|
                builder << "# Undocumented\n" unless setting[:allow_additional]
                builder << "#{key}: #{value[key].inspect},\n"
              end
            end

            builder.dedent!
            builder << "}"
          elsif setting[:type] == "Array"
            builder << "#{value&.inspect || '[]'}"
          elsif setting[:type] == "Integer"
            builder << "#{value || 0}"
          elsif setting[:type] == "Numeric"
            builder << "#{value || 1.0}"
          else setting[:type] == "String"
            builder << "\"#{value}\""
          end

          builder.to_s
        end
      end

      def settings_structure
        self.class.settings_structure
      end

      def validate_settings
        validate_settings_level(settings || {}, settings_structure).each do |err|
          errors.add(:settings, err)
        end
      end

      private

      def validate_settings_level(settings, spec, path: [], errors: [])
        human_path = "[:#{path.join('][:')}]"

        if settings.nil?
          errors << "Entry #{human_path} is required" if spec[:required]
          return errors
        end

        if spec[:type]
          norm_types = Array(spec[:type]).map do |t|
            if [:bool, :boolean, 'Bool', 'Boolean'].include?(t)
              'Boolean'
            elsif t.is_a?(String)
              t.constantize
            else
              t
            end
          end

          any_match = norm_types.any? do |t|
            if t == 'Boolean'
              settings == true || settings == false
            elsif t.is_a?(Class)
              settings.is_a?(t)
            end
          end

          unless any_match
            errors << "Expected #{human_path} to be a #{spec[:type]}. Was a #{settings.class.to_s}"
            return errors
          end
        end

        if spec[:validate].present?
          val_errors = []
          if spec[:validate].is_a?(Symbol)
            proc_result = send(spec[:validate], settings, spec, path: path, errors: val_errors)
          elsif spec[:validate].is_a?(String)
            split_val = spec[:validate].split?('.')
            split_val << 'validate_settings' if split_val.count == 1
            resolved_module = split_val[0].constantize
            proc_result = resolved_module.send(split_val[1].to_sym, settings, spec, path: path, errors: val_errors)
          elsif spec[:validate].is_a?(Proc)
            proc_result = instance_exec(settings, spec, path: path, errors: val_errors, &spec[:validate])
          end
          val_errors << proc_result unless val_errors.present? || proc_result == val_errors
          val_errors = val_errors.flatten.uniq.compact.map do |ve|
            ve.gsub('<path>', human_path)
          end
          errors.concat(val_errors)
        end

        if settings.is_a?(Array)
          if spec[:length].is_a?(Range)
            errors << "#{human_path} should contain #{spec[:length]} items" unless spec[:length].include?(settings.count)
          elsif spec[:length].is_a?(Numeric)
            errors << "#{human_path} should contain exactly #{spec[:length]} items" unless spec[:length] == settings.count
          end

          if spec[:item] != nil
            settings.each_with_index do |value, i|
              validate_settings_level(settings[i], spec[:item], path: [*path, i], errors: errors)
            end
          end
        end

        if settings.is_a?(Hash)
          if spec[:properties] != nil
            spec[:properties].each do |key, pspec|
              validate_settings_level(settings[key], pspec, path: [*path, key], errors: errors)
            end
          end

          if spec[:properties] != nil || spec[:allow_additional] != nil
            set_keys = settings.keys
            expected_keys = spec[:properties]&.keys || []
            expected_keys = expected_keys.map(&:to_s) if settings.is_a?(HashWithIndifferentAccess)

            extra_keys = set_keys - expected_keys

            if extra_keys.present?
              if spec[:allow_additional].is_a?(Hash)
                extra_keys.each do |key|
                  validate_settings_level(settings[key], spec[:allow_additional], path: [*path, key], errors: errors)
                end
              elsif !spec[:allow_additional]
                errors << "Did not expect #{human_path} to contain [#{extra_keys.join(', ')}]"
              end
            end
          end
        end

        errors
      end
    end
  end
end