import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { resolve } from 'node:path'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'

interface DockerCommand {
  command: string
  prefix: string[]
}

const webRepo = resolve(import.meta.dirname, '../../..')
const backendRepo = resolve(webRepo, '../BatteryDetection')
const artifactDir = resolve(webRepo, 'tests/e2e/.artifacts')
const backendJar = resolve(backendRepo, 'ruoyi-admin/target/ruoyi-admin.jar')
const apiPort = Number(process.env.TICKET04_API_PORT || 18001)
const mysqlPort = Number(process.env.TICKET04_MYSQL_PORT || 63306)
const redisPort = Number(process.env.TICKET04_REDIS_PORT || 56379)
const simulatorPort = Number(process.env.TICKET04_SIMULATOR_PORT || 15021)
const mysqlImage = process.env.TICKET04_MYSQL_IMAGE || 'public.ecr.aws/docker/library/mysql:8.0.36'
const redisImage = process.env.TICKET04_REDIS_IMAGE || 'public.ecr.aws/docker/library/redis:7.2-alpine'
const mysqlPassword = 'ticket04-mysql'
const redisPassword = 'ticket04-redis'
const suffix = `${process.pid}`

function commandAvailable(command: string, args: string[]) {
  const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true })
  return result.status === 0
}

function resolveDocker(): DockerCommand {
  const candidates: DockerCommand[] = process.platform === 'win32'
    ? [{ command: 'docker.exe', prefix: [] }, { command: 'wsl.exe', prefix: ['-e', 'docker'] }]
    : [{ command: 'docker', prefix: [] }]
  const docker = candidates.find(item => commandAvailable(item.command, [...item.prefix, 'version']))
  if (!docker) throw new Error('Ticket04 浏览器验收需要可用的 Docker 或 WSL Docker')
  return docker
}

async function assertPortAvailable(port: number) {
  await new Promise<void>((resolvePromise, reject) => {
    const server = createServer()
    server.once('error', () => reject(new Error(`Ticket04 隔离端口 ${port} 已被占用`)))
    server.listen(port, '127.0.0.1', () => server.close(() => resolvePromise()))
  })
}

