import { createServer } from 'http'
import { sync } from 'resolve'
import { findOpenPort } from '../browser/findOpenPort'
import { getLauncher, openBrowser } from '../browser/openBrowser'
import { setupServer } from '../browser/server'
import { Logger, TestMetadata } from '../types'
import { StatsAndResults, TypedTestOptions } from './types'
import { basename, join, isAbsolute } from 'path'
import * as tempy from 'tempy'
import { writeFileSync } from 'fs'
import { generateTestBundle } from '../browser/generateTestBundle'
import { createIndexHtml } from '../browser/createIndexHtml'
import { ProcessResults, typecheckInAnotherProcess } from '../typescript/typeCheckInAnotherProcess'
import { watchTestMetadata } from '../tests/watchTestMetadata'
import { CompilerOptions } from 'typescript'
import { watchFile } from '../browser/webpack/watchFile'
import { collectByKey } from '../common/collectByKey'
import { Stats } from 'webpack'
import { Results } from './Results'
import { makeAbsolute } from '../common/makeAbsolute'
import { getTestStats, getTestResults } from '../results'

const isEqual: (a: any, b: any) => boolean = require('lodash.isequal')
const uniq = <A>(list: Array<A>): Array<A> => Array.from(new Set(list))
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

const updateQueue = (metadata: TestMetadata[], queue: TestMetadata[]) => {
  const queuedMetadataByFilePath = collectByKey(x => x.filePath, queue)
  const queuedFilePaths = Object.keys(queuedMetadataByFilePath).sort()
  const metadataByFilePath = collectByKey(x => x.filePath, metadata)
  const filePaths = Object.keys(metadataByFilePath).sort()
  const allFilePaths = uniq([...filePaths, ...queuedFilePaths])

  return allFilePaths.reduce(
    (xs, filePath) =>
      metadataByFilePath[filePath] // replace updated files
        ? xs.concat(metadataByFilePath[filePath])
        : xs.concat(queuedMetadataByFilePath[filePath]),
    [] as TestMetadata[],
  )
}

