/* eslint-disable no-console */

import { mkdirSync } from 'fs'
import path from 'path'
import ora from 'ora'
import { nanoid } from 'nanoid'
import { temporaryDirectory } from 'tempy'
import { premove } from 'premove/sync'
import {
  redirectConsole,
  getPw,
  addWorker,
  findTests,
  defaultTestPatterns,
  createCov,
  createPolka,
} from './utils/index.js'
import { compileSw } from './utils/build-sw.js'
import mergeOptions from 'merge-options'
import { fileURLToPath } from 'node:url'
import { watch } from 'chokidar'
import cpy from 'cpy'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const merge = mergeOptions.bind({ ignoreUndefined: true })

/**
 * @typedef {import('playwright-core').Page} Page
 * @typedef {import('playwright-core').BrowserContext} Context
 * @typedef {import('playwright-core').Browser} Browser
 * @typedef {import('./types').RunnerOptions} RunnerOptions
 * @typedef {import('playwright-core').ChromiumBrowserContext} ChromiumBrowserContext
 */

/**
 * @type {RunnerOptions}
 */
const defaultOptions = {
  cwd: process.cwd(),
  assets: '',
  browser: 'chromium',
  debug: false,
  mode: 'main', // worker
  incognito: false,
  input: undefined,
  extension: false,
  runnerOptions: {},
  before: undefined,
  sw: undefined,
  cov: false,
  reportDir: '.nyc_output',
  extensions: 'js,cjs,mjs,ts,tsx',
  buildConfig: {},
  buildSWConfig: {},
  beforeTests: async () => {},
  afterTests: async () => {},
}

export class Runner {
  /**
   *
   * @param {Partial<import('./types').RunnerOptions>} [options]
   */
  constructor(options = {}) {
    /** @type {RunnerOptions} */
    this.options = merge(defaultOptions, options)
    /** @type {import('polka').Polka["server"] | undefined} */
    this.server = undefined

    this.dir = path.join(__dirname, '../.tmp', nanoid())
    mkdirSync(this.dir, {
      recursive: true,
    })
    this.browserDir = temporaryDirectory()
    this.url = ''
    this.stopped = false
    this.watching = false
    this.env = merge(JSON.parse(JSON.stringify(process.env)), {
      PW_TEST: this.options,
    })
    this.extensions = this.options.extensions.split(',')
    this.beforeTestsOutput = undefined
    this.tests = findTests({
      cwd: this.options.cwd,
      extensions: this.extensions,
      filePatterns: this.options.input
        ? this.options.input
        : defaultTestPatterns(this.extensions),
    })
    if (this.tests.length === 0) {
      this.stop(false, 'No test files were found.')
    }

    process.env.DEBUG += ',-pw:*'
  }

  async launch() {
    // copy files to be served
    await cpy(path.join(__dirname, './../static') + '/**', this.dir)

    // setup http server
    await createPolka(this)

    // download playwright if needed
    const pw = await getPw(this.options.browser)

    /** @type {import('playwright-core').LaunchOptions} */
    const pwOptions = {
      headless: !this.options.extension && !this.options.debug,
      devtools: this.options.browser === 'chromium' && this.options.debug,
      args: this.options.extension
        ? [
            `--disable-extensions-except=${this.dir}`,
            `--load-extension=${this.dir}`,
          ]
        : [],
    }

    // create context
    if (this.options.incognito) {
      this.browser = await pw.launch(pwOptions)
      this.context = await this.browser.newContext()
    } else {
      this.context = await pw.launchPersistentContext(
        this.browserDir,
        pwOptions
      )
    }

    return this.context
  }

  /**
   * Setup Page
   *
   * @param {Context} context
   */
  async setupPage(context) {
    if (this.options.extension && this.options.browser !== 'chromium') {
      throw new Error('Extension testing is only supported in chromium')
    }

    if (this.options.cov && this.options.browser !== 'chromium') {
      throw new Error('Coverage is only supported in chromium')
    }

    if (this.options.cov && this.options.mode !== 'main') {
      throw new Error(
        'Coverage is only supported in the main thread use mode:"main" '
      )
    }

    if (this.options.extension) {
      const context = /** @type {ChromiumBrowserContext} */ (this.context)
      const backgroundPages = await context.backgroundPages()
      this.page =
        backgroundPages.length > 0
          ? backgroundPages[0]
          : await context.waitForEvent('backgroundpage')

      if (!this.page) {
        throw new Error('Could not find the background page for the extension.')
      }

      if (this.options.debug) {
        // Open extension devtools window
        const extPage = await context.newPage()

        await extPage.goto(
          `chrome://extensions/?id=${
            // @ts-ignore
            this.page._mainFrame._initializer.url.split('/')[2]
          }`
        )

        const buttonHandle = await extPage.evaluateHandle(
          'document.querySelector("body > extensions-manager").shadowRoot.querySelector("extensions-toolbar").shadowRoot.querySelector("#devMode")'
        )

        // @ts-ignore
        await buttonHandle.click()

        const backgroundPageLink = await extPage.evaluateHandle(
          'document.querySelector("body > extensions-manager").shadowRoot.querySelector("#viewManager > extensions-detail-view").shadowRoot.querySelector("#inspect-views > li:nth-child(2) > a")'
        )

        // @ts-ignore
        await backgroundPageLink.click()
      }
    } else if (this.options.incognito) {
      this.page = await context.newPage()
      await this.page.goto(this.url)
    } else {
      this.page = context.pages()[0]
      await this.page.goto(this.url)
    }

    if (this.options.cov && this.page.coverage) {
      await this.page.coverage.startJSCoverage()
    }

    this.page.on('console', redirectConsole)
    // uncaught rejections
    this.page.on('pageerror', (err) => {
      console.error(err)
      this.stop(
        true,
        'Uncaught exception happened within the page. Run with --debug.'
      )
    })
    return this.page
  }

