// Copyright 2018-2025 the Deno authors. MIT license.

import { spawn } from 'node:child_process'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
import { setTimeout as sleep } from 'node:timers/promises'
import { debuglog } from 'node:util'
import {
  sanitizeUnpairedSurrogates
} from './runner/utils.mjs'
import * as jsondiffpatch from 'jsondiffpatch'

const REPO_ROOT = join(import.meta.dirname, '..', '..')
const WPT_DIR = join(import.meta.dirname, 'wpt')
const WPT_SCRIPT_PATH = join(WPT_DIR, 'wpt')
const EXPECTATION_PATH = join(import.meta.dirname, 'expectation.json')
const CA_CERT_PATH = join(import.meta.dirname, 'runner/certs/cacert.pem')

const log = debuglog('UNDICI_WPT')
const WPT_SERVER_URL = 'http://web-platform.test:8000'
const PYTHON_CANDIDATES = ['python3', 'python']

let pythonInfoPromise

const SERVER_READY_CHECKS = [
  ['http-default', (line) => line.includes('http on port 8000') && line.includes('Starting http server')],
  ['http-alt', (line) => /\bhttp on port (?!8000\b)\d+\].*Starting http server/.test(line)],
  ['http-local', (line) => line.includes('http-local on port') && line.includes('Starting http server')],
  ['http-public', (line) => line.includes('http-public on port') && line.includes('Starting http server')],
  ['https-8443', (line) => line.includes('https on port 8443') && line.includes('Starting https server')],
  ['https-8444', (line) => line.includes('https on port 8444') && line.includes('Starting https server')],
  ['https-local', (line) => line.includes('https-local on port') && line.includes('Starting https server')],
  ['https-public', (line) => line.includes('https-public on port') && line.includes('Starting https server')],
  ['ws', (line) => line.includes('ws on port') && line.includes('Listen on:')],
  ['wss', (line) => line.includes('wss on port') && line.includes('Listen on:')],
  ['h2', (line) => line.includes('h2 on port 9000') && line.includes('Starting http2 server')]
]

function streamServerLogs (stream, target, onLine) {
  let buffer = ''

  stream.setEncoding('utf8')
  stream.on('data', (chunk) => {
    target.write(chunk)
    buffer += chunk

    let endIndex
    while ((endIndex = buffer.indexOf('\n')) !== -1) {
      onLine(buffer.slice(0, endIndex))
      buffer = buffer.slice(endIndex + 1)
    }
  })

  stream.on('end', () => {
    if (buffer.length > 0) {
      onLine(buffer)
    }
  })
}

async function terminateProcess (proc, exitPromise) {
  if (proc.exitCode != null) {
    await exitPromise
    return
  }

  if (process.platform === 'win32') {
    try {
      // SIGINT does not reliably terminate the Python WPT server tree on Windows.
      await new Promise((resolve) => {
        const killer = spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
          stdio: 'ignore'
        })

        killer.once('error', resolve)
        killer.once('exit', resolve)
      })
    } catch {
      proc.kill()
    }
  } else {
    proc.kill('SIGINT')

    const exited = await Promise.race([
      exitPromise.then(() => true),
      new Promise(resolve => setTimeout(resolve, 1_000, false))
    ])

    if (!exited && proc.exitCode == null) {
      proc.kill('SIGKILL')
    }
  }

  await exitPromise
}

