require 'singleton'

module Vmdb
  class Plugins
    include Singleton
    private_class_method :instance

    include Enumerable

    def self.method_missing(meth, ...)
      instance.respond_to?(meth) ? instance.send(meth, ...) : super
    end

    def self.respond_to_missing?(*args)
      instance.respond_to?(*args)
    end

    def all
      @all ||= Rails::Engine.subclasses.select { |engine| engine.try(:vmdb_plugin?) }.sort_by(&:name)
    end

    def each(&block)
      all.each(&block)
    end

    def init
      load_inflections
      init_loggers
      register_models
    end

    def details
      index_with do |engine|
        {
          :name    => engine.name,
          :version => version(engine),
          :path    => engine.root.to_s
        }
      end
    end

    def paths
      details.transform_values { |v| v[:path] }
    end

    def versions
      details.transform_values { |v| v[:version] }
    end

    def plugin_for_class(klass)
      klass = klass.to_s unless klass.kind_of?(String)

      klass_path, _klass_line = Object.const_source_location(klass)
      return unless klass_path

      paths.detect { |_engine, path| klass_path.start_with?(path) }&.first
    end

    # Ansible content (roles) that come out-of-the-box, for use by both Automate
    #   and ansible-runner
    def ansible_content
      @ansible_content ||= begin
        require_relative 'plugins/ansible_content'
        flat_map do |engine|
          content_directories(engine, "ansible").map { |dir| AnsibleContent.new(dir) }
        end
      end
    end

    # Ansible content (playbooks and roles) for internal use by provider plugins,
    #   not exposed to Automate, and to be run by ansible_runner
    def ansible_runner_content
      @ansible_runner_content ||= filter_map do |engine|
        content_dir = engine.root.join("content", "ansible_runner")
        next unless File.exist?(content_dir.join("roles/requirements.yml"))

        [engine, content_dir]
      end
    end

    def embedded_workflows_content
      @embedded_workflows_content ||= index_with do |engine|
        engine.root.join("content", "workflows").glob("**/*.asl")
      end
    end

    def automate_domains
      @automate_domains ||= begin
        require_relative 'plugins/automate_domain'
        flat_map do |engine|
          content_directories(engine, "automate").map { |dir| AutomateDomain.new(dir) }
        end
      end
    end

    def automation_runner_classes
      @automation_runner_classes ||= flat_map { |engine| engine.try(:automation_runners) }.compact
    end

    def miq_widgets_content
      @miq_widgets_content ||= Rails.root.join("product/dashboard/widgets").glob("*").map(&:to_s) + flat_map { |engine| content_directories(engine, "dashboard/widgets") }
    end

    def provider_plugins
      @provider_plugins ||= select { |engine| engine.name.start_with?("ManageIQ::Providers::") }
    end

    def ui_plugins
      @ui_plugins ||= select { |engine| engine.root.join("package.json").exist? }
    end

    def asset_paths
      @asset_paths ||= begin
        require_relative 'plugins/asset_path'
        filter_map { |engine| AssetPath.new(engine) if AssetPath.asset_path?(engine) }
      end
    end

    def server_role_paths
      @server_role_paths ||= filter_map do |engine|
        file = engine.root.join("config/server_roles.csv")
        file if file.exist?
      end
    end

    def systemd_units
      @systemd_units ||= flat_map { |engine| engine.root.join("systemd").glob("*.*") }
    end

    def load_inflections
      each do |engine|
        file = engine.root.join("config", "initializers", "inflections.rb")
        load file if file.exist?
      end
    end

    def init_loggers
      each do |engine|
        engine.try(:init_loggers)
      end
    end

    def register_models
      each do |engine|
        # make sure STI models are recognized
        DescendantLoader.instance.descendants_paths << engine.root.join('app')
      end
    end

    private

    # Determine the version of the specified engine
    #
    # If the gem is
    # - git based, pointing to a branch:   <branch>@<sha>
    # - git based, pointing to a tag:      <tag>@<sha>
    # - git based, pointing to a sha:      <sha>
    # - path based, with git, on a branch: <branch>@<sha>
    # - path based, with git, on a tag:    <tag>@<sha>
    # - path based, with git, on a sha:    <sha>
    # - path based, without git:           nil
    # - a real gem:                        <gem_version>
    #
    # The paths above can be real paths or symlinked paths.
    def version(engine)
      spec = bundler_specs_by_path[engine.root.realpath.to_s]

      case spec&.source
      when Bundler::Source::Git
        [
          spec.source.branch || spec.source.options["tag"],
          spec.source.revision.presence[0, 8]
        ].compact.join("@").presence
      when Bundler::Source::Path
        if engine.root.join(".git").exist?
          branch = sha = nil
          Dir.chdir(engine.root) do
            branch   = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip.presence
            branch   = nil if branch == "HEAD"
            branch ||= `git describe --tags --exact-match HEAD 2>/dev/null`.strip.presence

            sha = `git rev-parse HEAD 2>/dev/null`.strip[0, 8].presence
          end

          [branch, sha].compact.join("@").presence
        end
      when Bundler::Source::Rubygems
        spec.version
      end
    end

    def bundler_specs_by_path
      # NOTE: The rescue nil / delete nil dance is needed because of a bundler
      # bug where on Ruby 2.5 the full_gem_path is a nonexistent directory for
      # gems that are also default gems.
      # See https://github.com/bundler/bundler/issues/6930.
      @bundler_specs_by_path ||= Bundler.load.specs.index_by { |s| File.realpath(s.full_gem_path) rescue nil }.tap { |s| s.delete(nil) }
    end

    def content_directories(engine, subfolder)
      Dir.glob(engine.root.join("content", subfolder, "*"))
    end
  end
end
