import { describe, expect, beforeEach, afterEach, it, vi } from 'vitest'
import { NetworkProxy } from '../../'
import {
  netStubbingState as _netStubbingState,
  NetStubbingState,
  onNetStubbingEvent,
} from '@packages/net-stubbing'
import { defaultMiddleware } from '../../lib/http'
import express from 'express'
import supertest from 'supertest'
import { allowDestroy } from '@packages/network'
import { DocumentDomainInjection, RemoteStates } from '@packages/network-tools'
import { EventEmitter } from 'events'
import { CookieJar } from '@packages/server/lib/util/cookies'
import { Request as ServerRequest } from '@packages/server/lib/request'
const getFixture = async () => {}

describe('network stubbing', () => {
  let config
  let remoteStates: RemoteStates
  let netStubbingState: NetStubbingState
  let app
  let destinationApp
  let server
  let destinationPort
  let socket
  let documentDomainInjection: DocumentDomainInjection

  const serverPort = 3030
  const fileServerPort = 3030

  const remoteStateConfig = () => {
    return { server: serverPort, fileServer: fileServerPort }
  }

  const createRemoteStates = () => {
    return new RemoteStates(remoteStateConfig, documentDomainInjection)
  }

  beforeEach(() => {
    config = {
      experimentalCspAllowList: false,
    }

    documentDomainInjection = DocumentDomainInjection.InjectionBehavior({ injectDocumentDomain: false, testingType: 'e2e' })

    remoteStates = createRemoteStates()
    socket = new EventEmitter()
    socket.toDriver = vi.fn()
    app = express()
    netStubbingState = _netStubbingState()

    const proxy = new NetworkProxy({
      socket,
      netStubbingState,
      config,
      middleware: defaultMiddleware,
      getCookieJar: () => new CookieJar(),
      remoteStates,
      getFileServerToken: () => 'fake-token',
      request: new ServerRequest(),
      getRenderedHTMLOrigins: () => ({}),
      serverBus: new EventEmitter(),
      getCurrentBrowser: vi.fn(),
    })

    app.use((req, res, next) => {
      req.proxiedUrl = req.url = req.url.slice(1)
      req.cookies = {}
      next()
    })

    app.use((req, res) => {
      proxy.handleHttpRequest(req, res)
    })

    destinationApp = express()

    destinationApp.get('/', (req, res) => res.send('it worked'))

    destinationApp.get('/csp-header-strip', (req, res) => {
      const headerName = req.query.headerName

      res.setHeader('content-type', 'text/html')
      res.setHeader(headerName, 'script-src \'self\' localhost')
      res.send('<foo>bar</foo>')
    })

    destinationApp.get('/csp-header-none', (req, res) => {
      const headerName = req.query.headerName

      proxy.http.config.experimentalCspAllowList = true
      res.setHeader('content-type', 'text/html')
      res.setHeader(headerName, 'fake-directive fake-value')
      res.send('<foo>bar</foo>')
    })

    destinationApp.get('/csp-header-single', (req, res) => {
      const headerName = req.query.headerName

      proxy.http.config.experimentalCspAllowList = ['script-src']
      res.setHeader('content-type', 'text/html')
      res.setHeader(headerName, ['default-src \'self\'', 'script-src \'self\' localhost'])
      res.send('<foo>bar</foo>')
    })

    destinationApp.get('/csp-header-multiple', (req, res) => {
      const headerName = req.query.headerName

      proxy.http.config.experimentalCspAllowList = ['script-src', 'default-src']
      res.setHeader('content-type', 'text/html')
      res.setHeader(headerName, ['default-src \'self\'', 'script-src \'self\' localhost'])
      res.send('<foo>bar</foo>')
    })

    return new Promise<void>((resolve) => {
      server = allowDestroy(destinationApp.listen(() => {
        destinationPort = server.address().port
        remoteStates.set(`http://localhost:${destinationPort}`)
        remoteStates.set(`http://localhost:${destinationPort}/csp-header-strip`)
        remoteStates.set(`http://localhost:${destinationPort}/csp-header-none`)
        remoteStates.set(`http://localhost:${destinationPort}/csp-header-single`)
        remoteStates.set(`http://localhost:${destinationPort}/csp-header-multiple`)
        resolve()
      }))
    })
  })

  afterEach(() => {
    server.destroy()
  })

  it('can make a vanilla request', async () => {
    const resp = await supertest(app).get(`/http://localhost:${destinationPort}`)

    expect(resp.text).toEqual('it worked')
  })

  it('does not add CORS headers to all responses', async () => {
    const resp = await supertest(app).get(`/http://localhost:${destinationPort}`)

    expect(resp.headers).not.toHaveProperty('access-control-allow-origin')
  })

  it('adds CORS headers to static stubs', async () => {
    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: false,
      staticResponse: {
        body: 'foo',
      },
      getFixture: async () => {},
      matches: 1,
    })

    const resp = await supertest(app)
    .get(`/http://localhost:${destinationPort}`)

    expect(resp.headers).toEqual(expect.objectContaining({
      'access-control-allow-origin': '*',
      'access-control-allow-credentials': 'true',
    }))

    expect(resp.text).toEqual('foo')
  })

  it('does not override CORS headers', async () => {
    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: false,
      staticResponse: {
        body: 'foo',
        headers: {
          'access-control-allow-origin': 'something',
        },
      },
      getFixture: async () => {},
      matches: 1,
    })

    const resp = await supertest(app).get(`/http://localhost:${destinationPort}`)

    expect(resp.headers).toEqual(expect.objectContaining({
      'access-control-allow-origin': 'something',
    }))
  })

  it('uses Origin to set CORS header', async () => {
    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: false,
      staticResponse: {
        body: 'foo',
      },
      getFixture: async () => {},
      matches: 1,
    })

    const resp = await supertest(app).get(`/http://localhost:${destinationPort}`).set('Origin', 'http://foo.com')

    expect(resp.headers).toEqual(expect.objectContaining({
      'access-control-allow-origin': 'http://foo.com',
    }))
  })

  it('adds CORS headers to dynamically intercepted requests', async () => {
    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: true,
      getFixture,
      matches: 1,
    })

    socket.toDriver.mockImplementation((_, event, data) => {
      if (event === 'before:request') {
        onNetStubbingEvent({
          eventName: 'send:static:response',
          // @ts-ignore
          frame: {
            requestId: data.requestId,
            staticResponse: {
              ...data.data,
              body: 'replaced',
            },
          },
          state: netStubbingState,
          getFixture,
          args: [],
          socket: {
            toDriver () {},
          },
        })
      }
    })

    const resp = await supertest(app).get(`/http://localhost:${destinationPort}`)

    expect(resp.text).toEqual('replaced')
    expect(resp.headers).toEqual(expect.objectContaining({
      'access-control-allow-origin': '*',
    }))
  })

  it('does not modify multipart/form-data files', async () => {
    const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64')
    let sendContentLength = ''
    let receivedContentLength = ''
    let realContentLength = ''

    destinationApp.post('/', (req, res) => {
      const chunks: Buffer[] = []

      req.on('data', (chunk) => {
        chunks.push(chunk)
      })

      req.on('error', (error) => {
        throw error
      })

      req.on('end', () => {
        realContentLength = Buffer.byteLength(Buffer.concat(chunks)).toString()
        receivedContentLength = req.headers['content-length']
        res.send('ok')
      })
    })

    // capture unintercepted content-length
    await supertest(app)
    .post(`/http://localhost:${destinationPort}`)
    .attach('file', png)

    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: true,
      getFixture,
      matches: 1,
    })

    socket.toDriver.mockImplementation((_, event, data) => {
      if (event === 'before:request') {
        sendContentLength = data.data.headers['content-length']
        onNetStubbingEvent({
          eventName: 'send:static:response',
          // @ts-ignore
          frame: {
            requestId: data.requestId,
            staticResponse: {
              ...data.data,
            },
          },
          state: netStubbingState,
          getFixture,
          args: [],
          socket: {
            toDriver () {},
          },
        })
      }
    })

    // capture content-length after intercepting
    await supertest(app)
    .post(`/http://localhost:${destinationPort}`)
    .attach('file', png)

    expect(sendContentLength).toEqual(receivedContentLength)
    expect(sendContentLength).toEqual(realContentLength)
  })

  it('does not intercept requests to the dev server', async () => {
    destinationApp.get('/__cypress/src/main.js', (req, res) => res.send('it worked'))

    // setup an intercept that matches all requests
    // and has a static response
    netStubbingState.routes.push({
      id: '1',
      routeMatcher: {
        url: '*',
      },
      hasInterceptor: false,
      staticResponse: {
        body: 'foo',
      },
      getFixture,
      matches: 1,
    })

    config.devServerPublicPathRoute = '/__cypress/src'

    // request to the dev server does NOT get intercepted
    const resp = await supertest(app)
    .get(`/http://localhost:${destinationPort}/__cypress/src/main.js`)

    expect(resp.status).toEqual(200)
    expect(resp.text).toEqual('it worked')

    // request NOT to the dev server DOES get intercepted
    const resp2 = await supertest(app)
    .get(`/http://localhost:${destinationPort}/`)

    expect(resp2.status).toEqual(200)
    expect(resp2.text).toEqual('foo')
  })

  describe('CSP Headers', () => {
    // Loop through valid CSP header names can verify that we handle them
    [
      'content-security-policy',
      'Content-Security-Policy',
      'content-security-policy-report-only',
      'Content-Security-Policy-Report-Only',
    ].forEach((headerName) => {
      describe(`${headerName}`, () => {
        it('does not add CSP header if injecting JS and original response had no CSP header', async () => {
          netStubbingState.routes.push({
            id: '1',
            routeMatcher: {
              url: '*',
            },
            hasInterceptor: false,
            staticResponse: {
              body: '<foo>bar</foo>',
            },
            getFixture: async () => {},
            matches: 1,
          })

          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}`)
          .set('Accept', 'text/html,application/xhtml+xml')

          expect(resp.headers[headerName]).toBeUndefined()
          expect(resp.headers[headerName.toLowerCase()]).toBeUndefined()
        })

        it('removes CSP header by default if not injecting JS and original response had CSP header', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-strip?headerName=${headerName}`)

          expect(resp.headers[headerName]).toBeUndefined()
          expect(resp.headers[headerName.toLowerCase()]).toBeUndefined()
        })

        it('removes CSP header by default if injecting JS and original response had CSP header', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-strip?headerName=${headerName}`)

          expect(resp.headers[headerName]).toBeUndefined()
          expect(resp.headers[headerName.toLowerCase()]).toBeUndefined()
        })

        it('does not modify CSP header if not injecting JS and original response had CSP header', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-none?headerName=${headerName}`)

          expect(resp.headers[headerName.toLowerCase()]).toEqual('fake-directive fake-value')
        })

        it('does not modify a CSP header if injecting JS and original response had CSP header, but did not have a directive affecting script-src', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-none?headerName=${headerName}`)
          .set('Accept', 'text/html,application/xhtml+xml')

          expect(resp.headers[headerName.toLowerCase()]).toEqual('fake-directive fake-value')
        })

        it('modifies a CSP header if injecting JS and original response had CSP header affecting script-src', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-single?headerName=${headerName}`)
          .set('Accept', 'text/html,application/xhtml+xml')

          expect(resp.headers[headerName.toLowerCase()]).toEqual(expect.stringMatching(/^script-src 'self' localhost 'nonce-[A-Za-z0-9+/=]+|=[^=]|={3,}$'$/))
        })

        it('modifies CSP header if injecting JS and original response had multiple CSP headers and directives', async () => {
          const resp = await supertest(app)
          .get(`/http://localhost:${destinationPort}/csp-header-multiple?headerName=${headerName}`)
          .set('Accept', 'text/html,application/xhtml+xml')

          expect(resp.headers[headerName.toLowerCase()]).toEqual(expect.stringMatching(/^default-src 'self' 'nonce-[A-Za-z0-9+/=]+|=[^=]|={3,}', script-src 'self' localhost 'nonce-[A-Za-z0-9+/=]+|=[^=]|={3,}$'$/))
        })

        if (headerName !== headerName.toLowerCase()) {
          // Do not add a non-lowercase version of a CSP header, because most-restrictive is used
          it('removes non-lowercase CSP header to avoid conflicts on unmodified CSP headers', async () => {
            const resp = await supertest(app)
            .get(`/http://localhost:${destinationPort}/csp-header-none?headerName=${headerName}`)

            expect(resp.headers[headerName]).toBeUndefined()
          })

          it('removes non-lowercase CSP header to avoid conflicts on modified CSP headers', async () => {
            const resp = await supertest(app)
            .get(`/http://localhost:${destinationPort}/csp-header-single?headerName=${headerName}`)
            .set('Accept', 'text/html,application/xhtml+xml')

            expect(resp.headers[headerName]).toBeUndefined()
          })

          it('removes non-lowercase CSP header to avoid conflicts on multiple CSP headers', async () => {
            const resp = await supertest(app)
            .get(`/http://localhost:${destinationPort}/csp-header-multiple?headerName=${headerName}`)
            .set('Accept', 'text/html,application/xhtml+xml')

            expect(resp.headers[headerName]).toBeUndefined()
          })
        }
      })
    })
  })
})
