require "pathname"
require "json"
require "hashie/extensions/method_access"

require "rspec"
require "rspec/core/shared_context"
require "chef-vault"

require "chef-vault/test_fixtures_version"

# chef-vault helps manage encrypted data bags using a node's public key
class ChefVault
  # dynamic RSpec contexts for cookbooks that use chef-vault
  class TestFixtures
    # dynamically creates a memoized RSpec shared context
    # that when included into an example group will stub
    # ChefVault::Item for each of the defined vaults. The
    # context is memoized and only created once
    # @return [Module] the RSpec shared context
    class << self
      # rubocop:disable Metrics/AbcSize, Metrics/MethodLength

      # created a shared RSpec context that stubs calls to ChefVault::Item.load
      # @param stub_encrypted_data [Boolean] whether to also stub calls to
      #   Chef::DataBagItem.load
      # @return [Module] a shared context to include in your example groups
      def rspec_shared_context(stub_encrypted_data = false)
        @context ||= begin
          Module.new do
            extend RSpec::Core::SharedContext

            before { find_vaults(stub_encrypted_data) }

            private

            # finds all the directories in test/integration/data_bags, stubbing
            # each as a vault
            # @param stub_encrypted_data [Boolean] whether to also stub calls to
            #   Chef::DataBagItem.load
            # return [void]
            # @api private
            def find_vaults(stub_encrypted_data)
              dbdir = Pathname.new("test") + "integration" + "data_bags"
              smokedir = Pathname.new("test") + "smoke" + "default" + "data_bags"
              [ dbdir, smokedir ].each do |dir|
                next unless dir.directory?
                dir.each_child do |vault|
                  next unless vault.directory?
                  stub_vault(stub_encrypted_data, vault)
                end
              end
            end

            # stubs a vault with the contents of JSON files in a directory.
            # Finds all files in the vault path ending in .json and stubs
            # each as a vault item.
            # @param stub_encrypted_data [Boolean] whether to also stub calls to
            #   Chef::DataBagItem.load
            # @param vault [Pathname] the path to the directory that will be
            #   stubbed as a vault
            # @return [void]
            # @api private
            def stub_vault(stub_encrypted_data, vault)
              db = {}
              vault.each_child do |e|
                next unless e.file?
                m = e.basename.to_s.downcase.match(/(.+)\.json/i)
                next unless m
                content = JSON.parse(e.read)
                vaultname = vault.basename.to_s
                stub_vault_item(vaultname, m[1], content, db)
                if stub_encrypted_data
                  stub_vault_item_encrypted_data(vaultname, m[1], content)
                end
              end
            end

            # stubs a vault item with the contents of a parsed JSON string.
            # If the class-level setting `encrypted_data_stub` is true, then
            # Chef::DataBagItem.load
            # @param vault [String] the name of the vault data bag
            # @param item [String] the name of the vault item
            # @param content [String] the JSON-encoded contents to populate the
            #   fake vault with
            # @param db [Hash] the fake data bag item that contains the item
            # @return [void]
            # @api private
            def stub_vault_item(vault, item, content, db)
              db["#{item}_keys"] = true
              vi = make_fakevault(vault, item)

              # stub vault lookup of each of the vault item keys
              content.each do |k, v|
                next if "id" == k
                allow(vi).to receive(:[]).with(k).and_return(v)
              end

              # stub hash conversion as a stopgap to other hash methods
              allow(vi).to receive(:to_h).with(no_args).and_return(content)
              allow(vi).to receive(:to_hash).with(no_args).and_return(content)

              # stub ChefVault and Chef::DataBag to return the doubles
              # via both symbol and string forms of the data bag name
              [vault, vault.to_sym].each do |dbname|
                allow(ChefVault::Item).to(
                  receive(:vault?).with(dbname, item).and_return(true)
                )

                allow(ChefVault::Item).to(
                  receive(:load)
                  .with(dbname, item)
                  .and_return(vi)
                )
                allow(Chef::DataBag).to(
                  receive(:load)
                  .with(dbname)
                  .and_return(db)
                )
              end
            end

            # stubs Chef::DataBagItem.load to return a fake hash in which
            # each key of the content returns a hash with single
            # `encrypted_data` key, which fools some attempts to determine
            # whether a data bag is a vault
            # @param vault [String] the name of the vault data bag
            # @param item [String] the name of the vault item
            # @param content [String] the JSON-encoded contents to populate the
            #   fake vault with
            # @return [void]
            # @api private
            def stub_vault_item_encrypted_data(vault, item, content)
              # stub data bag lookup of each of the vault item keys
              dbi = ChefVault::TestFixtureDataBagItem.new
              dbi["raw_data"] = content
              content.each_key do |k|
                next if "id" == k
                dbi[k] = { "encrypted_data" => "..." }
              end

              [vault, vault.to_sym].each do |dbname|
                allow(Chef::DataBagItem).to(
                  receive(:load).with(dbname, item).and_return(dbi)
                )
              end
            end

            # returns an RSpec double that acts like a vault item
            # @param vault [String] the name of the vault data bag
            # @param item [String] the name of the vault item
            # @return [RSpec::Mocks::Double] the vault double
            # @api private
            def make_fakevault(vault, item)
              fakevault = double "vault item #{vault}/#{item}"
              allow(fakevault).to receive(:[]=).with(String, Object)
              allow(fakevault).to receive(:clients).with(String)
              allow(fakevault).to receive(:save)
              fakevault
            end
          end
        end
      end
      # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
    end
  end

  # a hash with method access to stand in for a Chef::DataBagItem
  class TestFixtureDataBagItem < Hash
    include Hashie::Extensions::MethodAccess
  end
end