import {
  Before,
  After,
  BeforeAll,
  AfterAll,
  setDefaultTimeout,
} from '@cucumber/cucumber'
import { cp, mkdir, rm } from 'fs/promises'
import { spawn, execFileSync, type ChildProcess } from 'child_process'
import { resolve, dirname } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { createRequire } from 'module'
import type { AppWorld } from './world.js'
import { config } from './types.js'

setDefaultTimeout(config.responseTimeout)

let backendProcess: ChildProcess | undefined
let frontendProcess: ChildProcess | undefined
let goldenDbPath: string | undefined
let scenarioDbPath: string | undefined

async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    try {
      const res = await fetch(url)
      if (res.status < 500) return
    } catch {
      // not ready yet
    }
    await new Promise((r) => setTimeout(r, 500))
  }
  throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`)
}

function startProcess({
  cmd,
  cwd,
  env,
  label,
  detached = true,
}: {
  cmd: string
  cwd: string
  env: NodeJS.ProcessEnv
  label: string
  detached?: boolean
}) {
  const p = spawn(cmd, {
    cwd,
    env,
    stdio: 'pipe',
    shell: true,
    detached,
  })
  p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`))
  p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`))
  return p
}

async function stopProcess(p: ChildProcess | undefined) {
  if (!p?.pid) return
  try {
    process.kill(-p.pid, 'SIGTERM')
  } catch {
    try {
      p.kill('SIGTERM')
    } catch {
      // ignore
    }
  }
  await new Promise((r) => setTimeout(r, 1000))
}

function killPort(port: number) {
  try {
    const out = execFileSync('lsof', ['-ti', `tcp:${port}`], {
      encoding: 'utf8',
    }).trim()
    if (!out) return
    for (const pid of out.split(/\s+/)) {
      if (!pid) continue
      try {
        process.kill(Number(pid), 'SIGTERM')
      } catch {
        // ignore
      }
    }
  } catch {
    // no listeners / lsof unavailable
  }
}

const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '../../..')
const runtimeDir = resolve(repoRoot, '.e2e-runtime')
const functionsOutDir = resolve(repoRoot, 'packages/functions/.pikku')
const defaultFrontendCmd = 'bun run dev'

// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
// importable by bare specifier. Resolve the package dir via the `.` export and
// dynamic-import the file by absolute path — the exports map only governs bare
// specifiers, not absolute paths. Lazy (not top-level) because cucumber
// transpiles this module to CJS, which bans top-level await.
async function loadDbHelpers() {
  const cliBinDir = dirname(createRequire(import.meta.url).resolve('@pikku/cli'))
  return import(
    pathToFileURL(resolve(cliBinDir, '../src/functions/db/local-db.js')).href
  )
}

// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
// registered ESM loader the CLI's memoized sqlite runtime reads an empty schema;
// a fresh DatabaseSync against the same file sees the tables correctly.
async function seedGoldenDb(dbFile: string, seedFile: string) {
  const { readFileSync, existsSync } = await import('fs')
  if (!seedFile || !existsSync(seedFile)) return
  const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
    DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void }
  }>)
  const db = new DatabaseSync(dbFile)
  db.exec('BEGIN')
  try {
    db.exec(readFileSync(seedFile, 'utf8'))
    db.exec('COMMIT')
  } catch (err) {
    db.exec('ROLLBACK')
    throw err
  } finally {
    db.close()
  }
}

async function buildGoldenDb() {
  const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers()
  await mkdir(runtimeDir, { recursive: true })
  goldenDbPath = resolve(runtimeDir, 'golden.db')
  await rm(goldenDbPath, { force: true })
  const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir)
  if (!resolved) {
    throw new Error('[e2e] failed to resolve local DB config')
  }
  reset(resolved, repoRoot)
  await migrateAndCodegen(resolved)
  await seedGoldenDb(resolved.dbFile, resolved.seedFile)
}

async function startScenarioBackend(scenarioId: string) {
  if (!goldenDbPath) {
    throw new Error('[e2e] golden DB missing')
  }
  await stopProcess(backendProcess)
  killPort(4003)
  scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`)
  await rm(scenarioDbPath, { force: true })
  await cp(goldenDbPath, scenarioDbPath)
  backendProcess = startProcess({
    cmd: 'bun run dev:backend',
    cwd: repoRoot,
    env: {
      ...process.env,
      NODE_ENV: 'test',
      PIKKU_DEV_DB_FILE: scenarioDbPath,
      DATABASE_URL: `file:${scenarioDbPath}`,
    },
    label: 'backend',
  })
  await waitForUrl(config.apiUrl, 'backend')
}

BeforeAll(async function () {
  process.env.API_URL = process.env.API_URL ?? 'http://localhost:4003'
  process.env.APP_URL = process.env.APP_URL ?? 'http://localhost:7104'

  if (config.isolatedDb) {
    await buildGoldenDb()
  }

  if (!config.manageServers) {
    if (config.isolatedDb) {
      throw new Error('[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1')
    }
    return
  }

  const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd
  killPort(7104)

  frontendProcess = startProcess({
    cmd: frontendCmd,
    cwd: repoRoot,
    env: {
      ...process.env,
      NODE_ENV: 'test',
      VITE_API_URL: process.env.API_URL,
    },
    label: 'frontend',
  })
  await waitForUrl(config.appUrl, 'frontend')
})

AfterAll(async function () {
  await stopProcess(backendProcess)
  await stopProcess(frontendProcess)
  if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {})
  if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {})
})

Before(async function (this: AppWorld, { pickle }) {
  if (config.isolatedDb) {
    const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, '_')
    await startScenarioBackend(`scenario-${scenarioId}`)
  }
  await this.openBrowser()
  if (!config.isolatedDb && config.resetUrl) {
    const body = config.resetRpcName
      ? JSON.stringify({ rpcName: config.resetRpcName, data: {} })
      : undefined
    await fetch(config.resetUrl, {
      method: 'POST',
      headers: body ? { 'content-type': 'application/json' } : undefined,
      body,
    })
      .then((res) => {
        if (!res.ok) {
          process.stderr.write(
            `[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`,
          )
        }
      })
      .catch(() => {
        process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`)
      })
  }
})

After(async function (this: AppWorld) {
  await this.closeBrowser()
  if (config.isolatedDb) {
    await stopProcess(backendProcess)
    backendProcess = undefined
    if (scenarioDbPath) {
      await rm(scenarioDbPath, { force: true }).catch(() => {})
      scenarioDbPath = undefined
    }
  }
})