function parsePythonVersion (output) {
  const versionRegex = /^Python (?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/m.exec(output.trim())

  if (versionRegex === null) {
    return null
  }

  const { major, minor, patch } = versionRegex.groups
  return { major: Number(major), minor: Number(minor), patch: Number(patch) }
}

async function getPythonInfo () {
  pythonInfoPromise ??= (async () => {
    const failures = []

    for (const command of PYTHON_CANDIDATES) {
      const result = await new Promise((resolve) => {
        const proc = spawn(command, ['--version'], {
          stdio: ['ignore', 'pipe', 'pipe']
        })

        let output = ''
        let settled = false
        const onData = (chunk) => {
          output += chunk.toString()
        }

        proc.stdout.on('data', onData)
        proc.stderr.on('data', onData)

        const timeout = setTimeout(() => {
          if (!settled) {
            settled = true
            proc.kill()
            resolve({ command, reason: 'timed out while checking version' })
          }
        }, 10_000)

        proc.once('error', (err) => {
          if (settled) {
            return
          }

          settled = true
          clearTimeout(timeout)
          resolve({ command, reason: err.message })
        })

        proc.once('exit', (code) => {
          if (settled) {
            return
          }

          settled = true
          clearTimeout(timeout)

          const version = parsePythonVersion(output)
          if (code === 0 && version !== null && version.major === 3) {
            resolve({ command, version })
            return
          }

          const reason = version !== null
            ? `found unsupported Python ${version.major}.${version.minor}.${version.patch}`
            : output.trim() || `exited with code ${code}`
          resolve({ command, reason })
        })
      })

      if ('version' in result) {
        return result
      }

      failures.push(`${command}: ${result.reason}`)
    }

    throw new Error(`Python 3 is required. Checked: ${failures.join('; ')}`)
  })()

  return pythonInfoPromise
}

async function ensureWPTCheckout () {
  if (existsSync(WPT_SCRIPT_PATH)) {
    return
  }

  console.log('WPT checkout missing, attempting to initialize git submodule...')

  const submoduleProc = spawn('git', [
    'submodule',
    'update',
    '--init',
    '--recursive',
    '--',
    'test/web-platform-tests/wpt'
  ], {
    cwd: REPO_ROOT,
    stdio: 'inherit'
  })

  const submoduleOk = await new Promise(resolve => {
    submoduleProc.on('exit', code => resolve(code === 0))
    submoduleProc.on('error', () => resolve(false))
  })

  if (!submoduleOk || !existsSync(WPT_SCRIPT_PATH)) {
    throw new Error('WPT checkout is missing. Run `git submodule update --init --recursive test/web-platform-tests/wpt`.')
  }
}

async function startWPTServer () {
  const { command: pythonCommand } = await getPythonInfo()
  const { promise, resolve, reject } = Promise.withResolvers()
  const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers()
  const readyChecks = new Set()
  let serverResponding = false
  let readySettled = false

  const maybeResolveReady = () => {
    if (!readySettled && serverResponding && readyChecks.size === SERVER_READY_CHECKS.length) {
      readySettled = true
      resolveReady()
    }
  }

  const onServerLine = (line) => {
    for (const [name, matches] of SERVER_READY_CHECKS) {
      if (!readyChecks.has(name) && matches(line)) {
        readyChecks.add(name)
        maybeResolveReady()
      }
    }
  }

  const proc = spawn(pythonCommand, [WPT_SCRIPT_PATH, 'serve', '--config', '../runner/config.json'], {
    cwd: WPT_DIR,
    stdio: ['ignore', 'pipe', 'pipe']
  })

  streamServerLogs(proc.stdout, process.stdout, onServerLine)
  streamServerLogs(proc.stderr, process.stderr, onServerLine)

  proc.once('exit', (code, signal) => {
    if (!readySettled) {
      readySettled = true
      rejectReady(new Error(`WPT server exited before it was ready (code: ${code}, signal: ${signal ?? 'none'})`))
    }

    resolve()
  })

  proc.once('error', (err) => {
    if (!readySettled) {
      readySettled = true
      rejectReady(err)
    }

    reject(err)
  })

  const readinessTimeout = setTimeout(() => {
    if (!readySettled) {
      readySettled = true
      const missing = SERVER_READY_CHECKS
        .map(([name]) => name)
        .filter((name) => !readyChecks.has(name))
        .join(', ')
      rejectReady(new Error(`Timed out waiting for WPT server readiness. Missing: ${missing}`))
    }
  }, 30_000)

  try {
    while (!serverResponding && !proc.killed && proc.exitCode == null) {
      await new Promise(resolve => setTimeout(resolve, 100))

      try {
        const req = await fetch(WPT_SERVER_URL) // eslint-disable-line no-restricted-globals
        await req.body?.cancel()
        if (req.status === 200) {
          serverResponding = true
          maybeResolveReady()
        }
      } catch {
        // Server not ready yet
      }
    }

    await readyPromise
    return { proc, exitPromise: promise, readinessTimeout }
  } catch (err) {
    clearTimeout(readinessTimeout)

    try {
      await terminateProcess(proc, promise)
    } catch {}

    throw err
  }
}

async function runWithTestUtil (testFunction) {
  const maxRetries = 3
  let lastError

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    console.log(`Starting WPT server (attempt ${attempt}/${maxRetries})...`)

    try {
      const { proc, exitPromise, readinessTimeout } = await startWPTServer()

      console.log(`✅ WPT server started at ${WPT_SERVER_URL}`)

      try {
        const results = await testFunction()
        return results
      } finally {
        clearTimeout(readinessTimeout)
        console.log('Killing WPT server')
        await terminateProcess(proc, exitPromise)
      }
    } catch (err) {
      lastError = err

      if (attempt < maxRetries) {
        console.log(`⚠️ WPT server failed to start: ${err.message}`)
        console.log('Retrying in 2 seconds...')
        await sleep(2_000)
      }
    }
  }

  throw lastError
}