export async function watchBrowserTests(
  fileGlobs: Array<string>,
  compilerOptions: CompilerOptions,
  options: TypedTestOptions,
  cwd: string,
  logger: Logger,
  cb: (results: StatsAndResults) => void,
  errCb: (error: Error) => void,
  typeCheckCb: (results: ProcessResults) => void,
) {
  const { keepAlive, timeout, typeCheck, mode } = options
  const outputDirectory = tempy.directory()
  const temporaryPath = join(outputDirectory, basename(tempy.file({ extension: 'ts' })))
  const bundlePath = join(outputDirectory, basename(tempy.file({ extension: 'js' })))
  const indexHtmlPath = join(outputDirectory, 'index.html')
  const port = await findOpenPort()
  const url = `http://localhost:${port}`
  const logErrors = (errors: string[]) => Promise.all(errors.map(logger.error))
  const makeAbsolutePath = (path: string) => (isAbsolute(path) ? path : join(cwd, path))
  const { updateResults, removeFilePath } = new Results()

  // shared mutable state
  let queuedMetadata: TestMetadata[] = []
  let previousMetadata: TestMetadata[] = []
  let testsAreRunning = false
  let writingToDisk = false
  let scheduleNextRunHandle: NodeJS.Timer
  let newFileOnDisk = false
  let killBrowser: () => void = () => void 0
  let previousWebpackHash: string = ''
  let firstRun = true
  let timeToRunTests: number = 0
  let browserOpenTime: number = 0
  let testsCompletedTime: number = 0

  const killTestBrowser = () => (!keepAlive && killBrowser ? killBrowser() : void 0)

  const setupTestBrowser = async () => {
    const { browser, keepAlive } = options

    if (browser !== 'chrome-headless') {
      const launcher = await getLauncher()

      const run = async () => {
        killTestBrowser()

        logger.log('Opening browser...')
        browserOpenTime = Date.now()

        const instance = await openBrowser(browser, url, keepAlive, launcher)

        if (!firstRun) {
          const lastCompletion = testsCompletedTime
          // Re-run tests if they seem to have stalled - this can happen when browsers are opened in succession quite quickly
          // I've only been able to make it do this when I am very rapidly changing and saving a test file to purposely try and break things.
          delay(timeToRunTests * 3).then(
            () => (testsCompletedTime === lastCompletion ? run() : void 0),
          )
        }

        killBrowser = () => instance.stop()
      }

      return run
    }

    const { launch } = require(sync('chrome-launcher', { basedir: cwd }))

    const run = async () => {
      killTestBrowser()

      logger.log('Opening browser...')
      browserOpenTime = Date.now()

      const chrome = await launch({ startingUrl: url })

      if (!firstRun) {
        const lastCompletion = testsCompletedTime
        delay(timeToRunTests * 3).then(
          () => (testsCompletedTime === lastCompletion ? run() : void 0),
        )
      }

      killBrowser = () => chrome.kill()
    }

    return run
  }

  const runTestsInBrowser = await setupTestBrowser()

  const newStats = async (stats: Stats) => {
    const shouldReturnEarly =
      (stats as any).hash === previousWebpackHash || !newFileOnDisk || testsAreRunning

    if (shouldReturnEarly) {
      previousWebpackHash = (stats as any).hash

      return
    }

    if (stats.hasErrors()) {
      const { errors } = stats.toJson()

      return logErrors(errors)
    }

    testsAreRunning = true

    runTestsInBrowser()
  }

  const writeToDisk = (metadata: TestMetadata[]) => {
    writeFileSync(temporaryPath, generateTestBundle(cwd, outputDirectory, port, timeout, metadata))

    newFileOnDisk = true
    writingToDisk = false

    if (firstRun) {
      watchFile(cwd, temporaryPath, bundlePath, options.webpackConfiguration, newStats, errCb)
    }
  }

  const typeCheckMetadata = async (metadata: TestMetadata[]) => {
    logger.log('Typechecking...')
    const processResults = await typecheckInAnotherProcess(cwd, metadata.map(x => x.filePath))
    logger.log('Type checking complete')

    typeCheckCb(processResults)
  }

  const removeFileFromQueue = (filePath: string) => {
    const path = makeAbsolute(cwd, filePath)
    removeFilePath(path)

    queuedMetadata = queuedMetadata.filter(x => makeAbsolutePath(x.filePath) !== path)
  }

  const scheduleNextBundleWrite = () => {
    if (testsAreRunning || writingToDisk) {
      clearTimeout(scheduleNextRunHandle)

      scheduleNextRunHandle = setTimeout(scheduleNextBundleWrite, 600)

      return
    }

    writingToDisk = true
    const currentQueue = queuedMetadata.slice()

    writeToDisk(currentQueue)

    if (typeCheck) {
      typeCheckMetadata(currentQueue)
    }

    queuedMetadata = []
  }

  const updateMetadataAndWriteBundle = async (metadata: TestMetadata[]) => {
    queuedMetadata = updateQueue(metadata, queuedMetadata)

    if (isEqual(metadata, previousMetadata) || queuedMetadata.length === 0) {
      return
    }

    previousMetadata = metadata

    scheduleNextBundleWrite()
  }

  const server = createServer(
    setupServer(logger, outputDirectory, newResults => {
      firstRun = false

      if (!keepAlive && killBrowser) {
        killBrowser()
      }

      const results = updateResults(newResults)
      const stats = getTestStats(getTestResults(results))

      cb({ results, stats })

      testsCompletedTime = Date.now()
      timeToRunTests = testsCompletedTime - browserOpenTime
      newFileOnDisk = testsAreRunning = false
    }),
  )

  writeFileSync(indexHtmlPath, createIndexHtml(basename(bundlePath)))
  server.listen(port, '0.0.0.0')

  const { dispose: stopWatchingMetadata } = await watchTestMetadata(
    cwd,
    fileGlobs,
    compilerOptions,
    mode,
    logger,
    removeFileFromQueue,
    updateMetadataAndWriteBundle,
  )

  const dispose = () => {
    stopWatchingMetadata()
    server.close()
  }

  return { dispose }
}
