import Bluebird from 'bluebird'
import chai from 'chai'
import http from 'http'
import https from 'https'
import net from 'net'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
import tls from 'tls'
import url from 'url'
import DebuggingProxy from '@cypress/debugging-proxy'
import request from '@cypress/request-promise'
import * as socketIo from '@packages/socket'
import {
  buildConnectReqHead,
  createProxySock,
  isRequestHttps,
  isResponseStatusCode200,
  regenerateRequestHead,
  CombinedAgent,
  clientCertificateStore,
} from '../../lib/agent'
import { allowDestroy } from '../../lib/allow-destroy'
import { AsyncServer, Servers } from '../support/servers'
import { UrlClientCertificates, ClientCertificates, PemKey } from '../../lib/client-certificates'
import Forge from 'node-forge'
const { pki } = Forge

const expect = chai.expect

chai.use(sinonChai)

const PROXY_PORT = 31000
const HTTP_PORT = 31080
const HTTPS_PORT = 31443

function createCertAndKey (): [object, object] {
  let keys = pki.rsa.generateKeyPair(2048)
  let cert = pki.createCertificate()

  cert.publicKey = keys.publicKey
  cert.serialNumber = '01'
  cert.validity.notBefore = new Date()
  cert.validity.notAfter = new Date()
  cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1)

  let attrs = [
    {
      name: 'commonName',
      value: 'example.org',
    },
    {
      name: 'countryName',
      value: 'US',
    },
    {
      shortName: 'ST',
      value: 'California',
    },
    {
      name: 'localityName',
      value: 'San Fran',
    },
    {
      name: 'organizationName',
      value: 'Test',
    },
    {
      shortName: 'OU',
      value: 'Test',
    },
  ]

  cert.setSubject(attrs)
  cert.setIssuer(attrs)
  cert.sign(keys.privateKey)

  return [cert, keys.privateKey]
}