function runSingleTest (url, options, expectation, timeout = 10000) {
  const startTime = Date.now()
  const { promise, resolve, reject } = Promise.withResolvers()
  // NODE_EXTRA_CA_CERTS is required for HTTPS/WSS pages, but it causes the
  // WebSocket-over-HTTP/2 WPT variants to exit without emitting harness output.
  const useExtraCACerts = !(url.pathname.startsWith('/websockets/') && url.searchParams.get('wpt_flags')?.includes('h2'))

  const proc = spawn('node', [
    '--expose-gc',
    '--no-warnings',
    join(import.meta.dirname, 'runner/test-runner.mjs'),
    url.toString()
  ], {
    stdio: ['ignore', 'pipe', 'pipe'],
    env: {
      ...process.env,
      NO_COLOR: '1',
      ...(useExtraCACerts ? { NODE_EXTRA_CA_CERTS: CA_CERT_PATH } : {})
    }
  })

  const cases = []
  let harnessStatus = null
  let stdoutOutput = ''
  let stderrOutput = ''
  let error

  const timer = setTimeout(() => {
    if (!proc.killed) {
      proc.kill('SIGINT')
    }
  }, timeout)

  proc.stdout.setEncoding('utf-8')
  proc.stdout.on('data', (chunk) => {
    stdoutOutput += chunk

    let delimiterIndex
    while ((delimiterIndex = stdoutOutput.indexOf('#$#$#')) !== -1) {
      const endIndex = stdoutOutput.indexOf('\n', delimiterIndex)
      if (endIndex !== -1) {
        const message = stdoutOutput.slice(delimiterIndex + 5, endIndex)
        try {
          const { tests, harnessStatus: _harnessStatus } = JSON.parse(message)
          harnessStatus = _harnessStatus
          cases.push(...tests)
        } catch {
          console.error('Failed to parse:', message)
        }
        stdoutOutput = stdoutOutput.slice(endIndex + 1)
      } else {
        break
      }
    }
  })

  proc.stderr.setEncoding('utf-8')
  proc.stderr.on('data', (chunk) => {
    stderrOutput += chunk

    let delimiterIndex
    while ((delimiterIndex = stderrOutput.indexOf('!#!#!#')) !== -1) {
      const endIndex = stderrOutput.indexOf('\n', delimiterIndex)
      if (endIndex !== -1) {
        const message = stderrOutput.slice(delimiterIndex + 6, endIndex)
        ;({ error } = JSON.parse(message))
        stderrOutput = stderrOutput.slice(endIndex + 1)
      } else {
        break // Wait for more data
      }
    }
  })

  proc.once('close', () => {
    clearTimeout(timer)
    const duration = Date.now() - startTime

    resolve({
      status: harnessStatus?.status ?? 1,
      harnessStatus,
      duration,
      cases,
      error
    })
  })

  proc.once('error', (err) => reject(err))

  return promise
}

