// Runs fixture-backed Game stories through the project's local Storybook toolchain.
import { spawn, type ChildProcess } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import fs from 'fs-extra'

const UI_TEST_TOOLCHAIN = {
  '@storybook/addon-essentials': '8.6.14',
  '@storybook/addon-interactions': '8.6.14',
  '@storybook/react': '8.6.14',
  '@storybook/react-vite': '8.6.14',
  '@storybook/test': '8.6.14',
  '@storybook/test-runner': '0.22.0',
  playwright: '1.62.1',
  storybook: '8.6.14',
  vite: '^4.5.14',
} as const

type GamePackage = {
  devDependencies?: Record<string, string>
}

// Runs a command and resolves once its process exits successfully.
function run(command: string, args: string[], cwd: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, { cwd, stdio: 'inherit' })
    child.once('error', reject)
    child.once('exit', (code, signal) => {
      if (code === 0) return resolve()
      reject(new Error(`UI test runner exited with ${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`}`))
    })
  })
}

// Verifies that declared and installed Storybook packages match the supported UI test toolchain.
async function validateUiTestToolchain(cwd: string, gamePackage: GamePackage): Promise<void> {
  for (const [name, expectedVersion] of Object.entries(UI_TEST_TOOLCHAIN)) {
    if (gamePackage.devDependencies?.[name] !== expectedVersion) {
      throw new Error(`Expected devDependency \`${name}\` to be exactly \`${expectedVersion}\`; found \`${gamePackage.devDependencies?.[name] ?? 'missing'}\`.`)
    }

    const installedPackagePath = path.join(cwd, 'node_modules', ...name.split('/'), 'package.json')
    if (!await fs.pathExists(installedPackagePath)) {
      throw new Error(`Missing installed dependency \`${name}\`. Run \`pnpm install\` from the game directory.`)
    }
    const installedPackage = await fs.readJson(installedPackagePath) as { version?: unknown }
    if (installedPackage.version !== expectedVersion.replace('^', '')) {
      throw new Error(`Installed \`${name}\` is \`${String(installedPackage.version)}\`, but the UI test toolchain requires \`${expectedVersion}\`. Run \`pnpm install\` after updating package.json.`)
    }
  }
}

// Finds an available local port without disturbing a developer's existing Storybook process.
function availablePort(): Promise<number> {
  return new Promise((resolve, reject) => {
    const server = net.createServer()
    server.once('error', reject)
    server.listen(0, '127.0.0.1', () => {
      const address = server.address()
      if (!address || typeof address === 'string') {
        reject(new Error('Could not allocate a local port for Storybook'))
        return
      }
      server.close((error) => error ? reject(error) : resolve(address.port))
    })
  })
}

// Starts Storybook and resolves once its browser preview endpoint is reachable.
async function startStorybook(storybook: string, cwd: string, port: number): Promise<ChildProcess> {
  const child = spawn(storybook, ['dev', '--ci', '--port', String(port)], { cwd, stdio: 'inherit' })
  const url = `http://127.0.0.1:${port}/index.json`
  let startupError: Error | undefined
  child.once('error', (error) => { startupError = error })

  for (let attempt = 0; attempt < 60; attempt += 1) {
    if (startupError) throw startupError
    if (child.exitCode !== null) throw new Error(`Storybook exited with code ${child.exitCode}`)
    try {
      const response = await fetch(url)
      if (response.ok) return child
    } catch {
      // Storybook has not finished starting yet.
    }
    await new Promise((resolve) => setTimeout(resolve, 500))
  }
  child.kill('SIGTERM')
  throw new Error('Timed out waiting for Storybook to start')
}

// Stops the temporary Storybook process before the test command exits.
function stopStorybook(child: ChildProcess): Promise<void> {
  if (child.exitCode !== null) return Promise.resolve()
  return new Promise((resolve) => {
    child.once('exit', () => resolve())
    child.kill('SIGTERM')
  })
}

// Runs all explicitly tagged UI stories after checking the project's Storybook setup.
export async function runUiTests(cwd: string): Promise<void> {
  const packagePath = path.join(cwd, 'package.json')
  const storybookConfigPath = path.join(cwd, '.storybook', 'main.ts')
  if (!await fs.pathExists(packagePath)) {
    throw new Error(`\`package.json\` not found in directory \`${cwd}\`.`)
  }
  if (!existsSync(storybookConfigPath)) {
    throw new Error(`Storybook configuration not found at \`${storybookConfigPath}\`. Reconnect the registered game in a clean folder or add .storybook/main.ts.`)
  }

  const gamePackage = await fs.readJson(packagePath) as GamePackage
  await validateUiTestToolchain(cwd, gamePackage)

  const binDirectory = path.join(cwd, 'node_modules', '.bin')
  const playwright = path.join(binDirectory, process.platform === 'win32' ? 'playwright.cmd' : 'playwright')
  const storybook = path.join(binDirectory, process.platform === 'win32' ? 'storybook.cmd' : 'storybook')
  const testRunner = path.join(binDirectory, process.platform === 'win32' ? 'test-storybook.cmd' : 'test-storybook')
  if (!await fs.pathExists(playwright) || !await fs.pathExists(storybook) || !await fs.pathExists(testRunner)) {
    throw new Error('Project-local UI test executables are missing. Run `pnpm install` from the game directory.')
  }

  console.log(`Running UI tests for ${path.basename(cwd)}...`)
  await run(playwright, ['install', 'chromium'], cwd)
  const port = await availablePort()
  let storybookProcess: ChildProcess | undefined
  try {
    storybookProcess = await startStorybook(storybook, cwd, port)
    await run(testRunner, ['--url', `http://127.0.0.1:${port}`, '--includeTags', 'ui-test'], cwd)
  } finally {
    if (storybookProcess) await stopStorybook(storybookProcess)
  }
}
