const { expect } = require('chai')
const { FetchError, AbortError } = require('node-fetch')
const { Readable } = require('stream')
const { once } = require('events')
const { TestServer } = require('./helpers/TestServer')
const {
  fetchJson,
  fetchStream,
  fetchNothing,
  fetchString,
  RequestFailedError,
} = require('../..')

const PORT = 30001

describe('fetch-utils', function () {
  before(async function () {
    this.server = new TestServer()
    await this.server.start(PORT)
    this.url = path => `http://localhost:${PORT}${path}`
  })

  after(async function () {
    await this.server.stop()
  })

  describe('fetchJson', function () {
    it('parses a JSON response', async function () {
      const json = await fetchJson(this.url('/json/hello'))
      expect(json).to.deep.equal({ msg: 'hello' })
    })

    it('parses JSON in the request', async function () {
      const json = await fetchJson(this.url('/json/add'), {
        method: 'POST',
        json: { a: 2, b: 3 },
      })
      expect(json).to.deep.equal({ sum: 5 })
    })

    it('accepts stringified JSON as body', async function () {
      const json = await fetchJson(this.url('/json/add'), {
        method: 'POST',
        body: JSON.stringify({ a: 2, b: 3 }),
        headers: { 'Content-Type': 'application/json' },
      })
      expect(json).to.deep.equal({ sum: 5 })
    })

    it('throws a FetchError when the payload is not JSON', async function () {
      await expect(fetchJson(this.url('/hello'))).to.be.rejectedWith(FetchError)
    })

    it('aborts the request if JSON parsing fails', async function () {
      await expect(fetchJson(this.url('/large'))).to.be.rejectedWith(FetchError)
      await expectRequestAborted(this.server.lastReq)
    })

    it('handles errors when the payload is JSON', async function () {
      await expect(fetchJson(this.url('/json/500'))).to.be.rejectedWith(
        RequestFailedError
      )
      await expectRequestAborted(this.server.lastReq)
    })

    it('handles errors when the payload is not JSON', async function () {
      await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
        RequestFailedError
      )
      await expectRequestAborted(this.server.lastReq)
    })

    it('supports abort signals', async function () {
      await expect(
        fetchJson(this.url('/hang'), { signal: AbortSignal.timeout(10) })
      ).to.be.rejectedWith(AbortError)
      await expectRequestAborted(this.server.lastReq)
    })

    it('supports basic auth', async function () {
      const json = await fetchJson(this.url('/json/basic-auth'), {
        basicAuth: { user: 'user', password: 'pass' },
      })
      expect(json).to.deep.equal({ key: 'verysecret' })
    })

    it("destroys the request body if it doesn't get consumed", async function () {
      const stream = Readable.from(infiniteIterator())
      await fetchJson(this.url('/json/ignore-request'), {
        method: 'POST',
        body: stream,
      })
      expect(stream.destroyed).to.be.true
    })
  })

  describe('fetchStream', function () {
    it('returns a stream', async function () {
      const stream = await fetchStream(this.url('/large'))
      const text = await streamToString(stream)
      expect(text).to.equal(this.server.largePayload)
    })

    it('aborts the request when the stream is destroyed', async function () {
      const stream = await fetchStream(this.url('/large'))
      stream.destroy()
      await expectRequestAborted(this.server.lastReq)
    })

    it('aborts the request when the request body is destroyed', async function () {
      const stream = Readable.from(infiniteIterator())
      const promise = fetchStream(this.url('/hang'), {
        method: 'POST',
        body: stream,
      })
      stream.destroy()
      await expect(promise).to.be.rejectedWith(AbortError)
      await expectRequestAborted(this.server.lastReq)
    })

    it('handles errors', async function () {
      await expect(fetchStream(this.url('/500'))).to.be.rejectedWith(
        RequestFailedError
      )
      await expectRequestAborted(this.server.lastReq)
    })

    it('supports abort signals', async function () {
      await expect(
        fetchStream(this.url('/hang'), { signal: AbortSignal.timeout(10) })
      ).to.be.rejectedWith(AbortError)
      await expectRequestAborted(this.server.lastReq)
    })

    it('destroys the request body when an error occurs', async function () {
      const stream = Readable.from(infiniteIterator())
      await expect(
        fetchStream(this.url('/hang'), {
          body: stream,
          signal: AbortSignal.timeout(10),
        })
      ).to.be.rejectedWith(AbortError)
      expect(stream.destroyed).to.be.true
    })
  })

  describe('fetchNothing', function () {
    it('closes the connection', async function () {
      await fetchNothing(this.url('/large'))
      await expectRequestAborted(this.server.lastReq)
    })

    it('aborts the request when the request body is destroyed', async function () {
      const stream = Readable.from(infiniteIterator())
      const promise = fetchNothing(this.url('/hang'), {
        method: 'POST',
        body: stream,
      })
      stream.destroy()
      await expect(promise).to.be.rejectedWith(AbortError)
      await expectRequestAborted(this.server.lastReq)
    })

    it("doesn't abort the request if the request body ends normally", async function () {
      const stream = Readable.from('hello there')
      await fetchNothing(this.url('/sink'), { method: 'POST', body: stream })
    })

    it('handles errors', async function () {
      await expect(fetchNothing(this.url('/500'))).to.be.rejectedWith(
        RequestFailedError
      )
      await expectRequestAborted(this.server.lastReq)
    })

    it('supports abort signals', async function () {
      await expect(
        fetchNothing(this.url('/hang'), { signal: AbortSignal.timeout(10) })
      ).to.be.rejectedWith(AbortError)
      await expectRequestAborted(this.server.lastReq)
    })

    it('destroys the request body when an error occurs', async function () {
      const stream = Readable.from(infiniteIterator())
      await expect(
        fetchNothing(this.url('/hang'), {
          body: stream,
          signal: AbortSignal.timeout(10),
        })
      ).to.be.rejectedWith(AbortError)
      expect(stream.destroyed).to.be.true
    })
  })

  describe('fetchString', function () {
    it('returns a string', async function () {
      const body = await fetchString(this.url('/hello'))
      expect(body).to.equal('hello')
    })

    it('handles errors', async function () {
      await expect(fetchJson(this.url('/500'))).to.be.rejectedWith(
        RequestFailedError
      )
      await expectRequestAborted(this.server.lastReq)
    })
  })
})

async function streamToString(stream) {
  let s = ''
  for await (const chunk of stream) {
    s += chunk
  }
  return s
}

async function* infiniteIterator() {
  let i = 1
  while (true) {
    yield `chunk ${i++}\n`
  }
}

async function expectRequestAborted(req) {
  if (!req.destroyed) {
    await once(req, 'close')
    expect(req.destroyed).to.be.true
  }
}