function getExpectation () {
  return JSON.parse(readFileSync(EXPECTATION_PATH, 'utf8'))
}

function updateExpectations (results) {
  const expectations = getExpectation()

  for (const { test, result } of results) {
    const pathSegments = test.path.slice(1).split('/')
    const filename = pathSegments.pop()

    // Navigate to the correct nested object
    let current = expectations
    for (const segment of pathSegments) {
      current[segment] ??= {}
      current = current[segment]
    }

    // Set the expectation based on test result
    const currentFilename = current[filename]

    current[filename] = {
      success: typeof currentFilename?.success === 'string'
        ? currentFilename.success
        : result.status === 0, // If test file itself did not error
      cases: result.cases.map((c) => {
        const currentCase = current[filename]?.cases.find((cc) => cc.name === c.name)

        if (currentCase?.flaky) {
          return {
            name: c.name,
            flaky: true
          }
        }

        return {
          name: c.name,
          success: c.status === 0,
          message: c.message ?? undefined
        }
      })
    }
  }

  writeFileSync(EXPECTATION_PATH, JSON.stringify(expectations, null, 2) + '\n')
  console.log(`✅ Updated expectations file: ${EXPECTATION_PATH}`)
}

function getManifest () {
  const manifestPath = join(WPT_DIR, 'MANIFEST.json')
  if (!existsSync(manifestPath)) {
    throw new Error('MANIFEST.json not found. Run setup first.')
  }
  return JSON.parse(readFileSync(manifestPath, 'utf8'))
}

function discoverTestsToRun (filter, expectation) {
  const manifest = getManifest()
  const tests = []

  function walkManifest (folder, parentExpectation, prefix) {
    for (const [key, entry] of Object.entries(folder)) {
      if (Array.isArray(entry)) {
        // Test file
        for (const [path, options] of entry.slice(1)) {
          if (!key.endsWith('.html') && !key.endsWith('.js')) continue

          const testPath = path || `${prefix}/${key}`
          const url = new URL(testPath, WPT_SERVER_URL)

          if (url.pathname.includes('.worker.') ||
              url.pathname.includes('serviceworker') ||
              url.pathname.includes('sharedworker') ||
              url.pathname.includes('shadowrealm')) {
            continue
          }

          const finalPath = url.pathname + url.search
          if (!filter.some((filter) => finalPath.startsWith(filter) || finalPath.slice(1).startsWith(filter))) {
            continue
          }

          const pathSegments = finalPath.slice(1).split('/')

          const filename = pathSegments[pathSegments.length - 1]
          const testExpectation = parentExpectation?.[filename]

          tests.push({
            path: finalPath,
            url,
            options: options || { script_metadata: [] },
            expectation: testExpectation
          })
        }
      } else {
        const folderExpectation =
          Array.isArray(parentExpectation) || typeof parentExpectation === 'boolean'
            ? parentExpectation
            : parentExpectation?.[key]

        walkManifest(entry, folderExpectation, `${prefix}/${key}`)
      }
    }
  }

  if (manifest.items?.testharness) {
    walkManifest(manifest.items.testharness, expectation, '')
  }

  return tests
}

