# frozen_string_literal: true

require 'rails_helper'
require 'pathname'

RSpec.describe Runner::Strategy::DockerContainerPool do
  let(:runner_id) { attributes_for(:runner)[:runner_id] }
  let(:execution_environment) { create(:ruby) }
  let(:container_pool) { described_class.new(runner_id, execution_environment) }
  let(:docker_container_pool_url) { 'https://localhost:1234' }
  let(:config) { {url: docker_container_pool_url, unused_runner_expiration_time: 180} }
  let(:container) { instance_double(Docker::Container) }

  before do
    allow(described_class).to receive(:config).and_return(config)
    allow(container).to receive(:id).and_return(runner_id)
  end

  # All requests handle a Faraday error the same way.
  shared_examples 'Faraday error handling' do |http_verb|
    it 'raises a runner error' do
      allow(Faraday).to receive(http_verb).and_raise(Faraday::TimeoutError)
      expect { action.call }.to raise_error(Runner::Error::FaradayError)
    end
  end

  describe '::request_from_management' do
    let(:action) { -> { described_class.request_from_management(execution_environment) } }
    let(:response_body) { nil }
    let!(:request_runner_stub) do
      WebMock
        .stub_request(:post, "#{docker_container_pool_url}/docker_container_pool/get_container/#{execution_environment.id}")
        .to_return(body: response_body, status: 200)
    end

    context 'when the DockerContainerPool returns an id' do
      let(:response_body) { {id: runner_id}.to_json }

      it 'successfully requests the DockerContainerPool' do
        action.call
        expect(request_runner_stub).to have_been_requested.once
      end

      it 'returns the received runner id' do
        id = action.call
        expect(id).to eq(runner_id)
      end
    end

    context 'when the DockerContainerPool does not return an id' do
      let(:response_body) { {}.to_json }

      it 'raises an error' do
        expect { action.call }.to raise_error(Runner::Error::NotAvailable)
      end
    end

    context 'when the DockerContainerPool returns invalid JSON' do
      let(:response_body) { '{hello}' }

      it 'raises an error' do
        expect { action.call }.to raise_error(Runner::Error::UnexpectedResponse)
      end
    end

    it_behaves_like 'Faraday error handling', :post
  end

  describe '#destroy_at_management' do
    let(:action) { -> { container_pool.destroy_at_management } }
    let!(:destroy_runner_stub) do
      WebMock
        .stub_request(:delete, "#{docker_container_pool_url}/docker_container_pool/destroy_container/#{runner_id}")
        .to_return(body: nil, status: 200)
    end

    before { allow(container_pool).to receive(:container).and_return(container) }

    it 'successfully requests the DockerContainerPool' do
      action.call
      expect(destroy_runner_stub).to have_been_requested.once
    end

    it_behaves_like 'Faraday error handling', :delete
  end

  describe '#copy_files' do
    let(:files) { [] }
    let(:action) { -> { container_pool.copy_files(files) } }
    let(:local_path) { Pathname.new('/tmp/container20') }

    before do
      allow(container_pool).to receive(:local_workspace_path).and_return(local_path)
      allow(container_pool).to receive(:clean_workspace)
      allow(FileUtils).to receive(:chmod_R)
    end

    it 'creates the workspace directory' do
      expect(FileUtils).to receive(:mkdir_p).with(local_path)
      container_pool.copy_files(files)
    end

    it 'cleans the workspace' do
      expect(container_pool).to receive(:clean_workspace)
      container_pool.copy_files(files)
    end

    it 'sets permission bits on the workspace' do
      expect(FileUtils).to receive(:chmod_R).with('+rwtX', local_path)
      container_pool.copy_files(files)
    end

    context 'when receiving a normal file' do
      let(:file_content) { 'print("Hello World!")' }
      let(:files) { [build(:file, content: file_content)] }

      it 'writes the file to disk' do
        expect(File).to receive(:write).with(local_path.join(files.first.filepath), file_content)
        container_pool.copy_files(files)
      end

      it 'creates the file inside the workspace' do
        expect(File).to receive(:write).with(local_path.join(files.first.filepath), files.first.content)
        container_pool.copy_files(files)
      end

      it 'raises an error in case of an IOError' do
        allow(File).to receive(:write).and_raise(IOError)
        expect { container_pool.copy_files(files) }.to raise_error(Runner::Error::WorkspaceError, /#{files.first.filepath}/)
      end

      it 'does not create a directory for it' do
        expect(FileUtils).not_to receive(:mkdir_p)
      end

      context 'when the file is inside a directory' do
        let(:directory) { 'temp/dir' }
        let(:files) { [build(:file, path: directory)] }

        before do
          allow(File).to receive(:write)
          allow(FileUtils).to receive(:mkdir_p).with(local_path)
          allow(FileUtils).to receive(:mkdir_p).with(local_path.join(directory))
        end

        it 'cleans the directory path' do
          allow(container_pool).to receive(:local_path).and_call_original
          expect(container_pool).to receive(:local_path).with(directory).and_call_original
          container_pool.copy_files(files)
        end

        it 'creates the directory of the file' do
          expect(FileUtils).to receive(:mkdir_p).with(local_path.join(directory))
          container_pool.copy_files(files)
        end
      end
    end

    context 'when receiving a binary file' do
      let(:files) { [build(:file, :image)] }

      it 'copies the file inside the workspace' do
        expect(File).to receive(:write).with(local_path.join(files.first.filepath), files.first.read)
        container_pool.copy_files(files)
      end
    end

    context 'when receiving multiple files' do
      let(:files) { build_list(:file, 3) }

      it 'creates all files' do
        files.each do |file|
          expect(File).to receive(:write).with(local_path.join(file.filepath), file.content)
        end
        container_pool.copy_files(files)
      end
    end
  end

  describe '#local_workspace_path' do
    before { allow(container_pool).to receive(:container).and_return(container) }

    it 'returns the local part of the mount binding' do
      local_path = 'tmp/container20'
      allow(container).to receive(:json).and_return({HostConfig: {Binds: ["#{local_path}:/workspace"]}}.as_json)
      expect(container_pool.send(:local_workspace_path)).to eq(Pathname.new(local_path))
    end
  end

  describe '#local_path' do
    let(:local_workspace) { Pathname.new('/tmp/workspace') }

    before { allow(container_pool).to receive(:local_workspace_path).and_return(local_workspace) }

    it 'raises an error for relative paths outside of the workspace' do
      expect { container_pool.send(:local_path, '../exercise.py') }.to raise_error(Runner::Error::WorkspaceError, %r{tmp/exercise.py})
    end

    it 'raises an error for absolute paths outside of the workspace' do
      expect { container_pool.send(:local_path, '/test') }.to raise_error(Runner::Error::WorkspaceError, %r{/test})
    end

    it 'removes .. from the path' do
      expect(container_pool.send(:local_path, 'test/../exercise.py')).to eq(Pathname.new('/tmp/workspace/exercise.py'))
    end

    it 'joins the given path with the local workspace path' do
      expect(container_pool.send(:local_path, 'exercise.py')).to eq(Pathname.new('/tmp/workspace/exercise.py'))
    end
  end

  describe '#clean_workspace' do
    let(:local_workspace) { instance_double(Pathname) }

    before { allow(container_pool).to receive(:local_workspace_path).and_return(local_workspace) }

    it 'removes all children of the workspace recursively' do
      children = %w[test.py exercise.rb subfolder].map {|child| Pathname.new(child) }
      allow(local_workspace).to receive(:children).and_return(children)
      expect(FileUtils).to receive(:rm_r).with(children, force: true)
      container_pool.send(:clean_workspace)
    end

    it 'raises an error if the workspace does not exist' do
      allow(local_workspace).to receive(:children).and_raise(Errno::ENOENT)
      expect { container_pool.send(:clean_workspace) }.to raise_error(Runner::Error::WorkspaceError, /not exist/)
    end

    it 'raises an error if it lacks permission for deleting an entry' do
      allow(local_workspace).to receive(:children).and_return(['test.py'])
      allow(FileUtils).to receive(:remove_entry).and_raise(Errno::EPERM)
      expect { container_pool.send(:clean_workspace) }.to raise_error(Runner::Error::WorkspaceError, /Not allowed/)
    end
  end

  describe '#container' do
    it 'raises an error if there is no container for the saved id' do
      allow(Docker::Container).to receive(:get).and_raise(Docker::Error::NotFoundError)
      expect { container_pool.send(:container) }.to raise_error(Runner::Error::RunnerNotFound)
    end

    it 'raises an error if the received container is not running' do
      allow(Docker::Container).to receive(:get).and_return(container)
      allow(container).to receive(:info).and_return({'State' => {'Running' => false}})
      expect { container_pool.send(:container) }.to raise_error(Runner::Error::RunnerNotFound)
    end

    it 'returns the received container' do
      allow(Docker::Container).to receive(:get).and_return(container)
      allow(container).to receive(:info).and_return({'State' => {'Running' => true}})
      expect(container_pool.send(:container)).to eq(container)
    end

    it 'does not request a container if one is saved' do
      container_pool.instance_variable_set(:@container, container)
      expect(Docker::Container).not_to receive(:get)
      container_pool.send(:container)
    end
  end

  describe '#attach_to_execution' do
    # TODO: add tests here

    let(:command) { 'ls' }
    let(:event_loop) { Runner::EventLoop.new }
    let(:action) { -> { container_pool.attach_to_execution(command, event_loop) } }
    let(:websocket_url) { 'ws://ws.example.com/path/to/websocket' }
  end
end