describe('lib/agent', function () {
  beforeEach(function () {
    process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
  })

  afterEach(function () {
    process.env.NO_PROXY = process.env.HTTP_PROXY = process.env.HTTPS_PROXY = ''
    sinon.restore()
  })

  context('CombinedAgent', function () {
    before(function () {
      this.servers = new Servers()

      return this.servers.start(HTTP_PORT, HTTPS_PORT)
    })

    after(function () {
      return this.servers.stop()
    })

    ;[
      {
        name: 'with no upstream',
      },
      {
        name: 'with an HTTP upstream',
        proxyUrl: `http://localhost:${PROXY_PORT}`,
      },
      {
        name: 'with an HTTPS upstream',
        proxyUrl: `https://localhost:${PROXY_PORT}`,
        httpsProxy: true,
      },
      {
        name: 'with an HTTP upstream requiring auth',
        proxyUrl: `http://foo:bar@localhost:${PROXY_PORT}`,
        proxyAuth: true,
      },
      {
        name: 'with an HTTPS upstream requiring auth',
        proxyUrl: `https://foo:bar@localhost:${PROXY_PORT}`,
        httpsProxy: true,
        proxyAuth: true,
      },
    ].slice().map((testCase) => {
      context(testCase.name, function () {
        beforeEach(function () {
          if (testCase.proxyUrl) {
            // PROXY vars should override npm_config vars, so set them to cause failures if they are used
            // @see https://github.com/cypress-io/cypress/pull/8295
            process.env.npm_config_proxy = process.env.npm_config_https_proxy = 'http://erroneously-used-npm-proxy.invalid'
            process.env.npm_config_noproxy = 'just,some,nonsense'

            process.env.HTTP_PROXY = process.env.HTTPS_PROXY = testCase.proxyUrl
            process.env.NO_PROXY = ''
          }

          this.agent = new CombinedAgent()

          this.request = request.defaults({
            proxy: null,
            agent: this.agent,
          })

          if (testCase.proxyUrl) {
            let options: any = {
              keepRequests: true,
              https: false,
              auth: false,
            }

            if (testCase.httpsProxy) {
              options.https = this.servers.https
            }

            if (testCase.proxyAuth) {
              options.auth = {
                username: 'foo',
                password: 'bar',
              }
            }

            this.debugProxy = new DebuggingProxy(options)

            return this.debugProxy.start(PROXY_PORT)
          }
        })

        afterEach(function () {
          if (testCase.proxyUrl) {
            this.debugProxy.stop()
          }
        })

        it('HTTP pages can be loaded', function () {
          return this.request({
            url: `http://localhost:${HTTP_PORT}/get`,
          }).then((body) => {
            expect(body).to.eq('It worked!')
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0]).to.include({
                url: `http://localhost:${HTTP_PORT}/get`,
              })
            }
          })
        })

        it('HTTPS pages can be loaded', function () {
          return this.request({
            url: `https://localhost:${HTTPS_PORT}/get`,
          }).then((body) => {
            expect(body).to.eq('It worked!')
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0]).to.include({
                https: true,
                url: `localhost:${HTTPS_PORT}`,
              })
            }
          })
        })

        it('HTTP errors are catchable', function () {
          return this.request({
            url: `http://localhost:${HTTP_PORT}/empty-response`,
          })
          .then(() => {
            throw new Error('Shouldn\'t reach this')
          })
          .catch((err) => {
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0]).to.include({
                url: `http://localhost:${HTTP_PORT}/empty-response`,
              })

              expect(err.statusCode).to.eq(502)
            } else {
              expect(err.message).to.eq('Error: socket hang up')
            }
          })
        })

        it('HTTPS errors are catchable', function () {
          return this.request({
            url: `https://localhost:${HTTPS_PORT}/empty-response`,
          })
          .then(() => {
            throw new Error('Shouldn\'t reach this')
          })
          .catch((err) => {
            expect(err.message).to.eq('Error: socket hang up')
          })
        })

        it('HTTP websocket connections can be established and used', function () {
          const socket = socketIo.client(`http://localhost:${HTTP_PORT}`, {
            agent: this.agent,
            transports: ['websocket'],
            rejectUnauthorized: false,
          })

          return new Bluebird((resolve) => {
            socket.on('message', resolve)
          })
          .then((msg) => {
            expect(msg).to.eq('It worked!')
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0].ws).to.be.true
              expect(this.debugProxy.requests[0].url).to.include('http://localhost:31080')
            }

            socket.close()
          })
        })

        it('HTTPS websocket connections can be established and used', function () {
          const socket = socketIo.client(`https://localhost:${HTTPS_PORT}`, {
            agent: this.agent,
            transports: ['websocket'],
            rejectUnauthorized: false,
          })

          return new Bluebird((resolve) => {
            socket.on('message', resolve)
          })
          .then((msg) => {
            expect(msg).to.eq('It worked!')
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0]).to.include({
                url: 'localhost:31443',
              })
            }

            socket.close()
          })
        })

        // https://github.com/cypress-io/cypress/issues/5729
        it('does not warn when making a request to an IP address', function () {
          const warningStub = sinon.spy(process, 'emitWarning')

          return this.request({
            url: `https://127.0.0.1:${HTTPS_PORT}/get`,
          })
          .then(() => {
            expect(warningStub).to.not.be.called
          })
        })
      })
    })

    context('HttpsAgent', function () {
      beforeEach(function () {
        this.agent = new CombinedAgent()

        this.request = request.defaults({
          agent: this.agent as any,
          proxy: null,
        })
      })

      it('#createUpstreamProxyConnection does not go to proxy if domain in NO_PROXY', function () {
        const spy = sinon.spy(this.agent.httpsAgent, 'createUpstreamProxyConnection')

        process.env.HTTP_PROXY = process.env.HTTPS_PROXY = 'http://0.0.0.0:0'
        process.env.NO_PROXY = 'mtgox.info,example.com,homestarrunner.com,'

        return this.request({
          url: 'https://example.com/',
        })
        .then(() => {
          expect(spy).to.not.be.called

          return this.request({
            url: 'https://example.org/',
          })
          .then(() => {
            throw new Error('should not be able to connect')
          })
          .catch({ message: 'Error: A connection to the upstream proxy could not be established: connect ECONNREFUSED 0.0.0.0' }, () => {
            expect(spy).to.be.calledOnce
          })
        })
      })

      it('#createUpstreamProxyConnection calls to super for caching, TLS-ifying', function () {
        const spy = sinon.spy(https.Agent.prototype, 'createConnection')

        const proxy = new DebuggingProxy()
        const proxyPort = PROXY_PORT + 1

        process.env.HTTP_PROXY = process.env.HTTPS_PROXY = `http://localhost:${proxyPort}`
        process.env.NO_PROXY = ''

        return proxy.start(proxyPort)
        .then(() => {
          return this.request({
            url: `https://localhost:${HTTPS_PORT}/get`,
          })
        })
        .then(() => {
          const options = spy.getCall(0).args[0]
          const session = this.agent.httpsAgent._sessionCache.map[options._agentKey]

          expect(spy).to.be.calledOnce
          expect(this.agent.httpsAgent._sessionCache.list).to.have.length(1)
          expect(session).to.not.be.undefined

          return proxy.stop()
        })
      })

      it('#createUpstreamProxyConnection throws when connection is accepted then closed', function () {
        const proxy = Bluebird.promisifyAll(
          allowDestroy(
            net.createServer((socket) => {
              socket.end()
            }),
          ),
        ) as net.Server & AsyncServer

        const proxyPort = PROXY_PORT + 2

        process.env.HTTP_PROXY = process.env.HTTPS_PROXY = `http://localhost:${proxyPort}`
        process.env.NO_PROXY = ''

        return proxy.listenAsync(proxyPort)
        .then(() => {
          return this.request({
            url: `https://localhost:${HTTPS_PORT}/get`,
          })
        })
        .then(() => {
          throw new Error('should not succeed')
        })
        .catch((e) => {
          expect(e.message).to.eq('Error: A connection to the upstream proxy could not be established: ERR_EMPTY_RESPONSE: The upstream proxy closed the socket after connecting but before sending a response.')

          return proxy.destroyAsync()
        })
      })
    })

    context('HttpAgent', function () {
      beforeEach(function () {
        this.agent = new CombinedAgent()

        this.request = request.defaults({
          agent: this.agent as any,
          proxy: null,
        })
      })

      it('#addRequest does not go to proxy if domain in NO_PROXY', function () {
        const spy = sinon.spy(this.agent.httpAgent, '_addProxiedRequest')

        process.env.HTTP_PROXY = process.env.HTTPS_PROXY = 'http://0.0.0.0:0'
        process.env.NO_PROXY = 'mtgox.info,example.com,homestarrunner.com,'

        return this.request({
          url: 'http://example.com/',
        })
        .then(() => {
          expect(spy).to.not.be.called

          return this.request({
            url: 'http://example.org/',
          })
          .then(() => {
            throw new Error('should not be able to connect')
          })
          .catch({ message: 'Error: connect ECONNREFUSED 0.0.0.0' }, () => {
            expect(spy).to.be.calledOnce
          })
        })
      })
    })
  })

  context('CombinedAgent with client certificates', function () {
    const proxyUrl = `https://localhost:${PROXY_PORT}`

    before(function () {
      this.servers = new Servers()

      return this.servers.start(HTTP_PORT, HTTPS_PORT)
    })

    after(function () {
      return this.servers.stop()
    })

    ;[
      {
        name: 'should present a client certificate',
        peresentClientCertificate: true,
      },
      {
        name: 'should present not a client certificate',
        peresentClientCertificate: false,
      },
    ].slice().map((testCase) => {
      context(testCase.name, function () {
        beforeEach(function () {
          // PROXY vars should override npm_config vars, so set them to cause failures if they are used
          // @see https://github.com/cypress-io/cypress/pull/8295
          process.env.npm_config_proxy = process.env.npm_config_https_proxy = 'http://erroneously-used-npm-proxy.invalid'
          process.env.npm_config_noproxy = 'just,some,nonsense'

          process.env.HTTP_PROXY = process.env.HTTPS_PROXY = proxyUrl
          process.env.NO_PROXY = ''

          this.agent = new CombinedAgent()

          this.request = request.defaults({
            proxy: null,
            agent: this.agent,
          })

          let options: any = {
            keepRequests: true,
            https: this.servers.https,
            auth: false,
          }

          if (testCase.peresentClientCertificate) {
            clientCertificateStore.clear()
            const certAndKey = createCertAndKey()
            const pemCert = pki.certificateToPem(certAndKey[0])

            this.clientCert = pemCert
            const testCerts = new UrlClientCertificates(`https://localhost`)

            testCerts.clientCertificates = new ClientCertificates()
            testCerts.clientCertificates.cert.push(pemCert)
            testCerts.clientCertificates.key.push(new PemKey(pki.privateKeyToPem(certAndKey[1]), undefined))
            clientCertificateStore.addClientCertificatesForUrl(testCerts)
          }

          this.debugProxy = new DebuggingProxy(options)

          return this.debugProxy.start(PROXY_PORT)
        })

        afterEach(function () {
          this.debugProxy.stop()
        })

        it('Client certificate presneted if appropriate', function () {
          return this.request({
            url: `https://localhost:${HTTPS_PORT}/get`,
          }).then((body) => {
            expect(body).to.eq('It worked!')
            if (this.debugProxy) {
              expect(this.debugProxy.requests[0]).to.include({
                https: true,
                url: `localhost:${HTTPS_PORT}`,
              })
            }

            const socketKey = Object.keys(this.agent.httpsAgent.sockets).filter((key) => key.includes(`localhost:${HTTPS_PORT}`))

            expect(socketKey.length).to.eq(1, 'There should only be a single localhost TLS Socket')

            // If a client cert has been assigned to a TLS connection, the key for the TLSSocket
            // will include the public certificate
            if (this.clientCert) {
              expect(socketKey[0]).to.contain(this.clientCert, 'A client cert should be used for the TLS Socket')
            } else {
              expect(socketKey[0]).not.to.contain(this.clientCert, 'A client cert should not be used for the TLS Socket')
            }
          })
        })
      })
    })
  })

  context('.buildConnectReqHead', function () {
    it('builds the correct request', function () {
      const head = buildConnectReqHead('foo.bar', '1234', {})

      expect(head).to.eq([
        'CONNECT foo.bar:1234 HTTP/1.1',
        'Host: foo.bar:1234',
        '', '',
      ].join('\r\n'))
    })

    it('can do Proxy-Authorization', function () {
      const head = buildConnectReqHead('foo.bar', '1234', {
        auth: 'baz:quux',
      })

      expect(head).to.eq([
        'CONNECT foo.bar:1234 HTTP/1.1',
        'Host: foo.bar:1234',
        'Proxy-Authorization: basic YmF6OnF1dXg=',
        '', '',
      ].join('\r\n'))
    })
  })

  context('.createProxySock', function () {
    it('creates a `net` socket for an http url', function (done) {
      sinon.spy(net, 'connect')
      const proxy = url.parse('http://foo.bar:1234')

      createProxySock({ proxy }, () => {
        expect(net.connect).to.be.calledWith({ host: 'foo.bar', port: 1234 })
        done()
      })
    })

    it('creates a `tls` socket for an https url', function (done) {
      sinon.spy(tls, 'connect')
      const proxy = url.parse('https://foo.bar:1234')

      createProxySock({ proxy }, () => {
        expect(tls.connect).to.be.calledWith({ host: 'foo.bar', port: 1234 })
        done()
      })
    })

    it('throws on unsupported proxy protocol', function (done) {
      const proxy = url.parse('socksv5://foo.bar:1234')

      createProxySock({ proxy }, (err) => {
        expect(err.message).to.eq('Unsupported proxy protocol: socksv5:')
        done()
      })
    })
  })

  context('.isRequestHttps', function () {
    [
      {
        protocol: 'http',
        agent: http.globalAgent,
        expect: false,
      },
      {
        protocol: 'https',
        agent: https.globalAgent,
        expect: true,
      },
    ].map((testCase) => {
      it(`detects correctly from ${testCase.protocol} requests`, () => {
        const spy = sinon.spy(testCase.agent, 'addRequest')

        return request({
          url: `${testCase.protocol}://foo.bar.baz.invalid`,
          agent: testCase.agent,
        })
        .then(() => {
          throw new Error('Shouldn\'t succeed')
        })
        .catch(() => {
          const requestOptions = spy.getCall(0).args[1]

          expect(isRequestHttps(requestOptions)).to.equal(testCase.expect)
        })
      })

      it(`detects correctly from ${testCase.protocol} websocket requests`, () => {
        const spy = sinon.spy(testCase.agent, 'addRequest')
        const socket = socketIo.client(`${testCase.protocol}://foo.bar.baz.invalid`, {
          agent: <any>testCase.agent,
          transports: ['websocket'],
          timeout: 1,
          rejectUnauthorized: false,
        })

        return new Bluebird((resolve, reject) => {
          socket.on('message', reject)
          socket.io.on('error', resolve)
        })
        .then(() => {
          const requestOptions = spy.getCall(0).args[1]

          expect(isRequestHttps(requestOptions)).to.equal(testCase.expect)

          socket.close()
        })
      })
    })
  })

  context('.isResponseStatusCode200', function () {
    it('matches a 200 OK response correctly', function () {
      const result = isResponseStatusCode200('HTTP/1.1 200 Connection established')

      expect(result).to.be.true
    })

    it('matches a 500 error response correctly', function () {
      const result = isResponseStatusCode200('HTTP/1.1 500 Internal Server Error')

      expect(result).to.be.false
    })
  })

  context('.regenerateRequestHead', function () {
    it('regenerates changed request head', () => {
      const spy = sinon.spy(http.globalAgent, 'createSocket')

      return request({
        url: 'http://foo.bar.baz.invalid',
        agent: http.globalAgent,
      })
      .then(() => {
        throw new Error('this should fail')
      })
      .catch(() => {
        const req = spy.getCall(0).args[0]

        expect(req._header).to.equal([
          'GET / HTTP/1.1',
          'host: foo.bar.baz.invalid',
          'Connection: close',
          '', '',
        ].join('\r\n'))

        // now change some stuff, regen, and expect it to work
        delete req._header
        // @ts-ignore
        req.path = 'http://quuz.quux.invalid/abc?def=123'
        req.setHeader('Host', 'foo.fleem.invalid')
        req.setHeader('bing', 'bang')
        regenerateRequestHead(req)
        expect(req._header).to.equal([
          'GET http://quuz.quux.invalid/abc?def=123 HTTP/1.1',
          'Host: foo.fleem.invalid',
          'bing: bang',
          'Connection: close',
          '', '',
        ].join('\r\n'))
      })
    })
  })
})
