# frozen_string_literal: true

require 'optparse'
require 'pathname'
require 'uri'

require 'jazzy/podspec_documenter'
require 'jazzy/source_declaration/access_control_level'

module Jazzy
  # rubocop:disable Metrics/ClassLength
  class Config
    # rubocop:disable Naming/AccessorMethodName
    class Attribute
      attr_reader :name, :description, :command_line, :config_file_key,
                  :default, :parse, :per_module

      def initialize(name, description: nil, command_line: nil,
                     default: nil, parse: ->(x) { x }, per_module: false)
        @name = name.to_s
        @description = Array(description)
        @command_line = Array(command_line)
        @default = default
        @parse = parse
        @config_file_key = full_command_line_name || @name
        @per_module = per_module
      end

      def get(config)
        config.method(name).call
      end

      def set_raw(config, val)
        config.method("#{name}=").call(val)
      end

      def set(config, val, mark_configured: true)
        set_raw(config, config.instance_exec(val, &parse))
        config.method("#{name}_configured=").call(true) if mark_configured
      end

      def set_to_default(config)
        set(config, default, mark_configured: false) if default
      end

      def set_if_unconfigured(config, val)
        set(config, val) unless configured?(config)
      end

      def configured?(config)
        config.method("#{name}_configured").call
      end

      def attach_to_option_parser(config, opt)
        return if command_line.empty?

        opt.on(*command_line, *description) do |val|
          set(config, val)
        end
      end

      private

      def full_command_line_name
        long_option_names = command_line.map do |opt|
          Regexp.last_match(1) if opt.to_s =~ %r{
            ^--           # starts with double dash
            (?:\[no-\])?  # optional prefix for booleans
            ([^\s]+)      # long option name
          }x
        end
        if long_option_name = long_option_names.compact.first
          long_option_name.tr('-', '_')
        end
      end
    end
    # rubocop:enable Naming/AccessorMethodName

    def self.config_attr(name, **opts)
      attr_accessor name
      attr_accessor "#{name}_configured"

      @all_config_attrs ||= []
      @all_config_attrs << Attribute.new(name, **opts)
    end

    def self.alias_config_attr(name, forward, **opts)
      alias_method name.to_s, forward.to_s
      alias_method "#{name}=", "#{forward}="
      alias_method "#{name}_configured", "#{forward}_configured"
      alias_method "#{name}_configured=", "#{forward}_configured="
      @all_config_attrs << Attribute.new(name, **opts)
    end

    class << self
      attr_reader :all_config_attrs
    end

    attr_accessor :base_path

    def expand_glob_path(path)
      Pathname(path).expand_path(base_path) # nil means Pathname.pwd
    end

    def expand_path(path)
      abs_path = expand_glob_path(path)
      Pathname(Dir[abs_path][0] || abs_path) # Use existing filesystem spelling
    end

    def hide_swift?
      hide_declarations == 'swift'
    end

    def hide_objc?
      hide_declarations == 'objc'
    end

    # ──────── Build ────────

    # rubocop:disable Layout/ArgumentAlignment

    config_attr :output,
      description: 'Folder to output the HTML docs to',
      command_line: ['-o', '--output FOLDER'],
      default: 'docs',
      parse: ->(o) { expand_path(o) }

    config_attr :clean,
      command_line: ['-c', '--[no-]clean'],
      description: ['Delete contents of output directory before running. ',
                    'WARNING: If --output is set to ~/Desktop, this will ' \
                      'delete the ~/Desktop directory.'],
      default: false

    config_attr :objc_mode,
      command_line: '--[no-]objc',
      description: 'Generate docs for Objective-C.',
      default: false,
      per_module: true

    config_attr :umbrella_header,
      command_line: '--umbrella-header PATH',
      description: 'Umbrella header for your Objective-C framework.',
      parse: ->(uh) { expand_path(uh) },
      per_module: true

    config_attr :framework_root,
      command_line: '--framework-root PATH',
      description: 'The root path to your Objective-C framework.',
      parse: ->(fr) { expand_path(fr) },
      per_module: true

    config_attr :sdk,
      command_line: '--sdk [iphone|watch|appletv][os|simulator]|macosx',
      description: 'The SDK for which your code should be built.',
      default: 'macosx',
      per_module: true

    config_attr :hide_declarations,
      command_line: '--hide-declarations [objc|swift] ',
      description: 'Hide declarations in the specified language. Given that ' \
        'generating Swift docs only generates Swift declarations, ' \
        'this is useful for hiding a specific interface for ' \
        'either Objective-C or mixed Objective-C and Swift ' \
        'projects.',
      default: ''

    config_attr :keep_property_attributes,
      command_line: '--[no-]keep-property-attributes',
      description: 'Include the default Objective-C property attributes.',
      default: false

    config_attr :config_file,
      command_line: '--config PATH',
      description: ['Configuration file (.yaml or .json)',
                    'Default: .jazzy.yaml in source directory or ancestor'],
      parse: ->(cf) { expand_path(cf) }

    config_attr :build_tool_arguments,
      command_line: ['-b', '--build-tool-arguments arg1,arg2,…argN', Array],
      description: 'Arguments to forward to xcodebuild, swift build, or ' \
        'sourcekitten.',
      default: [],
      per_module: true

    config_attr :modules,
      command_line: ['--modules Mod1,Mod2,…ModN', Array],
      description: 'List of modules to document.  Use the config file to set per-module ' \
        "build flags, see 'Documenting multiple modules' in the README.",
      default: []

    alias_config_attr :xcodebuild_arguments, :build_tool_arguments,
      command_line: ['-x', '--xcodebuild-arguments arg1,arg2,…argN', Array],
      description: 'Back-compatibility alias for build_tool_arguments.'

    config_attr :sourcekitten_sourcefile,
      command_line: ['-s', '--sourcekitten-sourcefile filepath1,…filepathN',
                     Array],
      description: 'File(s) generated from sourcekitten output to parse',
      parse: ->(paths) { [paths].flatten.map { |path| expand_path(path) } },
      default: [],
      per_module: true

    config_attr :source_directory,
      command_line: '--source-directory DIRPATH',
      description: 'The directory that contains the source to be documented',
      default: Pathname.pwd,
      parse: ->(sd) { expand_path(sd) },
      per_module: true

    config_attr :symbolgraph_directory,
      command_line: '--symbolgraph-directory DIRPATH',
      description: 'A directory containing a set of Swift Symbolgraph files ' \
        'representing the module to be documented',
      parse: ->(sd) { expand_path(sd) },
      per_module: true

    config_attr :excluded_files,
      command_line: ['-e', '--exclude filepath1,filepath2,…filepathN', Array],
      description: 'Source file pathnames to be excluded from documentation. ' \
        'Supports wildcards.',
      default: [],
      parse: ->(files) do
        Array(files).map { |f| expand_glob_path(f).to_s }
      end

    config_attr :included_files,
      command_line: ['-i', '--include filepath1,filepath2,…filepathN', Array],
      description: 'Source file pathnames to be included in documentation. ' \
        'Supports wildcards.',
      default: [],
      parse: ->(files) do
        Array(files).map { |f| expand_glob_path(f).to_s }
      end

    config_attr :swift_version,
      command_line: '--swift-version VERSION',
      default: nil,
      parse: ->(v) do
        if v.to_s.empty?
          nil
        elsif v.to_f < 2
          raise 'jazzy only supports Swift 2.0 or later.'
        else
          v
        end
      end

    SWIFT_BUILD_TOOLS = %w[spm xcodebuild symbolgraph].freeze

    config_attr :swift_build_tool,
      command_line: "--swift-build-tool #{SWIFT_BUILD_TOOLS.join(' | ')}",
      description: 'Control whether Jazzy uses Swift Package Manager, ' \
        'xcodebuild, or swift-symbolgraph to build the module ' \
        'to be documented.  By default it uses xcodebuild if ' \
        'there is a .xcodeproj file in the source directory.',
      parse: ->(tool) do
        return tool.to_sym if SWIFT_BUILD_TOOLS.include?(tool)

        raise "Unsupported swift_build_tool #{tool}, " \
          "supported values: #{SWIFT_BUILD_TOOLS.join(', ')}"
      end

    # ──────── Metadata ────────

    config_attr :author_name,
      command_line: ['-a', '--author AUTHOR_NAME'],
      description: 'Name of author to attribute in docs (e.g. Realm)',
      default: ''

    config_attr :author_url,
      command_line: ['-u', '--author_url URL'],
      description: 'Author URL of this project (e.g. https://realm.io)',
      default: '',
      parse: ->(u) { URI(u) }

    config_attr :module_name,
      command_line: ['-m', '--module MODULE_NAME'],
      description: 'Name of module being documented. (e.g. RealmSwift)',
      default: '',
      per_module: true

    config_attr :version,
      command_line: '--module-version VERSION',
      description: 'Version string to use as part of the default docs ' \
        'title and inside the docset.',
      default: '1.0'

    config_attr :title,
      command_line: '--title TITLE',
      description: 'Title to display at the top of each page, overriding the ' \
        'default generated from module name and version.',
      default: ''

    config_attr :copyright,
      command_line: '--copyright COPYRIGHT_MARKDOWN',
      description: 'copyright markdown rendered at the bottom of the docs pages'

    config_attr :readme_path,
      command_line: '--readme FILEPATH',
      description: 'The path to a markdown README file',
      parse: ->(rp) { expand_path(rp) }

    config_attr :readme_title,
      command_line: '--readme-title TITLE',
      description: 'The title for the README in the generated documentation'

    config_attr :documentation_glob,
      command_line: '--documentation GLOB',
      description: 'Glob that matches available documentation',
      parse: ->(dg) { Pathname.glob(dg) }

    config_attr :abstract_glob,
      command_line: '--abstract GLOB',
      description: 'Glob that matches available abstracts for categories',
      parse: ->(ag) { Pathname.glob(ag) }

    config_attr :podspec,
      command_line: '--podspec FILEPATH',
      description: 'A CocoaPods Podspec that describes the Swift library to ' \
        'document',
      parse: ->(ps) { PodspecDocumenter.create_podspec(Pathname(ps)) if ps },
      default: Dir['*.podspec{,.json}'].first

    config_attr :pod_sources,
      command_line: ['--pod-sources url1,url2,…urlN', Array],
      description: 'A list of sources to find pod dependencies. Used only ' \
        'with --podspec when the podspec contains references to ' \
        'privately hosted pods. You must include the default pod ' \
        'source if public pods are also used.',
      default: []

    config_attr :docset_icon,
      command_line: '--docset-icon FILEPATH',
      parse: ->(di) { expand_path(di) }

    config_attr :docset_path,
      command_line: '--docset-path DIRPATH',
      description: 'The relative path for the generated docset'

    config_attr :docset_title,
      command_line: '--docset-title TITLE',
      description: 'The title of the generated docset.  A simplified version ' \
        'is used for the filenames associated with the docset.  If the ' \
        'option is not set then the name of the module being documented is ' \
        'used as the docset title.'

    # ──────── URLs ────────

    config_attr :root_url,
      command_line: ['-r', '--root-url URL'],
      description: 'Absolute URL root where these docs will be stored',
      # ensure trailing slash for correct URI.join()
      parse: ->(r) { URI(r.sub(%r{/?$}, '/')) }

    config_attr :dash_url,
      command_line: ['-d', '--dash_url URL'],
      description: 'Location of the dash XML feed ' \
        'e.g. https://realm.io/docsets/realm.xml)',
      parse: ->(d) { URI(d) }

    SOURCE_HOSTS = %w[github gitlab bitbucket].freeze

    config_attr :source_host,
      command_line: "--source-host #{SOURCE_HOSTS.join(' | ')}",
      description: ['The source-code hosting site to be linked from documentation.',
                    'This setting affects the logo image and link format.',
                    "Default: 'github'"],
      default: 'github',
      parse: ->(host) do
        return host.to_sym if SOURCE_HOSTS.include?(host)

        raise "Unsupported source_host '#{host}', " \
          "supported values: #{SOURCE_HOSTS.join(', ')}"
      end

    config_attr :source_host_url,
      command_line: ['--source-host-url URL'],
      description: ["URL to link from the source host's logo.",
                    'For example https://github.com/realm/realm-cocoa'],
      parse: ->(g) { URI(g) }

    alias_config_attr :github_url, :source_host_url,
      command_line: ['-g', '--github_url URL'],
      description: 'Back-compatibility alias for source_host_url.'

    config_attr :source_host_files_url,
      command_line: '--source-host-files-url PREFIX',
      description: [
        "The base URL on the source host of the project's files, to link " \
          'from individual declarations.',
        'For example https://github.com/realm/realm-cocoa/tree/v0.87.1',
      ]

    alias_config_attr :github_file_prefix, :source_host_files_url,
      command_line: '--github-file-prefix PREFIX',
      description: 'Back-compatibility alias for source_host_files_url'

    config_attr :docset_playground_url,
      command_line: '--docset-playground-url URL',
      description: 'URL of an interactive playground to demonstrate the ' \
        'framework, linked to from the docset.'

    # ──────── Doc generation options ────────
    config_attr :disable_search,
      command_line: '--disable-search',
      description: 'Avoid generating a search index. ' \
        'Search is available in some themes.',
      default: false

    config_attr :skip_documentation,
      command_line: '--skip-documentation',
      description: 'Will skip the documentation generation phase.',
      default: false

    config_attr :min_acl,
      command_line:
         '--min-acl [private | fileprivate | internal | package | public | open]',
      description: 'minimum access control level to document',
      default: 'public',
      parse: ->(acl) do
        SourceDeclaration::AccessControlLevel.from_human_string(acl)
      end

    config_attr :skip_undocumented,
      command_line: '--[no-]skip-undocumented',
      description: "Don't document declarations that have no documentation " \
        'comments.',
      default: false

    config_attr :hide_documentation_coverage,
      command_line: '--[no-]hide-documentation-coverage',
      description: 'Hide "(X% documented)" from the generated documents',
      default: false

    config_attr :custom_categories,
      description: 'Custom navigation categories to replace the standard ' \
        "'Classes', 'Protocols', etc. Types not explicitly named " \
        'in a custom category appear in generic groups at the ' \
        'end.  Example: https://git.io/v4Bcp',
      default: []

    config_attr :custom_categories_unlisted_prefix,
      description: "Prefix for navigation section names that aren't " \
        'explicitly listed in `custom_categories`.',
      default: 'Other '

    config_attr :hide_unlisted_documentation,
      command_line: '--[no-]hide-unlisted-documentation',
      description: "Don't include documentation in the sidebar from the " \
        "`documentation` config value that aren't explicitly " \
        'listed in `custom_categories`.',
      default: false

    config_attr :custom_head,
      command_line: '--head HTML',
      description: 'Custom HTML to inject into <head></head>.',
      default: ''

    BUILTIN_THEME_DIR = Pathname(__dir__) + 'themes'
    BUILTIN_THEMES = BUILTIN_THEME_DIR.children(false).map(&:to_s)

    config_attr :theme_directory,
      command_line: "--theme [#{BUILTIN_THEMES.join(' | ')} | DIRPATH]",
      description: "Which theme to use. Specify either 'apple' (default), " \
        'one of the other built-in theme names, or the path to ' \
        'your mustache templates and other assets for a custom ' \
        'theme.',
      default: 'apple',
      parse: ->(t) do
        if BUILTIN_THEMES.include?(t)
          BUILTIN_THEME_DIR + t
        else
          expand_path(t)
        end
      end

    config_attr :use_safe_filenames,
      command_line: '--use-safe-filenames',
      description: 'Replace unsafe characters in filenames with an encoded ' \
        'representation. This will reduce human readability of ' \
        'some URLs, but may be necessary for projects that ' \
        'expose filename-unfriendly functions such as /(_:_:)',
      default: false

    config_attr :template_directory,
      command_line: ['-t', '--template-directory DIRPATH'],
      description: 'DEPRECATED: Use --theme instead.',
      parse: ->(_) do
        raise '--template-directory (-t) is deprecated: use --theme instead.'
      end

    config_attr :assets_directory,
      command_line: '--assets-directory DIRPATH',
      description: 'DEPRECATED: Use --theme instead.',
      parse: ->(_) do
        raise '--assets-directory is deprecated: use --theme instead.'
      end

    config_attr :undocumented_text,
      command_line: '--undocumented-text UNDOCUMENTED_TEXT',
      description: 'Default text for undocumented symbols. The default ' \
        'is "Undocumented", put "" if no text is required',
      default: 'Undocumented'

    config_attr :separate_global_declarations,
      command_line: '--[no-]separate-global-declarations',
      description: 'Create separate pages for all global declarations ' \
        "(classes, structures, enums etc.) even if they don't " \
        'have children.',
      default: false

    config_attr :include_spi_declarations,
      command_line: '--[no-]include-spi-declarations',
      description: 'Include Swift declarations marked `@_spi` even if ' \
        '--min-acl is set to `public` or `open`.',
      default: false

    MERGE_MODULES = %w[all extensions none].freeze

    config_attr :merge_modules,
      command_line: "--merge-modules #{MERGE_MODULES.join(' | ')}",
      description: 'Control how to display declarations from multiple ' \
        'modules.  `all`, the default, places all declarations of the ' \
        "same kind together.  `none` keeps each module's declarations " \
        'separate.  `extensions` is like `none` but merges ' \
        'cross-module extensions into their extended type.',
      default: 'all',
      parse: ->(merge) do
        return merge.to_sym if MERGE_MODULES.include?(merge)

        raise "Unsupported merge_modules #{merge}, " \
          "supported values: #{MERGE_MODULES.join(', ')}"
      end

    # rubocop:enable Layout/ArgumentAlignment

    def initialize
      self.class.all_config_attrs.each do |attr|
        attr.set_to_default(self)
      end
    end

    def theme_directory=(theme_directory)
      @theme_directory = theme_directory
      Doc.template_path = theme_directory + 'templates'
    end

    def self.parse!
      config = new
      config.parse_command_line
      config.parse_config_file
      PodspecDocumenter.apply_config_defaults(config.podspec, config)

      config.set_module_configs

      config.validate

      config
    end

    def warning(message)
      warn "WARNING: #{message}"
    end

    # rubocop:disable Metrics/MethodLength
    def parse_command_line
      OptionParser.new do |opt|
        opt.banner = 'Usage: jazzy'
        opt.separator ''
        opt.separator 'Options'

        self.class.all_config_attrs.each do |attr|
          attr.attach_to_option_parser(self, opt)
        end

        opt.on('-v', '--version', 'Print version number') do
          puts "jazzy version: #{Jazzy::VERSION}"
          exit
        end

        opt.on('-h', '--help [TOPIC]', 'Available topics:',
               '  usage   Command line options (this help message)',
               '  config  Configuration file options',
               '...or an option keyword, e.g. "dash"') do |topic|
          case topic
          when 'usage', nil
            puts opt
          when 'config'
            print_config_file_help
          else
            print_option_help(topic)
          end
          exit
        end
      end.parse!

      unless ARGV.empty?
        warning "Leftover unused command-line text: #{ARGV}"
      end
    end

    def parse_config_file
      config_path = locate_config_file
      return unless config_path

      self.base_path = config_path.parent

      puts "Using config file #{config_path}"
      config_file = read_config_file(config_path)

      attrs_by_conf_key, attrs_by_name = grouped_attributes

      parse_config_hash(config_file, attrs_by_conf_key, attrs_by_name)
    end

    def parse_config_hash(hash, attrs_by_conf_key, attrs_by_name, override: false)
      hash.each do |key, value|
        unless attr = attrs_by_conf_key[key]
          message = "Unknown config file attribute #{key.inspect}"
          if matching_name = attrs_by_name[key]
            message +=
              " (Did you mean #{matching_name.first.config_file_key.inspect}?)"
          end
          warning message
          next
        end
        setter = override ? :set : :set_if_unconfigured
        attr.first.method(setter).call(self, value)
      end
    end

    # Find keyed versions of the attributes, by config file key and then name-in-code
    # Optional block allows filtering/overriding of attribute list.
    def grouped_attributes
      attrs = self.class.all_config_attrs
      attrs = yield attrs if block_given?
      %i[config_file_key name].map do |property|
        attrs.group_by(&property)
      end
    end

    def validate
      if source_host_configured &&
         source_host_url.nil? &&
         source_host_files_url.nil?
        warning 'Option `source_host` is set but has no effect without either ' \
          '`source_host_url` or `source_host_files_url`.'
      end

      if modules_configured && module_name_configured
        raise 'Options `modules` and `module` are both set which is not supported. ' \
          'To document multiple modules, use just `modules`.'
      end

      if modules_configured && podspec_configured
        raise 'Options `modules` and `podspec` are both set which is not supported.'
      end

      module_configs.each(&:validate_module)
    end

    def validate_module
      if objc_mode &&
         build_tool_arguments_configured &&
         (framework_root_configured || umbrella_header_configured)
        warning 'Option `build_tool_arguments` is set: values passed to ' \
          '`framework_root` or `umbrella_header` may be ignored.'
      end
    end

    # rubocop:enable Metrics/MethodLength

    # Module Configs
    #
    # The user can enter module information in three different ways.  This
    # consolidates them into one view for the rest of the code.
    #
    # 1) Single module, back-compatible
    #    --module Foo etc etc (or not given at all)
    #
    # 2) Multiple modules, simple, sharing build params
    #    --modules Foo,Bar,Baz --source-directory Xyz
    #
    # 3) Multiple modules, custom, different build params but
    #    inheriting others from the top level.
    #    This is config-file only.
    #    - modules
    #      - module: Foo
    #        source_directory: Xyz
    #        build_tool_arguments: [a, b, c]
    #
    # After this we're left with `config.module_configs` that is an
    # array of `Config` objects.

    attr_reader :module_configs
    attr_reader :module_names

    def set_module_configs
      @module_configs = parse_module_configs
      @module_names = module_configs.map(&:module_name)
      @module_names_set = Set.new(module_names)
    end

    def module_name?(name)
      @module_names_set.include?(name)
    end

    def multiple_modules?
      @module_names.count > 1
    end

    def parse_module_configs
      return [self] unless modules_configured

      raise 'Config file key `modules` must be an array' unless modules.is_a?(Array)

      if modules.first.is_a?(String)
        # Massage format (2) into (3)
        self.modules = modules.map { |mod| { 'module' => mod } }
      end

      # Allow per-module overrides of only some config options
      attrs_by_conf_key, attrs_by_name =
        grouped_attributes { |attr| attr.select(&:per_module) }

      modules.map do |module_hash|
        mod_name = module_hash['module'] || ''
        raise 'Missing `modules.module` config key' if mod_name.empty?

        dup.tap do |module_config|
          module_config.parse_config_hash(
            module_hash, attrs_by_conf_key, attrs_by_name, override: true
          )
        end
      end
    end

    # For podspec query
    def module_name_known?
      module_name_configured || modules_configured
    end

    def locate_config_file
      return config_file if config_file

      source_directory.ascend do |dir|
        candidate = dir.join('.jazzy.yaml')
        return candidate if candidate.exist?
      end
      nil
    end

    def read_config_file(file)
      case File.extname(file)
        when '.json'
          JSON.parse(File.read(file))
        when '.yaml', '.yml'
          YAML.safe_load(File.read(file))
        else raise "Config file must be .yaml or .json, but got #{file.inspect}"
      end
    end

    def print_config_file_help
      puts <<-_EOS_

        By default, jazzy looks for a file named ".jazzy.yaml" in the source
        directory and its ancestors. You can override the config file location
        with --config.

        (The source directory is the current working directory by default.
        You can override that with --source-directory.)

        The config file can be in YAML or JSON format. Available options are:

      _EOS_
        .gsub(/^ +/, '')

      print_option_help
    end

    def print_option_help(topic = '')
      found = false
      self.class.all_config_attrs.each do |attr|
        match = ([attr.name] + attr.command_line).any? do |opt|
          opt.to_s.include?(topic)
        end
        if match
          found = true
          puts
          puts attr.name.to_s.tr('_', ' ').upcase
          puts
          puts "  Config file:   #{attr.config_file_key}"
          cmd_line_forms = attr.command_line.select { |opt| opt.is_a?(String) }
          if cmd_line_forms.any?
            puts "  Command line:  #{cmd_line_forms.join(', ')}"
          end
          puts
          print_attr_description(attr)
        end
      end
      warn "Unknown help topic #{topic.inspect}" unless found
    end

    def print_attr_description(attr)
      attr.description.each { |line| puts "  #{line}" }
      if attr.default && attr.default != ''
        puts "  Default: #{attr.default}"
      end
    end

    #-------------------------------------------------------------------------#

    # @!group Singleton

    # @return [Config] the current config instance creating one if needed.
    #
    def self.instance
      @instance ||= new
    end

    # Sets the current config instance. If set to nil the config will be
    # recreated when needed.
    #
    # @param  [Config, Nil] the instance.
    #
    # @return [void]
    #
    class << self
      attr_writer :instance
    end

    # Provides support for accessing the configuration instance in other
    # scopes.
    #
    module Mixin
      def config
        Config.instance
      end
    end
  end
  # rubocop:enable Metrics/ClassLength
end