# frozen_string_literal: true

module I18nJS
  class Schema
    InvalidError = Class.new(StandardError)

    REQUIRED_LINT_TRANSLATIONS_KEYS = %i[ignore].freeze
    REQUIRED_LINT_SCRIPTS_KEYS = %i[ignore patterns].freeze
    REQUIRED_TRANSLATION_KEYS = %i[file patterns].freeze
    TRANSLATION_KEYS = %i[file patterns].freeze

    def self.root_keys
      @root_keys ||= Set.new(%i[
        translations
        lint_translations
        lint_scripts
        check
      ])
    end

    def self.required_root_keys
      @required_root_keys ||= Set.new(%i[translations])
    end

    def self.validate!(target)
      schema = new(target)
      schema.validate!
      schema
    end

    attr_reader :target

    def initialize(target)
      @target = target
    end

    def validate!
      validate_root

      expect_required_keys(
        keys: self.class.required_root_keys,
        path: nil
      )

      reject_extraneous_keys(
        keys: self.class.root_keys,
        path: nil
      )

      validate_translations
      validate_lint_translations
      validate_lint_scripts
      validate_plugins
    end

    def validate_plugins
      I18nJS.plugins.each do |plugin|
        next unless target.key?(plugin.config_key)

        expect_type(
          path: [plugin.config_key, :enabled],
          types: [TrueClass, FalseClass]
        )

        plugin.validate_schema
      end
    end

    def validate_root
      return if target.is_a?(Hash)

      message =  "Expected config to be \"Hash\"; " \
                 "got #{target.class} instead"

      reject message, target
    end

    def validate_lint_translations
      key = :lint_translations

      return unless target.key?(key)

      expect_type(path: [key], types: Hash)

      expect_required_keys(
        keys: REQUIRED_LINT_TRANSLATIONS_KEYS,
        path: [key]
      )

      expect_type(path: [key, :ignore], types: Array)
    end

    def validate_lint_scripts
      key = :lint_scripts

      return unless target.key?(key)

      expect_type(path: [key], types: Hash)
      expect_required_keys(
        keys: REQUIRED_LINT_SCRIPTS_KEYS,
        path: [key]
      )
      expect_type(path: [key, :ignore], types: Array)
      expect_type(path: [key, :patterns], types: Array)
    end

    def validate_translations
      expect_array_with_items(path: [:translations])

      target[:translations].each_with_index do |translation, index|
        validate_translation(translation, index)
      end
    end

    def validate_translation(_translation, index)
      expect_required_keys(
        path: [:translations, index],
        keys: REQUIRED_TRANSLATION_KEYS
      )

      reject_extraneous_keys(
        keys: TRANSLATION_KEYS,
        path: [:translations, index]
      )

      expect_type(path: [:translations, index, :file], types: String)
      expect_array_with_items(path: [:translations, index, :patterns])
    end

    def reject(error_message, node = nil)
      node_json = "\n#{JSON.pretty_generate(node)}" if node
      raise InvalidError, "#{error_message}#{node_json}"
    end

    def expect_type(path:, types:)
      path = prepare_path(path:)
      value = value_for(path:)
      types = Array(types)

      return if types.any? {|type| value.is_a?(type) }

      actual_type = value.class

      type_desc = if types.size == 1
                    types[0].to_s.inspect
                  else
                    "one of #{types.inspect}"
                  end

      message = [
        "Expected #{path.join('.').inspect} to be #{type_desc};",
        "got #{actual_type} instead"
      ].join(" ")

      reject message, target
    end

    def expect_array_with_items(path:)
      expect_type(path:, types: Array)

      path = prepare_path(path:)
      value = value_for(path:)

      return unless value.empty?

      reject "Expected #{path.join('.').inspect} to have at least one item",
             target
    end

    def expect_required_keys(keys:, path:)
      path = prepare_path(path:)
      value = value_for(path:)
      actual_keys = value.keys.map(&:to_sym)

      keys.each do |key|
        next if actual_keys.include?(key)

        path_desc = if path.empty?
                      key.to_s.inspect
                    else
                      (path + [key]).join(".").inspect
                    end

        reject "Expected #{path_desc} to be defined", target
      end
    end

    def reject_extraneous_keys(keys:, path:)
      path = prepare_path(path:)
      value = value_for(path:)

      actual_keys = value.keys.map(&:to_sym)
      extraneous = actual_keys.to_a - keys.to_a

      return if extraneous.empty?

      path_desc = if path.empty?
                    "config"
                  else
                    path.join(".").inspect
                  end

      reject "#{path_desc} has unexpected keys: #{extraneous.inspect}",
             target
    end

    def prepare_path(path:)
      path = path.to_s.split(".").map(&:to_sym) unless path.is_a?(Array)
      path
    end

    def value_for(path:)
      path.empty? ? target : target.dig(*path)
    end
  end
end