function run(command: string, args: string[], cwd: string) {
  const executable = process.platform === 'win32' ? 'cmd.exe' : command
  const executableArgs = process.platform === 'win32'
    ? ['/d', '/s', '/c', command, ...args]
    : args
  const result = spawnSync(executable, executableArgs, {
    cwd, encoding: 'utf8', stdio: 'inherit', windowsHide: true,
  })
  if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} 执行失败`)
}

async function waitUntil(check: () => boolean | Promise<boolean>, message: string, timeoutMs = 120_000) {
  const deadline = Date.now() + timeoutMs
  while (Date.now() < deadline) {
    if (await check()) return
    await new Promise(resolvePromise => setTimeout(resolvePromise, 1_000))
  }
  throw new Error(message)
}

function waitForProcessClose(child: ChildProcess, timeoutMs: number) {
  if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
  return new Promise<boolean>((resolvePromise) => {
    const onClose = () => {
      clearTimeout(timer)
      resolvePromise(true)
    }
    const timer = setTimeout(() => {
      child.off('close', onClose)
      resolvePromise(false)
    }, timeoutMs)
    child.once('close', onClose)
  })
}

export class Ticket04Stack {
  private docker!: DockerCommand
  private backend: ChildProcess | null = null
  private readonly mysqlContainer = `battery-ticket04-mysql-${suffix}`
  private readonly redisContainer = `battery-ticket04-redis-${suffix}`

  readonly backendUrl = `http://127.0.0.1:${apiPort}`

  async start(profiles = 'druid,dev-simulator') {
    await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable))
    this.docker = resolveDocker()
    mkdirSync(artifactDir, { recursive: true })
    writeFileSync(resolve(artifactDir, 'backend.log'), '')
    try {
      this.dockerRun([
        'run', '--detach', '--rm', '--name', this.mysqlContainer,
        '-e', `MYSQL_ROOT_PASSWORD=${mysqlPassword}`,
        '-e', 'MYSQL_DATABASE=battery_detection',
        '-p', `127.0.0.1:${mysqlPort}:3306`,
        mysqlImage, '--default-authentication-plugin=mysql_native_password',
      ])
      this.dockerRun([
        'run', '--detach', '--rm', '--name', this.redisContainer,
        '-p', `127.0.0.1:${redisPort}:6379`,
        redisImage, 'redis-server', '--requirepass', redisPassword, '--appendonly', 'yes',
      ])
      await this.waitForContainers()
      this.buildBackend()
      await this.startBackend(profiles)
      this.disableCaptchaInIsolatedDatabase()
    } catch (error) {
      await this.stop()
      throw error
    }
  }

  async restartBackend(profiles = 'druid,dev-simulator') {
    await this.stopBackend()
    await this.startBackend(profiles)
  }

  queryDatabase(sql: string) {
    if (!/^SELECT\s/i.test(sql)) throw new Error('验收查询只允许 SELECT')
    return this.dockerRun(['exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
      '--database=battery_detection', '--batch', '--skip-column-names', '-e', sql]).stdout.trim()
  }

  // 仅操作本次启动的临时容器，用于受控验收数据及事务故障注入。
  executeIsolatedSql(sql: string) {
    return this.dockerRun(['exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
      '--database=battery_detection', '--default-character-set=utf8mb4', '-e', sql]).stdout
  }

  redisCommand(...args: string[]) {
    const result = this.dockerRun([
      'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, '--no-auth-warning', '--raw', ...args,
    ])
    return result.stdout.trim()
  }

  simulatorState<T>() {
    const stored = JSON.parse(this.redisCommand('GET', 'dev:bts-simulator:state:v1'))
    return (typeof stored === 'string' ? JSON.parse(stored) : stored) as T
  }

  backendLog() {
    return readFileSync(resolve(artifactDir, 'backend.log'), 'utf8')
  }

  async replaceSimulatorStateAndRestart(state: unknown) {
    await this.stopBackend()
    const registryKey = 'dev:bts-simulator:collector-history-keys:v1'
    const registeredHistory = this.redisCommand('SMEMBERS', registryKey)
      .split(/\r?\n/).filter(Boolean)
      .map(value => {
        const decoded = JSON.parse(value)
        return typeof decoded === 'string' ? decoded : value
      })
    if (registeredHistory.length > 0) this.redisCommand('DEL', ...registeredHistory)
    this.redisCommand('DEL', registryKey)
    this.redisCommand('SET', 'dev:bts-simulator:state:v1', JSON.stringify(JSON.stringify(state)))
    await this.startBackend()
  }

  async stop() {
    await this.stopBackend()
    if (!this.docker) return
    this.dockerRun(['rm', '--force', this.mysqlContainer], true)
    this.dockerRun(['rm', '--force', this.redisContainer], true)
  }

  private dockerRun(args: string[], allowFailure = false) {
    const result = spawnSync(this.docker.command, [...this.docker.prefix, ...args], {
      encoding: 'utf8', windowsHide: true,
    })
    if (!allowFailure && result.status !== 0) {
      throw new Error(`Docker 命令失败: ${result.stderr || result.stdout}`)
    }
    return result
  }

  private async waitForContainers() {
    await waitUntil(() => {
      const logs = this.dockerRun(['logs', this.mysqlContainer], true)
      return `${logs.stdout}\n${logs.stderr}`.includes('MySQL init process done. Ready for start up.')
    }, '隔离 MySQL 8 初始化未完成')
    await waitUntil(() => this.dockerRun([
      'exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
      '--database=battery_detection', '--skip-column-names', '-e', 'SELECT 1',
    ], true).status === 0, '隔离 MySQL 8 未就绪')
    await waitUntil(() => this.dockerRun([
      'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, 'ping',
    ], true).status === 0, '隔离 Redis 未就绪')
  }

  private buildBackend() {
    if (process.env.TICKET04_SKIP_BACKEND_BUILD === '1' && existsSync(backendJar)) return
    run(process.platform === 'win32' ? 'mvn.cmd' : 'mvn',
      ['-pl', 'ruoyi-admin', '-am', 'package', '-DskipTests'], backendRepo)
    if (!existsSync(backendJar)) throw new Error(`未生成后端可执行包: ${backendJar}`)
  }

  private async startBackend(profiles = 'druid,dev-simulator') {
    const logPath = resolve(artifactDir, 'backend.log')
    const log = createWriteStream(logPath, { flags: 'a' })
    const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true`
    this.backend = spawn('java', [
      '-jar', backendJar,
      `--server.port=${apiPort}`,
      `--spring.datasource.druid.master.url=${jdbcUrl}`,
      '--spring.datasource.druid.master.username=root',
      `--spring.datasource.druid.master.password=${mysqlPassword}`,
      '--spring.data.redis.host=127.0.0.1',
      `--spring.data.redis.port=${redisPort}`,
      `--spring.data.redis.password=${redisPassword}`,
      '--spring.data.redis.database=0',
      `--spring.profiles.active=${profiles}`,
      '--spring.devtools.restart.enabled=false',
      `--battery.dev-simulator.port=${simulatorPort}`,
      `--ruoyi.profile=${artifactDir}`,
    ], {
      cwd: backendRepo,
      env: { ...process.env, LOG_PATH: artifactDir },
      windowsHide: true,
      stdio: ['ignore', 'pipe', 'pipe'],
    })
    this.backend.stdout?.pipe(log)
    this.backend.stderr?.pipe(log)
    await waitUntil(async () => {
      if (this.backend?.exitCode !== null) throw new Error(`后端提前退出，详见 ${logPath}`)
      try {
        return (await fetch(`${this.backendUrl}/captchaImage`)).ok
      } catch {
        return false
      }
    }, `后端未就绪，详见 ${logPath}`, 180_000)
  }

  private disableCaptchaInIsolatedDatabase() {
    this.dockerRun([
      'exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`,
      '-e', "UPDATE battery_detection.sys_config SET config_value='false' WHERE config_key='sys.account.captchaEnabled'",
    ])
  }

  private async stopBackend() {
    const backend = this.backend
    this.backend = null
    if (!backend || backend.exitCode !== null || backend.signalCode !== null) return
    const gracefulClose = waitForProcessClose(backend, 15_000)
    backend.kill('SIGTERM')
    if (await gracefulClose) return
    if (backend.exitCode === null && backend.signalCode === null) {
      const forcedClose = waitForProcessClose(backend, 15_000)
      if (process.platform === 'win32') {
        spawnSync('taskkill.exe', ['/pid', String(backend.pid), '/t', '/f'], { windowsHide: true })
      } else {
        backend.kill('SIGKILL')
      }
      await forcedClose
    }
  }
}
