# frozen_string_literal: true

# rubocop:disable Lint/ConstantDefinitionInBlock

require 'spec_helper'

RSpec.describe Puppet::ResourceApi::BaseContext do
  class TestContext < described_class
    attr_reader :last_level, :last_message

    def send_log(log, msg)
      @last_level = log
      @last_message = msg
    end
  end

  subject(:context) do
    TestContext.new(definition)
  end

  let(:definition_hash) do
    {
      name: 'some_resource',
      desc: 'a test resource',
      attributes: {
        name: { type: 'String', desc: 'message' }
      },
      features: feature_support
    }
  end
  let(:definition) { Puppet::ResourceApi::TypeDefinition.new(definition_hash) }
  let(:feature_support) { [] }

  it { expect { described_class.new(nil) }.to raise_error ArgumentError, /BaseContext requires definition to be a child of Puppet::ResourceApi::BaseTypeDefinition/ }

  describe 'legacy hash definition support' do
    let(:definition) { definition_hash }

    it { expect { context }.not_to raise_error }
    it { expect(context.type.name).to eq 'some_resource' }
  end

  describe '#failed?' do
    it('defaults to false') { is_expected.not_to be_failed }
  end

  describe '#warning(msg)' do
    it 'outputs the message' do
      context.warning('message')
      expect(context.last_message).to eq 'some_resource: message'
    end

    it 'outputs at the correct level' do
      context.warning('message')
      expect(context.last_level).to eq :warning
    end
  end

  describe '#warning(titles, msg)' do
    it 'formats no titles correctly' do
      context.warning([], 'message')
      expect(context.last_message).to eq 'some_resource: message'
    end

    it 'formats an empty title correctly' do
      context.warning('', 'message')
      expect(context.last_message).to eq 'some_resource[]: message'
    end

    it 'formats a single title' do
      context.warning('a', 'message')
      expect(context.last_message).to eq 'some_resource[a]: message'
    end

    it 'formats multiple titles' do
      context.warning(%w[a b], 'message')
      expect(context.last_message).to eq 'some_resource[a, b]: message'
    end
  end

  describe '#warning(msg1, msg2, msg3, ...)' do
    it 'outputs all passed messages' do
      context.warning('msg1', 'msg2', 'msg3')
      expect(context.last_message).to eq 'msg1, msg2, msg3'
    end
  end

  %i[creating updating deleting].each do |method|
    describe "##{method}(title, &block)" do
      it 'outputs the start and stop messages' do
        allow(context).to receive(:send_log)
        expect(context).to receive(:send_log).with(:debug, /some_title.*#{method}.*start/i)
        expect(context).to receive(:send_log).with(:notice, /some_title.*#{method}.*finished/i)
        context.send(method, 'some_title') {}
      end

      it 'logs completion time' do
        allow(context).to receive(:send_log).with(:debug, anything)
        expect(context).to receive(:send_log).with(:notice, /finished in [0-9]*\.[0-9]* seconds/i)
        context.send(method, 'timed_resource') {}
      end

      it 'does not leak state between invocations' do
        context.send(method, 'resource_one') {}
        expect(context).to receive(:send_log).with(:debug, /resource_two.*#{method}.*start/i)
        expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
        context.send(method, 'resource_two') {}
      end

      context 'when a StandardError is raised' do
        it 'swallows the exception' do
          expect do
            context.send(method, 'bad_resource') { raise StandardError, 'Bad Resource!' }
          end.not_to raise_error
        end

        it 'logs an error' do
          allow(context).to receive(:send_log)
          expect(context).to receive(:send_log).with(:err, /bad_resource.*#{method}.*failed.*reasons/i)
          context.send(method, 'bad_resource') { raise StandardError, 'Reasons' }
        end

        it 'does not leak state into next invocation' do
          context.send(method, 'resource_one') { raise StandardError, 'Bad Resource!' }
          expect(context).to receive(:send_log).with(:debug, /resource_two.*#{method}.*start/i)
          expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
          context.send(method, 'resource_two') {}
        end

        it 'marks the context as failed' do
          context.send(method, 'resource_one') { raise StandardError, 'Bad Resource!' }
          expect(context).to be_failed
        end
      end

      context 'when an Exception that is not StandardError is raised' do
        it 'raises the exception' do
          expect do
            context.send(method, 'total_failure') { raise LoadError, 'Disk Read Error' }
          end.to raise_error(LoadError, 'Disk Read Error')
        end

        it 'does not leak state into next invocation' do
          expect do
            context.send(method, 'resource_one') { raise LoadError, 'Uh oh' }
          end.to raise_error(LoadError, 'Uh oh')
          expect(context).to receive(:send_log).with(:debug, /resource_two.*#{method}.*start/i)
          expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
          context.send(method, 'resource_two') {}
        end
      end
    end
  end

  describe '#failing(titles, &block)' do
    it 'logs a debug start message' do
      allow(context).to receive(:send_log)
      expect(context).to receive(:send_log).with(:debug, /\[Thing\[one\], Thing\[two\]\].*failing.*start/i)
      context.failing(['Thing[one]', 'Thing[two]']) {}
    end

    it 'logs a warning on completion' do
      allow(context).to receive(:send_log)
      expect(context).to receive(:send_log).with(:warning, /\[Thing\[one\], Thing\[two\]\].*failing.*finished/i)
      context.failing(['Thing[one]', 'Thing[two]']) {}
    end

    it 'logs completion time' do
      allow(context).to receive(:send_log)
      expect(context).to receive(:send_log).with(:warning, /finished failing in [0-9]*\.[0-9]* seconds/i)
      context.failing(['Thing[one]', 'Thing[two]']) {}
    end

    it 'does not leak state between invocations' do
      context.failing('resource_one') {}
      expect(context).to receive(:send_log).with(:debug, /resource_two.*failing.*start/i)
      expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
      context.failing('resource_two') {}
    end

    context 'when a StandardError is raised' do
      it 'swallows the exception' do
        expect do
          context.failing('bad_resource') { raise StandardError, 'Bad Resource!' }
        end.not_to raise_error
      end

      it 'logs an error' do
        allow(context).to receive(:send_log)
        expect(context).to receive(:send_log).with(:err, /some_resource.*failing.*failed.*reasons/i)
        context.failing('bad_resource') { raise StandardError, 'Reasons' }
      end

      it 'does not leak state into next invocation' do
        context.failing('resource_one') { raise StandardError, 'Bad Resource!' }
        expect(context).to receive(:send_log).with(:debug, /resource_two.*failing.*start/i)
        expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
        context.failing('resource_two') {}
      end
    end

    context 'when an Exception that is not StandardError is raised' do
      it 'raises the exception' do
        expect do
          context.failing('total_failure') { raise LoadError, 'Disk Read Error' }
        end.to raise_error(LoadError, 'Disk Read Error')
      end

      it 'does not leak state into next invocation' do
        expect do
          context.failing('resource_one') { raise LoadError, 'Uh oh' }
        end.to raise_error(LoadError, 'Uh oh')
        expect(context).to receive(:send_log).with(:debug, /resource_two.*failing.*start/i)
        expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
        context.failing('resource_two') {}
      end
    end
  end

  %i[created updated deleted].each do |method|
    describe "##{method}(titles, message: '#{method.to_s.capitalize}')" do
      it 'logs the action at :notice level' do
        expect(context).to receive(:send_log).with(:notice, /#{method.to_s.capitalize}: \["Thing\[one\]", "Thing\[two\]"\]/i)
        context.send(method, ['Thing[one]', 'Thing[two]'])
      end

      it 'logs a custom message if provided' do
        expect(context).to receive(:send_log).with(:notice, /My provider did the action: \["Thing\[one\]", "Thing\[two\]"\]/i)
        context.send(method, ['Thing[one]', 'Thing[two]'], message: 'My provider did the action')
      end
    end
  end

  describe '#failed(titles, message: \'Failed\')' do
    it 'logs the action at :err level' do
      expect(context).to receive(:send_log).with(:err, /\[Thing\[one\], Thing\[two\]\].*failed/)
      context.failed(['Thing[one]', 'Thing[two]'])
    end

    it 'logs a custom message if provided' do
      expect(context).to receive(:send_log).with(:err, /\[Thing\[one\], Thing\[two\]\].*My provider is really sorry/)
      context.failed(['Thing[one]', 'Thing[two]'], message: 'My provider is really sorry')
    end
  end

  describe '#processed(title, is, should)' do
    it 'logs the successful change of attributes' do
      expect(context).to receive(:send_log).with(:notice, /Processed Thing\[one\] from {:ensure=>"absent"} to {:ensure=>"present", :name=>"thing one"}/)
      context.processed('Thing[one]', { ensure: 'absent' }, { ensure: 'present', name: 'thing one' })
    end

    it 'raises if multiple titles are passed' do
      expect { context.processed(['Thing[one]', 'Thing[two'], { foo: 'bar' }, { foo: 'baz' })  }.to raise_error('processed only accepts a single resource title')
    end
  end

  describe '#processing(title, is, should, message: \'Processing\', &block)' do
    it 'raises if multiple titles are passed' do
      expect { context.processing(['Thing[one]', 'Thing[two'], { foo: 'bar' }, { foo: 'baz' }) { puts 'Doing it' } }.to raise_error('processing only accepts a single resource title')
    end

    it 'logs the start message' do
      allow(context).to receive(:send_log)
      expect(context).to receive(:send_log).with(:debug, /starting processing of.*some_title.*/i)
      context.processing('some_title', { ensure: 'absent' }, { name: 'some_title', ensure: 'present' }) {}
    end

    it 'logs a succesful change' do
      allow(context).to receive(:send_log)
      expect(context).to receive(:send_log).with(:notice, /Finished processing some_title in .* seconds: \{:name=>"some_title", :ensure=>"present"\}/i)
      context.processing('some_title', { ensure: 'absent' }, { name: 'some_title', ensure: 'present' }) {}
    end

    it 'logs completion time' do
      allow(context).to receive(:send_log).with(:debug, anything)
      expect(context).to receive(:send_log).with(:notice, /finished processing some_title in [0-9]*\.[0-9]* seconds/i)
      context.processing('some_title', { ensure: 'absent' }, ensure: 'present') {}
    end

    it 'logs failure if the block raises an exception' do
      allow(context).to receive(:send_log).with(:debug, anything)
      expect(context).to receive(:send_log).with(:err, /failed processing some_title after [0-9]*\.[0-9]* seconds: salt the fries/i)
      expect { context.processing('some_title', { ensure: 'absent' }, ensure: 'present') { raise StandardError, 'salt the fries' } }.not_to raise_error
    end

    it 'does not leak state between invocations' do
      context.processing('resource_one', { ensure: 'absent' }, ensure: 'present') {}
      expect(context).to receive(:send_log).with(:debug, /processing of resource_two/i)
      expect(context).not_to receive(:send_log).with(anything, /.*resource_one.*/)
      context.processing('resource_two', { ensure: 'absent' }, ensure: 'present') {}
    end
  end

  describe 'attribute_changed(title, attribute, is, should, message: nil)' do
    it 'logs string values with quotes' do
      expect(context).to receive(:send_log).with(:notice, %r{Thing\[foo\]: attribute 'config_file' changed from '/tmp/foo.cfg' to '/tmp/bar.cfg'}i)
      context.attribute_changed('Thing[foo]', 'config_file', '/tmp/foo.cfg', '/tmp/bar.cfg')
    end

    it 'logs number values without quotes' do
      expect(context).to receive(:send_log).with(:notice, /attribute 'height' changed from 5 to 6$/i)
      context.attribute_changed('Thing[foo]', 'height', 5, 6)
    end

    it 'logs a nil should value as \'nil\'' do
      expect(context).to receive(:send_log).with(:notice, /attribute 'height' changed from 5 to nil$/i)
      context.attribute_changed('Thing[foo]', 'height', 5, nil)
    end

    it 'logs a nil is value as \'nil\'' do
      expect(context).to receive(:send_log).with(:notice, /attribute 'height' changed from nil to 6$/i)
      context.attribute_changed('Thing[foo]', 'height', nil, 6)
    end

    it 'logs with a message if one is passed' do
      expect(context).to receive(:send_log).with(:notice, /attribute 'height' changed from 5 to 6: something interesting$/i)
      context.attribute_changed('Thing[foo]', 'height', 5, 6, message: 'something interesting')
    end

    it 'raises if multiple titles are passed' do
      expect { context.attribute_changed(['Thing[one]', 'Thing[two]'], 'room', 'clean', 'messy') }.to raise_error('attribute_changed only accepts a single resource title')
    end
  end

  describe '#format_seconds' do
    it 'returns 6 decimal points for a number less than 1' do
      expect(described_class.new(definition).send(:format_seconds, 0.000136696)).to eq('0.000137')
    end

    it 'returns 2 decimal places for a number greater than 1' do
      expect(described_class.new(definition).send(:format_seconds, 123.45678)).to eq('123.46')
      expect(described_class.new(definition).send(:format_seconds, 1_234_567.89)).to eq('1234567.89')
      expect(described_class.new(definition).send(:format_seconds, 123_456)).to eq('123456.00')
    end
  end

  describe '#log_exception(exception, message:, trace:)' do
    let(:exception) do
      ex = ArgumentError.new('x')
      ex.set_backtrace %w[a b c]
      ex
    end

    context 'without tracing' do
      it 'creates a log message' do
        context.log_exception(exception, message: 'message', trace: false)
        expect(context.last_message).to eq 'some_resource: message: x'
      end
    end

    context 'with tracing' do
      it 'creates a log message containing the backtrace' do
        context.log_exception(exception, message: 'message', trace: true)
        expect(context.last_message).to eq "some_resource: message: x\na\nb\nc"
      end
    end
  end

  describe '#device' do
    it { expect { described_class.new(definition).device }.to raise_error RuntimeError, /Received device\(\) on an unprepared BaseContext\. Use a PuppetContext instead/ }
  end

  describe '#transport' do
    it { expect { described_class.new(definition).transport }.to raise_error RuntimeError, /No transport available\./ }
  end

  describe '#send_log' do
    it { expect { described_class.new(definition).send_log(nil, nil) }.to raise_error RuntimeError, /Received send_log\(\) on an unprepared BaseContext\. Use IOContext, or PuppetContext instead/ }
  end

  describe '#feature_support?' do
    it {
      expect(Puppet::Util::Log).to receive(:create).with({ level: :warning, source: 'Puppet', message: start_with('context.feature_support? is deprecated. Please use context.type.feature? instead.') }) # rubocop:disable Layout/LineLength
      context.feature_support?('anything')
    }
  end

  describe '#reset_failed' do
    it 'resets the failure state' do
      context.failing('bad_resource') { raise StandardError, 'Bad Resource!' }
      expect(context).to be_failed
      context.reset_failed
      expect(context).not_to be_failed
    end
  end
end

# rubocop:enable Lint/ConstantDefinitionInBlock