function generateWPTReport (results, startTime, endTime) {
  const reportResults = []

  for (const { test, result } of results) {
    const status = result.status !== 0
      ? 'CRASH'
      : result.harnessStatus?.status === 0
        ? 'OK'
        : 'ERROR'

    const message = result.harnessStatus?.message ?? result.error?.message ?? null

    const reportResult = {
      test: test.path,
      subtests: result.cases.map((c) => {
        let expected
        if (c.status !== 0) {
          const { success, cases } = test.expectation ?? {}
          if (success === false) { // If the test failed to load
            expected = 'FAIL'
          } else if (Array.isArray(cases)) {
            const theCase = cases.find((aCase) => aCase.name === c.name)
            expected = theCase && !theCase.success ? 'FAIL' : 'PASS'
          }
        }

        return {
          name: sanitizeUnpairedSurrogates(c.name),
          status: c.status === 0 ? 'PASS' : 'FAIL',
          message: c.message ? sanitizeUnpairedSurrogates(c.message) : null,
          expected,
          known_intermittent: []
        }
      }),
      status,
      message: message ? sanitizeUnpairedSurrogates(message) : null,
      duration: result.duration,
      expected: status === 'OK' ? undefined : 'OK',
      known_intermittent: []
    }

    reportResults.push(reportResult)
  }

  return {
    time_start: startTime,
    time_end: endTime,
    results: reportResults
  }
}

async function setup () {
  console.log('Setting up WPT environment...')

  await ensureWPTCheckout()

  const { command: pythonCommand, version: pythonVersion } = await getPythonInfo()
  console.log(`Using Python command: ${pythonCommand} (${pythonVersion.major}.${pythonVersion.minor}.${pythonVersion.patch})`)

  // Check if manifest exists
  const manifestPath = join(WPT_DIR, 'MANIFEST.json')
  if (!existsSync(manifestPath)) {
    console.log('Updating WPT manifest...')
    const manifestProc = spawn(pythonCommand, [WPT_SCRIPT_PATH, 'manifest'], {
      cwd: WPT_DIR,
      stdio: 'inherit'
    })
    const manifestOk = await new Promise(resolve => {
      manifestProc.on('exit', code => resolve(code === 0))
      manifestProc.on('error', () => resolve(false))
    })

    if (!manifestOk) {
      throw new Error('Failed to update manifest')
    }
  } else {
    console.log('Using existing WPT manifest')
  }

  // Configure hosts file
  const hostsPath = process.platform === 'win32'
    ? `${process.env.SystemRoot}\\System32\\drivers\\etc\\hosts`
    : '/etc/hosts'

  const hostsContent = existsSync(hostsPath) ? readFileSync(hostsPath, 'utf8') : ''
  const etcHostsConfigured = hostsContent.includes('web-platform.test')

  async function setupHostsFile () {
    const makeHostsProc = spawn(pythonCommand, [WPT_SCRIPT_PATH, 'make-hosts-file'], {
      cwd: WPT_DIR,
      stdio: ['ignore', 'pipe', 'pipe']
    })

    let stdout = ''
    makeHostsProc.stdout.setEncoding('utf-8')
    makeHostsProc.stdout.on('data', (data) => stdout += data) // eslint-disable-line no-return-assign

    const success = await new Promise(resolve => {
      makeHostsProc.on('exit', code => resolve(code === 0))
    })

    if (success) {
      try {
        const entries = '\n\n# Configured for Web Platform Tests (Node.js)\n' + stdout
        writeFileSync(hostsPath, entries)
        console.log(`Updated ${hostsPath}`)
      } catch (err) {
        console.error(`Failed to write to ${hostsPath}. Please run with sudo or configure manually.`)
        throw err
      }
    } else {
      throw new Error('Failed to generate hosts entries')
    }
  }

  if (etcHostsConfigured) {
    console.log(hostsPath + ' is already configured.')
  } else if (!process.env.CI) {
    const rl = createInterface({
      input: process.stdin,
      output: process.stdout
    })

    /** @type {Promise<string>} */
    const answer = await new Promise((resolve) => {
      rl.question(
        `The WPT require certain entries to be present in your ${hostsPath} file. Should these be configured automatically? (y/n): `,
        resolve
      )
    }).finally(() => rl.close()).then((a) => a.trim().toLowerCase())

    let hostsModified = false
    if (answer === 'y' || answer === 'yes') {
      try {
        await setupHostsFile()
        hostsModified = true
      } catch (err) {
        console.error('❌ \x1B[31mAutomatic configuration failed.\x1B[0m')
      }
    }
    if (!hostsModified) {
      console.log('Please configure hosts file manually:')
      console.log(`cd ${WPT_DIR}`)
      if (process.platform === 'win32') {
        console.log(`${pythonCommand} wpt make-hosts-file | Out-File $env:SystemRoot\\System32\\drivers\\etc\\hosts -Encoding ascii -Append`)
      } else {
        console.log(`${pythonCommand} wpt make-hosts-file | sudo tee -a /etc/hosts`)
      }

      console.log('❌ \x1B[31mSetup incomplete.\x1B[0m')
      process.exit(1) // eslint-disable-line n/no-process-exit
    }
  }

  console.log('✅ Setup complete!')
}