  /**
   * Run the tests
   *
   * @param {Page} page
   */
  async runTests(page) {
    await page.addScriptTag({ url: 'setup.js' })
    await page.evaluate(
      `localStorage.debug = "${this.env.DEBUG},-pw:*,-mocha:*"`
    )
    const files = []
    const { outName, files: mainFiles } = await this.compiler()
    files.push(...mainFiles)

    switch (this.options.mode) {
      case 'main': {
        await page.addScriptTag({ url: outName })
        break
      }
      case 'worker': {
        // do not await for the promise because we will wait for the 'worker' event after
        page.evaluate(addWorker(outName))
        break
      }
      default:
        throw new Error('mode not supported')
    }

    // inject and register the service
    if (this.options.sw) {
      const { files: swFiles } = await compileSw(this, {
        entry: this.options.sw,
      })
      files.push(...swFiles)
      await page.evaluate(() => {
        navigator.serviceWorker.register(`/sw-out.js`)
        return navigator.serviceWorker.ready
      })
    }

    return { outName, files }
  }

  async run() {
    this.beforeTestsOutput = await this.options.beforeTests(this.options)

    const spinner = ora(`Setting up ${this.options.browser}`).start()

    try {
      // get the context
      const context = await this.launch()

      // run the before script
      if (this.options.before) {
        await this.runBefore(context)
      }

      // get the page
      const page = await this.setupPage(context)
      spinner.succeed(`${this.options.browser} set up`)

      if (this.options.debug) {
        page.on('load', async () => {
          this.runTests(page).catch((error) => {
            console.log(error)
          })
        })
      }
      // run tests
      const { outName } = await this.runTests(page)

      // Re run on page reload
      if (!this.options.debug) {
        // wait for the tests
        await page.waitForFunction(
          // @ts-ignore
          () => self.PW_TEST.ended === true,
          undefined,
          {
            timeout: 0,
            polling: 100, // need to be polling raf doesnt work in extensions
          }
        )
        const testsFailed = await page.evaluate('self.PW_TEST.failed')

        // coverage
        if (this.options.cov && page.coverage) {
          await createCov(
            this,
            await page.coverage.stopJSCoverage(),
            outName,
            this.options.reportDir
          )
        }

        // exit
        await this.stop(testsFailed)
      }
    } catch (/** @type {any} */ error) {
      spinner.fail('Running tests failed.')
      await this.stop(true, error)
    }
  }

  /**
   * Setup and run before page
   *
   * @param {Context} context
   */
  async runBefore(context) {
    const page = await context.newPage()
    await page.goto(this.url + 'before.html')

    page.on('console', redirectConsole)
    page.on('pageerror', (err) => {
      this.stop(true, `Before page:\n ${err}`)
    })

    const { outName } = await this.compiler('before')
    await page.addScriptTag({ url: outName })
    await page.waitForFunction('self.PW_TEST.beforeEnded', {
      timeout: 0,
    })
  }

  async watch() {
    const spinner = ora(`Setting up ${this.options.browser}`).start()

    const context = await this.launch()
    if (this.options.before) {
      spinner.text = 'Running before script'
      await this.runBefore(context)
    }
    const page = await this.setupPage(context)

    spinner.succeed()
    const { files } = await this.runTests(page)

    const watcher = watch([...files], {
      ignored: /(^|[/\\])\../,
      ignoreInitial: true,
      awaitWriteFinish: { pollInterval: 100, stabilityThreshold: 500 },
    }).on('change', async () => {
      // Unregister any service worker in the page before reload
      await page.evaluate(async () => {
        const regs = await navigator.serviceWorker.getRegistrations()
        return regs[0] ? regs[0].unregister() : Promise.resolve()
      })
      await page.reload()
      const { files } = await this.runTests(page)
      watcher.add([...files])
    })
  }

  /**
   * @param {boolean} fail
   * @param {string | undefined} [msg]
   */
  async stop(fail, msg) {
    if (this.stopped || this.options.debug) {
      return
    }
    this.stopped = true

    // Run after tests hook
    await this.options.afterTests(this.options, this.beforeTestsOutput)

    if (this.context) {
      await this.context.close()
    }

    const serverClose = new Promise((resolve, reject) => {
      if (this.server) {
        this.server.close((err) => {
          if (err) {
            return reject(err)
          }
          resolve(true)
        })
      } else {
        resolve(true)
      }
    })

    await serverClose

    premove(this.dir)
    // premove(this.browserDir)

    if (fail && msg) {
      console.error(msg)
    } else if (msg) {
      console.log(msg)
    }

    // eslint-disable-next-line unicorn/no-process-exit
    process.exit(fail ? 1 : 0)
  }

  /**
   * Compile tests
   *
   * @param {"before" | "bundle" | "watch"} mode
   * @returns {Promise<import('./types').CompilerOutput>} file to be loaded in the page
   */
  async compiler(mode = 'bundle') {
    //
    throw new Error('abstract method')
  }
}