import nock from 'nock'
import request from 'supertest'
import { afterAll, afterEach, describe, expect, it, test, vi } from 'vitest'
import packageJson from '../package.json' with { type: 'json' }
import * as tokenService from '../src/server/helpers/jwt.js'
import { isRecord } from '../src/server/helpers/type-guards.js'
import * as defaults from './fixtures/constants.js'
import { nockGoogleDownloadFile } from './fixtures/drive.js'
import mockOauthState from './mockoauthstate.js'
import { getServer } from './mockserver.js'

vi.mock('express-prom-bundle')
vi.mock('tus-js-client')
mockOauthState()

const fakeLocalhost = 'localhost.com'

vi.mock('node:dns', () => ({
  default: {
    lookup: (
      hostname: string,
      _options: unknown,
      callback: (err: Error | null, address?: string, family?: number) => void,
    ) => {
      if (fakeLocalhost === hostname || hostname === 'localhost') {
        return callback(null, '127.0.0.1', 4)
      }
      return callback(new Error(`Unexpected call to hostname ${hostname}`))
    },
  },
}))

const getServerWithEnv = async () =>
  getServer({ COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '0' })

const secret = 'secret'
const authData = {
  dropbox: { accessToken: 'token value' },
  box: { accessToken: 'token value' },
  drive: { accessToken: 'token value' },
}
const token = tokenService.generateEncryptedAuthToken(authData, secret)
const OAUTH_STATE = 'some-cool-nice-encrytpion'

afterEach(() => {
  nock.cleanAll()
})
afterAll(() => {
  nock.restore()
})

describe('validate upload data', () => {
  test('access token expired or invalid when starting provider download', async () => {
    const meta: { size: null; mimeType: string; id: string } = {
      size: null,
      mimeType: 'video/mp4',
      id: defaults.ITEM_ID,
    }
    nock('https://www.googleapis.com')
      .get(`/drive/v3/files/${defaults.ITEM_ID}`)
      .query(() => true)
      .reply(200, meta)

    nock('https://www.googleapis.com')
      .get(
        `/drive/v3/files/${defaults.ITEM_ID}?alt=media&supportsAllDrives=true`,
      )
      .reply(401, {
        error: {
          code: 401,
          message:
            'Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.',
          status: 'UNAUTHENTICATED',
        },
      })

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        httpMethod: 'POST',
      })
      .expect(401)
      .then((res) =>
        expect(res.body.message).toBe(
          'HTTP 401: invalid access token detected by Provider',
        ),
      )
  })

  test('invalid upload protocol gets rejected', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tusInvalid',
      })
      .expect(400)
      .then((res) =>
        expect(res.body.message).toBe('unsupported protocol specified'),
      )
  })

  test('invalid upload fieldname gets rejected', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        fieldname: 390,
      })
      .expect(400)
      .then((res) =>
        expect(res.body.message).toBe('fieldname must be a string'),
      )
  })

  test('invalid upload metadata gets rejected', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        metadata: 'I am a string instead of object',
      })
      .expect(400)
      .then((res) =>
        expect(res.body.message).toBe('metadata must be an object'),
      )
  })

  test('invalid upload headers get rejected', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        headers: 'I am a string instead of object',
      })
      .expect(400)
      .then((res) => expect(res.body.message).toBe('headers must be an object'))
  })

  test('invalid upload HTTP Method gets rejected', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        httpMethod: 'DELETE',
      })
      .expect(400)
      .then((res) =>
        expect(res.body.message).toBe('unsupported HTTP METHOD specified'),
      )
  })

  test('valid upload data is allowed - tus', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 'tus',
        httpMethod: 'POST',
        headers: {
          customheader: 'header value',
        },
        metadata: {
          mymetadata: 'matadata value',
        },
        fieldname: 'uploadField',
      })
      .expect(200)
  })

  test('valid upload data is allowed - s3-multipart', async () => {
    nockGoogleDownloadFile()

    return request(await getServerWithEnv())
      .post('/drive/get/DUMMY-FILE-ID')
      .set('uppy-auth-token', token)
      .set('Content-Type', 'application/json')
      .send({
        endpoint: 'http://url.myendpoint.com/files',
        protocol: 's3-multipart',
        httpMethod: 'PUT',
        headers: {
          customheader: 'header value',
        },
        metadata: {
          mymetadata: 'matadata value',
        },
        fieldname: 'uploadField',
      })
      .expect(200)
  })
})

describe('handle main oauth redirect', async () => {
  const serverWithMainOauth = await getServer({
    COMPANION_OAUTH_DOMAIN: 'localhost:3040',
  })
  test('redirect to a valid uppy instance', () => {
    return request(serverWithMainOauth)
      .get(`/dropbox/redirect?state=${OAUTH_STATE}`)
      .set('uppy-auth-token', token)
      .expect(302)
      .expect(
        'Location',
        `http://localhost:3020/connect/dropbox/callback?state=${OAUTH_STATE}`,
      )
  })

  test('do not redirect to invalid uppy instances', () => {
    const state = 'state-with-invalid-instance-url' // see mock ../../src/server/helpers/oauth-state above
    return request(serverWithMainOauth)
      .get(`/dropbox/redirect?state=${state}`)
      .set('uppy-auth-token', token)
      .expect(400)
  })
})