async function run (filters = []) {
  await ensureWPTCheckout()

  const startTime = Date.now()
  const expectation = getExpectation()
  const tests = discoverTestsToRun(filters, expectation)

  console.log(`Going to run ${tests.length} test files`)

  const results = await runWithTestUtil(async () => {
    const testResults = []

    for (const test of tests) {
      console.log(`${'='.repeat(40)}\n${test.path}\n`)

      const timeout = test.options.timeout === 'long' ? 60_000 : 10_000
      const result = await runSingleTest(test.url, test.options, test.expectation, timeout)

      testResults.push({ test, result })

      console.log(`${test.path}: ${result.cases.length} tests ran in ${result.duration}ms:`)

      if (result.cases.length === 0) {
        console.log(`\t??. ❌ ${result.error?.message ?? 'N/A'}`)
      }

      for (const c of result.cases) {
        console.log(`\t${c.index + 1}. "${c.name}": ${c.status === 0 ? '✅ PASS' : '❌ FAIL'}`)

        if (c.status !== 0 && (c.message || c.stack)) {
          log(`${c.message}:\n${c.stack.split('\n').slice(1).join('\n')}`)
        }
      }
    }

    return testResults
  })

  const endTime = Date.now()
  console.log(`\nCompleted in ${endTime - startTime}ms`)

  // Calculate summary
  const totalTests = results.length
  const { pass, fail } = results.reduce((curr, { result }) => {
    for (const c of result.cases) {
      if (c.status !== 0) {
        curr.fail++
      } else {
        curr.pass++
      }
    }

    return curr
  }, { pass: 0, fail: 0 })

  console.log('\n' + '='.repeat(50))
  console.log('TEST SUMMARY')
  console.log('='.repeat(50))
  console.log(`Total Test Files: ${totalTests}`)
  console.log(`✅ Passing: ${pass}`)
  console.log(`❌ Failing: ${fail}`)
  console.log('='.repeat(50))

  if (process.env.WPT_REPORT) {
    const report = generateWPTReport(results, startTime, endTime)
    writeFileSync(process.env.WPT_REPORT, JSON.stringify(report))
  } else {
    const oldExpectations = getExpectation()
    updateExpectations(results)

    const jsondiff = jsondiffpatch.create({
      propertyFilter: (name) => {
        return name === 'success'
      }
    })

    const diff = jsondiff.diff(oldExpectations, getExpectation())
    process.exitCode = diff === undefined ? 0 : 1

    if (diff !== undefined) {
      console.dir(diff, { depth: Infinity })
    }
  }
}

// CLI
const command = process.argv[2]
const filters = process.argv.slice(3)

switch (command) {
  case 'setup':
    await setup()
    break
  case 'run':
    await run(filters)
    break
  default:
    console.log(`
WPT Test Runner for Node.js

Commands:
  setup                    Configure environment
  run [filter...]          Run tests
`)
    break
}
