'use strict'
/* eslint-env mocha */

const { expect } = require('aegir/utils/chai')
const sinon = require('sinon')

const { AbortError } = require('libp2p-interfaces/src/transport/errors')
const AggregateError = require('aggregate-error')
const pDefer = require('p-defer')
const delay = require('delay')

const DialRequest = require('../../src/dialer/dial-request')
const createMockConnection = require('../utils/mockConnection')
const error = new Error('dial failes')

describe('Dial Request', () => {
  it('should end when a single multiaddr dials succeeds', async () => {
    const mockConnection = await createMockConnection()
    const actions = {
      1: () => Promise.reject(error),
      2: () => Promise.resolve(mockConnection),
      3: () => Promise.reject(error)
    }
    const dialAction = (num) => actions[num]()
    const tokens = ['a', 'b']
    const controller = new AbortController()
    const dialer = {
      getTokens: () => [...tokens],
      releaseToken: () => {}
    }

    const dialRequest = new DialRequest({
      addrs: Object.keys(actions),
      dialer,
      dialAction
    })

    sinon.spy(actions, 1)
    sinon.spy(actions, 2)
    sinon.spy(actions, 3)
    sinon.spy(dialer, 'releaseToken')
    const result = await dialRequest.run({ signal: controller.signal })
    expect(result).to.equal(mockConnection)
    expect(actions[1]).to.have.property('callCount', 1)
    expect(actions[2]).to.have.property('callCount', 1)
    expect(actions[3]).to.have.property('callCount', 0)
    expect(dialer.releaseToken).to.have.property('callCount', tokens.length)
  })

  it('should release tokens when all addr dials have started', async () => {
    const mockConnection = await createMockConnection()
    const firstDials = pDefer()
    const deferred = pDefer()
    const actions = {
      1: () => firstDials.promise,
      2: () => firstDials.promise,
      3: () => deferred.promise
    }
    const dialAction = (num) => actions[num]()
    const tokens = ['a', 'b']
    const controller = new AbortController()
    const dialer = {
      getTokens: () => [...tokens],
      releaseToken: () => {}
    }

    const dialRequest = new DialRequest({
      addrs: Object.keys(actions),
      dialer,
      dialAction
    })

    sinon.spy(actions, 1)
    sinon.spy(actions, 2)
    sinon.spy(actions, 3)
    sinon.spy(dialer, 'releaseToken')
    dialRequest.run({ signal: controller.signal })
    // Let the first dials run
    await delay(0)

    // Finish the first 2 dials
    firstDials.reject(error)
    await delay(0)

    // Only 1 dial should remain, so 1 token should have been released
    expect(actions[1]).to.have.property('callCount', 1)
    expect(actions[2]).to.have.property('callCount', 1)
    expect(actions[3]).to.have.property('callCount', 1)
    expect(dialer.releaseToken).to.have.property('callCount', 1)

    // Finish the dial and release the 2nd token
    deferred.resolve(mockConnection)
    await delay(0)
    expect(dialer.releaseToken).to.have.property('callCount', 2)
  })

  it('should throw an AggregateError if all dials fail', async () => {
    const actions = {
      1: () => Promise.reject(error),
      2: () => Promise.reject(error),
      3: () => Promise.reject(error)
    }
    const dialAction = (num) => actions[num]()
    const addrs = Object.keys(actions)
    const tokens = ['a', 'b']
    const controller = new AbortController()
    const dialer = {
      getTokens: () => [...tokens],
      releaseToken: () => {}
    }

    const dialRequest = new DialRequest({
      addrs,
      dialer,
      dialAction
    })

    sinon.spy(actions, 1)
    sinon.spy(actions, 2)
    sinon.spy(actions, 3)
    sinon.spy(dialer, 'getTokens')
    sinon.spy(dialer, 'releaseToken')

    try {
      await dialRequest.run({ signal: controller.signal })
      expect.fail('Should have thrown')
    } catch (/** @type {any} */ err) {
      expect(err).to.be.an.instanceof(AggregateError)
    }

    expect(actions[1]).to.have.property('callCount', 1)
    expect(actions[2]).to.have.property('callCount', 1)
    expect(actions[3]).to.have.property('callCount', 1)
    expect(dialer.getTokens.calledWith(addrs.length)).to.equal(true)
    expect(dialer.releaseToken).to.have.property('callCount', tokens.length)
  })

  it('should handle a large number of addrs', async () => {
    const reject = sinon.stub().callsFake(() => Promise.reject(error))
    const actions = {}
    const addrs = [...new Array(25)].map((_, index) => index + 1)
    addrs.forEach(addr => {
      actions[addr] = reject
    })

    const dialAction = (addr) => actions[addr]()
    const tokens = ['a', 'b']
    const controller = new AbortController()
    const dialer = {
      getTokens: () => [...tokens],
      releaseToken: () => {}
    }

    const dialRequest = new DialRequest({
      addrs,
      dialer,
      dialAction
    })

    sinon.spy(dialer, 'releaseToken')
    try {
      await dialRequest.run({ signal: controller.signal })
      expect.fail('Should have thrown')
    } catch (/** @type {any} */ err) {
      expect(err).to.be.an.instanceof(AggregateError)
    }

    expect(reject).to.have.property('callCount', addrs.length)
    expect(dialer.releaseToken).to.have.property('callCount', tokens.length)
  })

  it('should abort all dials when its signal is aborted', async () => {
    const deferToAbort = ({ signal }) => {
      if (signal.aborted) throw new Error('already aborted')
      const deferred = pDefer()
      const onAbort = () => {
        deferred.reject(new AbortError())
        signal.removeEventListener('abort', onAbort)
      }
      signal.addEventListener('abort', onAbort)
      return deferred.promise
    }

    const actions = {
      1: deferToAbort,
      2: deferToAbort,
      3: deferToAbort
    }
    const dialAction = (num, opts) => actions[num](opts)
    const addrs = Object.keys(actions)
    const tokens = ['a', 'b']
    const controller = new AbortController()
    const dialer = {
      getTokens: () => [...tokens],
      releaseToken: () => {}
    }

    const dialRequest = new DialRequest({
      addrs,
      dialer,
      dialAction
    })

    sinon.spy(actions, 1)
    sinon.spy(actions, 2)
    sinon.spy(actions, 3)
    sinon.spy(dialer, 'getTokens')
    sinon.spy(dialer, 'releaseToken')

    try {
      setTimeout(() => controller.abort(), 100)
      await dialRequest.run({ signal: controller.signal })
      expect.fail('dial should have failed')
    } catch (/** @type {any} */ err) {
      expect(err).to.be.an.instanceof(AggregateError)
    }

    expect(actions[1]).to.have.property('callCount', 1)
    expect(actions[2]).to.have.property('callCount', 1)
    expect(actions[3]).to.have.property('callCount', 1)
    expect(dialer.getTokens.calledWith(addrs.length)).to.equal(true)
    expect(dialer.releaseToken).to.have.property('callCount', tokens.length)
  })
})