it('periodically pings', async () => {
  await Promise.all([
    getServer({
      COMPANION_PERIODIC_PING_URLS: 'http://localhost/ping',
      COMPANION_PERIODIC_PING_STATIC_JSON_PAYLOAD: '{"some": "value"}',
      COMPANION_PERIODIC_PING_INTERVAL: '10',
      COMPANION_PERIODIC_PING_COUNT: '1',
    }),
    new Promise<void>((resolve) => {
      nock('http://localhost')
        .post(
          '/ping',
          (body: unknown) =>
            isRecord(body) &&
            body['some'] === 'value' &&
            body['version'] === packageJson.version &&
            typeof body['processId'] === 'string',
        )
        .reply(200, () => resolve())
    }),
  ])
}, 3000)

async function runUrlMetaTest(url: string) {
  const server = await getServer()

  return request(server).post('/url/meta').send({ url })
}

async function runUrlGetTest(url: string) {
  const server = await getServer()

  return request(server).post('/url/get').send({
    fileId: url,
    metadata: {},
    endpoint: 'http://url.myendpoint.com/files',
    protocol: 'tus',
    size: null,
    url,
  })
}

it('respects allowLocalUrls, localhost', async () => {
  let res = await runUrlMetaTest('http://localhost/')
  expect(res.statusCode).toBe(400)
  expect(res.body).toEqual({ error: 'Invalid request body' })

  res = await runUrlGetTest('http://localhost/')
  expect(res.statusCode).toBe(400)
  expect(res.body).toEqual({ error: 'Invalid request body' })
})

describe('respects allowLocalUrls, valid hostname that resolves to localhost', () => {
  test('meta', async () => {
    const res = await runUrlMetaTest(`http://${fakeLocalhost}/`)
    expect(res.statusCode).toBe(500)
    expect(res.body).toEqual({ message: 'failed to fetch URL metadata' })
  })

  test('get', async () => {
    const res = await runUrlGetTest(`http://${fakeLocalhost}/`)
    expect(res.statusCode).toBe(500)
    expect(res.body).toEqual({ message: 'failed to fetch URL' })
  })
})

describe('S3 controller', () => {
  test('createMultipartUpload rejects missing filename', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .post('/s3/multipart')
      .send({
        type: 'image/png',
        metadata: {},
        // filename is intentionally missing
      })
      .expect(400)
      .then((res) =>
        expect(res.body.error).toBe(
          's3: the "filename" field is required and must be a non-empty string',
        ),
      )
  })

  test('createMultipartUpload rejects empty filename', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .post('/s3/multipart')
      .send({
        type: 'image/png',
        metadata: {},
        filename: '',
      })
      .expect(400)
      .then((res) =>
        expect(res.body.error).toBe(
          's3: the "filename" field is required and must be a non-empty string',
        ),
      )
  })

  test('createMultipartUpload rejects non-string filename', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .post('/s3/multipart')
      .send({
        type: 'image/png',
        metadata: {},
        filename: 12345,
      })
      .expect(400)
      .then((res) =>
        expect(res.body.error).toBe(
          's3: the "filename" field is required and must be a non-empty string',
        ),
      )
  })

  test('getUploadParameters rejects missing filename', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .get('/s3/params')
      .query({
        type: 'image/png',
        // filename is intentionally missing
      })
      .expect(400)
      .then((res) =>
        expect(res.body.error).toBe(
          's3: the "filename" query parameter is required and must be a non-empty string',
        ),
      )
  })

  test('getUploadParameters rejects empty filename', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .get('/s3/params')
      .query({
        type: 'image/png',
        filename: '',
      })
      .expect(400)
      .then((res) =>
        expect(res.body.error).toBe(
          's3: the "filename" query parameter is required and must be a non-empty string',
        ),
      )
  })

  test('getUploadParameters supports bracket metadata query format', async () => {
    const server = await getServer({
      COMPANION_AWS_KEY: 'test_key',
      COMPANION_AWS_SECRET: 'test_secret',
      COMPANION_AWS_BUCKET: 'test-bucket',
      COMPANION_AWS_REGION: 'us-east-1',
    })

    return request(server)
      .get('/s3/params')
      .query({
        filename: 'test.txt',
        type: 'text/plain',
        'metadata[name]': 'demo-file',
      })
      .expect(200)
      .then((res) => {
        const fields = res.body.fields as Record<string, string>
        expect(fields['x-amz-meta-name']).toBe('demo-file')
      })
  })
})
