/**
 * @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com>
 * @license AGPL-3.0
 *
 * This code is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with OX App Suite. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>.
 *
 * Any use of the work other than as authorized under this license or copyright law is prohibited.
 */

import { injectApp, mockRedis, mockConfig, mockFetch } from './util.js'
import fs from 'fs'
import { expect } from 'chai'
import * as td from 'testdouble'
import RedisMock from 'ioredis-mock'
import sinon from 'sinon'
import zlib from 'node:zlib'

const image = fs.readFileSync('./spec/media/image.png')
const imageStat = fs.statSync('./spec/media/image.png')
const sandbox = sinon.createSandbox()

describe('File caching service', function () {
  let app
  let redis

  beforeEach(async function () {
    await mockConfig({ baseUrls: ['http://ui-server/'] })
    redis = await mockRedis()
    mockFetch({
      'http://ui-server': {}
    })
    await Promise.all([
      redis.client.set('ui-middleware:versionInfo', JSON.stringify({ version: 554855300 })),
      redis.client.set('ui-middleware:554855300:viteManifests', JSON.stringify({})),
      redis.client.set('ui-middleware:554855300:oxManifests:body', '{}'),
      redis.client.set('ui-middleware:554855300:oxManifests:meta', JSON.stringify({ headers: { 'content-type': 'application/json' } })),
      redis.client.set('ui-middleware:554855300:/example.js:body', 'this is example'),
      redis.client.set('ui-middleware:554855300:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } })),
      redis.client.set('ui-middleware:554855300:/test.txt:body', 'this is test'),
      redis.client.set('ui-middleware:554855300:/test.txt:meta', JSON.stringify({ headers: { 'content-type': 'text/plain' } })),
      redis.client.set('ui-middleware:554855300:/index.html:body', '<html><head></head><body>it\'s me</body></html>'),
      redis.client.set('ui-middleware:554855300:/index.html:meta', JSON.stringify({ headers: { 'content-type': 'text/html' } })),
      redis.client.set('ui-middleware:554855300:/main.css:body', 'body { color: red; }'),
      redis.client.set('ui-middleware:554855300:/main.css:meta', JSON.stringify({ headers: { 'content-type': 'text/css' } })),
      redis.client.set('ui-middleware:554855300:/image.png:body', image),
      redis.client.set('ui-middleware:554855300:/image.png:meta', JSON.stringify({ headers: { 'content-type': 'image/png' } }))
    ])
    app = await injectApp()
  })

  afterEach(async function () {
    await new RedisMock().flushdb()
    td.reset()
  })

  it('serves files defined in manifest.json file', async function () {
    const response = await app.inject({ url: '/example.js' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-type']).to.equal('application/javascript')
    expect(response.body).to.equal('this is example')
    // expect(response.headers['content-security-policy']).to.contain('sha256-NzZhMTE2Njc2YTgyNTZmZTdlZGVjZDU3YTNmYzRjNmM1OWZkMTI2NjRkYzZmMWM3YTkwMGU3ZTdhNDlhZmVlMwo=')
    const response2 = await app.inject({ url: '/test.txt' })
    expect(response2.statusCode).to.equal(200)
    expect(response2.headers['content-type']).to.equal('text/plain')
    expect(response2.body).to.equal('this is test')
  })

  it('serves css files', async function () {
    const response = await app.inject({ url: '/main.css' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-type']).to.equal('text/css')
    // expect(response.headers['content-security-policy']).to.contain('sha256-YjRiYWRlYTVhYmM5ZTZkNjE2ZGM4YjcwZWRlNzUxMmU0YjgxY2UxMWExOTI2ZjM1NzM1M2Y2MWJjNmUwMmZjMwo=')
  })

  it('serves / as index.html', async function () {
    const response = await app.inject({ url: '/' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-type']).to.equal('text/html')
    expect(response.body).to.equal('<html><head></head><body>it\'s me</body></html>')
  })

  it('returns 404 if file can not be resolved', async function () {
    const response = await app.inject({ url: '/unknown-file.txt' })
    expect(response.statusCode).to.equal(404)
  })

  it('serves binary files', async function () {
    const response = await app.inject({ url: '/image.png' })
    expect(response.statusCode).to.equal(200)
    expect(response.body.length === imageStat.size)
    expect(response.rawPayload).to.deep.equal(image)
  })

  it('only fetches files once', async function () {
    const spy = sandbox.spy(redis.client, 'get')

    expect(spy.callCount).to.equal(0)
    let response = await app.inject({ url: '/example.js' })
    expect(response.statusCode).to.equal(200)
    expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1)
    response = await app.inject({ url: '/example.js' })
    expect(response.statusCode).to.equal(200)
    expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1)
  })

  it('delivers binary files from cache', async function () {
    const spy = sandbox.spy(redis.client, 'get')

    expect(spy.callCount).to.equal(0)
    let response = await app.inject({ url: '/image.png' })
    expect(response.statusCode).to.equal(200)
    expect(response.rawPayload).to.deep.equal(image)
    expect(spy.withArgs(sinon.match(/\/image.png/)).callCount).to.equal(1)
    response = await app.inject({ url: '/image.png' })
    expect(response.statusCode).to.equal(200)
    expect(response.rawPayload).to.deep.equal(image)
    expect(spy.withArgs(sinon.match(/\/image.png/)).callCount).to.equal(1)
  })

  it('serves cached files with version', async function () {
    redis.client.set('ui-middleware:1234:/example.js:body', 'first')
    redis.client.set('ui-middleware:1234:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } }))
    const response1 = await app.inject({
      url: '/example.js',
      headers: { version: '1234' }
    })
    expect(response1.statusCode).to.equal(200)
    expect(response1.body).to.equal('first')

    const response2 = await app.inject({ url: '/example.js' })
    expect(response2.statusCode).to.equal(200)
    expect(response2.body).to.equal('this is example')

    const latestVersion = response1.headers['latest-version']

    const response3 = await app.inject({
      url: '/example.js',
      headers: { version: latestVersion }
    })
    expect(response3.statusCode).to.equal(200)
    expect(response3.body).to.equal('this is example')
  })

  it('checks again for files after an error occurred', async function () {
    redis.client.set('ui-middleware:123:viteManifests', JSON.stringify({}))
    const response1 = await app.inject({ url: '/example.js', headers: { version: '123' } })
    expect(response1.statusCode).to.equal(404)

    redis.client.set('ui-middleware:123:/example.js:body', 'Now available')
    redis.client.set('ui-middleware:123:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } }))

    const response2 = await app.inject({ url: '/example.js', headers: { version: '123' } })
    expect(response2.statusCode).to.equal(200)
    expect(response2.body).to.equal('Now available')
  })

  it('only fetches files once, even when requested simultanously', async function () {
    const spy = sandbox.spy(redis.client, 'get')

    expect(spy.callCount).to.equal(0)
    const [res1, res2] = await Promise.all([
      app.inject({ url: '/example.js' }),
      app.inject({ url: '/example.js' })
    ])
    expect(res1.statusCode).to.equal(200)
    expect(res2.statusCode).to.equal(200)
    expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1)
  })

  it('only fetches manifests once, even when requested simultaneously', async function () {
    const spy = sandbox.spy(redis.client, 'get')

    expect(spy.callCount).to.equal(0)
    const [res1, res2] = await Promise.all([
      app.inject({ url: '/manifests' }),
      app.inject({ url: '/example.js' })
    ])
    expect(res1.statusCode).to.equal(200)
    expect(res2.statusCode).to.equal(200)
    expect(spy.withArgs(sinon.match(/oxManifests/)).callCount).to.equal(1)
  })

  describe('redis request latency', function () {
    beforeEach(function () {
      // overwrite redis.get to simulate network latency
      const get = redis.client.get
      redis.client.get = function () {
        return new Promise(resolve => {
          setTimeout(async () => {
            resolve(await get.apply(redis.client, arguments))
          }, 20)
        })
      }
    })

    it('only requests files once, even though the response takes some time', async function () {
      const spy = sandbox.spy(redis.client, 'get')

      expect(spy.callCount).to.equal(0)
      const [res1, res2] = await Promise.all([
        app.inject({ url: '/example.js' }),
        app.inject({ url: '/example.js' })
      ])
      expect(res1.statusCode).to.equal(200)
      expect(res2.statusCode).to.equal(200)
      expect(spy.callCount).to.equal(1)
    })
  })

  it('serves index.html gzip compressed', async function () {
    redis.client.set('ui-middleware:554855300:/index.html:body', zlib.gzipSync('<html><head></head><body>it\'s me</body></html>'))
    redis.client.set('ui-middleware:554855300:/index.html:meta', JSON.stringify({ headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' } }))
    const response = await app.inject({ url: '/index.html' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-encoding']).to.equal('gzip')
    // check for files in redis
    expect(zlib.gunzipSync(await redis.client.getBuffer('ui-middleware:554855300:/index.html:body')).toString()).to.equal(zlib.gunzipSync(response.rawPayload).toString())
  })

  it('serves files as brotli compressed', async function () {
    redis.client.set('ui-middleware:554855300:/large.js:body', zlib.brotliCompressSync('this is example'))
    redis.client.set('ui-middleware:554855300:/large.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript', 'content-encoding': 'br' } }))
    const response = await app.inject({ url: '/large.js' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-encoding']).to.equal('br')

    // check for files in redis
    expect((await redis.client.getBuffer('ui-middleware:554855300:/large.js:body')).toString()).to.equal(response.body)
  })

  it('does not serve small files with compression', async function () {
    redis.client.set('ui-middleware:554855300:/small.js:body', 'this is example')
    redis.client.set('ui-middleware:554855300:/small.js:meta', JSON.stringify({ headers: { 'content-type': 'text/plain' } }))
    const response = await app.inject({ url: '/small.js' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers).to.not.have.property('content-encoding')

    // check for files in redis
    expect((await redis.client.getBuffer('ui-middleware:554855300:/small.js:body')).toString()).to.equal(response.body)
  })

  it('does not serve other mime types with compression', async function () {
    redis.client.set('ui-middleware:554855300:/file.mp3:body', 'this is example')
    redis.client.set('ui-middleware:554855300:/file.mp3:meta', JSON.stringify({ headers: { 'content-type': 'audio/mpeg' } }))
    const response = await app.inject({ url: '/file.mp3' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers).to.not.have.property('content-encoding')

    // check for files in redis
    expect(await redis.client.getBuffer('ui-middleware:554855300:/file.mp3:body')).to.deep.equal(response.rawPayload)
  })

  it('does serve svg with brotli compression (also escapes chars in regex)', async function () {
    redis.client.set('ui-middleware:554855300:/file.svg:body', zlib.brotliCompressSync([...new Array(2500)].join(' ')))
    redis.client.set('ui-middleware:554855300:/file.svg:meta', JSON.stringify({ headers: { 'content-type': 'image/svg+xml', 'content-encoding': 'br' } }))
    const response = await app.inject({ url: '/file.svg' })
    expect(response.statusCode).to.equal(200)
    expect(response.headers['content-encoding']).to.equal('br')

    // check for files in redis
    expect(await redis.client.getBuffer('ui-middleware:554855300:/file.svg:body')).to.deep.equal(response.rawPayload)
  })

  it('does not fetch from origins not defined in baseUrls', async function () {
    const response = await app.inject({ url: '//t989be0.netlify.app/xss.html' })
    expect(response.statusCode).to.equal(400)
  })
})