#!/usr/bin/env node

import { spawn } from 'node:child_process'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const WEB_BAK_DIR = path.resolve(__dirname, '..')
const ROOT_DIR = path.resolve(WEB_BAK_DIR, '..')
const AND_BAK_DIR = path.resolve(ROOT_DIR, 'and-bak')

const APP_PACKAGE = 'com.hikoncont'
const APP_MAIN_ACTIVITY = 'com.hikoncont.MainActivity'

const DEFAULTS = {
  serverHttp: process.env.RC_SERVER_HTTP || '',
  socketUrl: process.env.RC_SOCKET_URL || '',
  username: process.env.RC_USER || process.env.SUPERADMIN_USERNAME || 'superadmin',
  password: process.env.RC_PASS || process.env.SUPERADMIN_PASSWORD || 'superadmin123456789',
  apkPath: process.env.RC_APK_PATH || path.resolve(AND_BAK_DIR, 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'),
  reportPath: process.env.RC_MATRIX_REPORT || path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_result_${Date.now()}.json`),
  smokeTimeoutMs: Number(process.env.RC_SMOKE_TIMEOUT_MS || 240000),
  waitOnlineMs: Number(process.env.RC_WAIT_ONLINE_MS || 90000),
  installTimeoutMs: Number(process.env.RC_INSTALL_TIMEOUT_MS || 240000),
  settleMs: Number(process.env.RC_SETTLE_MS || 7000),
  parallelDevices: Number(process.env.RC_PARALLEL_DEVICES || 0),
  triageLogcatLines: Number(process.env.RC_TRIAGE_LOGCAT_LINES || 2500)
}

const PROFILE_RULES = [
  {
    id: 'oppo_family',
    priority: 120,
    maxAttempts: 4,
    allowedWarn: 5,
    settleMs: 10000,
    smokeTimeoutMs: 270000,
    waitOnlineMs: 120000,
    recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'],
    match: (d) => hasAny(`${d.brand} ${d.manufacturer}`, ['oppo', 'oneplus', 'realme'])
  },
  {
    id: 'vivo_iqoo',
    priority: 115,
    maxAttempts: 4,
    allowedWarn: 5,
    settleMs: 10000,
    smokeTimeoutMs: 270000,
    waitOnlineMs: 120000,
    recoveryActions: ['wake', 'force_stop', 'home', 'launch', 'wait_long'],
    match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['vivo', 'iqoo'])
  },
  {
    id: 'xiaomi_hyperos',
    priority: 110,
    maxAttempts: 3,
    allowedWarn: 4,
    settleMs: 8000,
    smokeTimeoutMs: 240000,
    waitOnlineMs: 100000,
    recoveryActions: ['wake', 'home', 'launch', 'wait_medium', 'reconnect_if_wifi'],
    match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['xiaomi', 'redmi', 'poco'])
  },
  {
    id: 'huawei_honor',
    priority: 100,
    maxAttempts: 3,
    allowedWarn: 4,
    settleMs: 9000,
    smokeTimeoutMs: 240000,
    waitOnlineMs: 100000,
    recoveryActions: ['wake', 'force_stop', 'launch', 'wait_medium'],
    match: (d) => hasAny(`${d.brand} ${d.manufacturer} ${d.model}`, ['huawei', 'honor'])
  },
  {
    id: 'default',
    priority: 0,
    maxAttempts: 3,
    allowedWarn: 4,
    settleMs: 7000,
    smokeTimeoutMs: 240000,
    waitOnlineMs: 90000,
    recoveryActions: ['wake', 'launch', 'wait_short'],
    match: () => true
  }
]

const CRITICAL_SMOKE_STEPS = new Set([
  'request_device_control',
  'enable_black_screen',
  'disable_black_screen',
  'app_list',
  'camera_permission_check',
  'microphone_permission_check',
  'sms_permission_check',
  'gallery_permission_check'
])

const RUNTIME_PERMISSION_LIST = [
  'android.permission.CAMERA',
  'android.permission.RECORD_AUDIO',
  'android.permission.READ_SMS',
  'android.permission.SEND_SMS',
  'android.permission.RECEIVE_SMS',
  'android.permission.READ_MEDIA_IMAGES',
  'android.permission.READ_MEDIA_VIDEO',
  'android.permission.READ_EXTERNAL_STORAGE'
]

function nowIso() {
  return new Date().toISOString()
}

function log(...args) {
  console.log(`[${nowIso()}]`, ...args)
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

function hasAny(raw, needles) {
  const source = String(raw || '').toLowerCase()
  return needles.some((needle) => source.includes(needle))
}

function parseArgs(argv) {
  const options = {
    buildApk: false,
    skipInstall: false,
    dryRun: false
  }

  for (let i = 2; i < argv.length; i += 1) {
    const token = argv[i]
    if (!token.startsWith('--')) {
      continue
    }

    const [rawKey, inlineValue] = token.includes('=')
      ? token.split(/=(.*)/s)
      : [token, undefined]
    const key = rawKey.replace(/^--/, '')
    const value = inlineValue ?? argv[i + 1]
    const hasExplicitValue = inlineValue !== undefined || (!String(argv[i + 1] || '').startsWith('--') && argv[i + 1] !== undefined)

    const setValue = (targetKey) => {
      if (hasExplicitValue) {
        options[targetKey] = value
        if (inlineValue === undefined) i += 1
      }
    }

    switch (key) {
      case 'adb':
        setValue('adbPath')
        break
      case 'apk':
        setValue('apkPath')
        break
      case 'server-http':
        setValue('serverHttp')
        break
      case 'socket-url':
        setValue('socketUrl')
        break
      case 'user':
        setValue('username')
        break
      case 'pass':
        setValue('password')
        break
      case 'report':
        setValue('reportPath')
        break
      case 'max-devices':
        setValue('maxDevices')
        break
      case 'only-serials':
        setValue('onlySerials')
        break
      case 'attempts':
        setValue('attempts')
        break
      case 'parallel-devices':
        setValue('parallelDevices')
        break
      case 'smoke-timeout-ms':
        setValue('smokeTimeoutMs')
        break
      case 'wait-online-ms':
        setValue('waitOnlineMs')
        break
      case 'install-timeout-ms':
        setValue('installTimeoutMs')
        break
      case 'settle-ms':
        setValue('settleMs')
        break
      case 'triage-logcat-lines':
        setValue('triageLogcatLines')
        break
      case 'build-apk':
        options.buildApk = true
        break
      case 'skip-install':
        options.skipInstall = true
        break
      case 'dry-run':
        options.dryRun = true
        break
      case 'help':
        options.help = true
        break
      default:
        break
    }
  }

  return options
}

function printHelp() {
  console.log(`
Usage:
  node scripts/adb_control_matrix_orchestrator.mjs [options]

Options:
  --adb <path>               ADB executable path
  --apk <path>               APK file path
  --build-apk                Build debug APK before testing
  --skip-install             Skip APK install stage
  --server-http <url>        Server HTTP URL (default from config/env)
  --socket-url <url>         Socket URL for smoke script
  --user <username>          Login username
  --pass <password>          Login password
  --only-serials <s1,s2>     Restrict ADB serials
  --max-devices <n>          Limit tested devices
  --attempts <n>             Override attempts per device
  --parallel-devices <n>     Number of devices tested concurrently (default: all selected)
  --smoke-timeout-ms <n>     Smoke timeout
  --wait-online-ms <n>       Wait online timeout
  --install-timeout-ms <n>   Install timeout
  --settle-ms <n>            Delay after app launch
  --triage-logcat-lines <n>  Logcat lines kept in per-attempt triage bundle
  --report <path>            Output JSON report path
  --dry-run                  Print planned actions only
`)
}

async function runCommand(command, args, opts = {}) {
  const {
    cwd = process.cwd(),
    env = process.env,
    timeoutMs = 0,
    allowFailure = true
  } = opts

  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      cwd,
      env,
      stdio: ['ignore', 'pipe', 'pipe']
    })

    let stdout = ''
    let stderr = ''
    let settled = false
    let timeoutHandle = null

    if (timeoutMs > 0) {
      timeoutHandle = setTimeout(() => {
        if (settled) return
        settled = true
        child.kill('SIGTERM')
        resolve({
          ok: false,
          command,
          args,
          cwd,
          stdout,
          stderr: `${stderr}\n<timed_out_${timeoutMs}ms>`,
          code: null,
          timedOut: true
        })
      }, timeoutMs)
    }

    child.stdout.on('data', (chunk) => {
      stdout += chunk.toString()
    })
    child.stderr.on('data', (chunk) => {
      stderr += chunk.toString()
    })

    child.on('error', (error) => {
      if (settled) return
      settled = true
      if (timeoutHandle) clearTimeout(timeoutHandle)
      if (allowFailure) {
        resolve({
          ok: false,
          command,
          args,
          cwd,
          stdout,
          stderr: `${stderr}\n${error.message}`,
          code: null,
          timedOut: false
        })
      } else {
        reject(error)
      }
    })

    child.on('close', (code) => {
      if (settled) return
      settled = true
      if (timeoutHandle) clearTimeout(timeoutHandle)
      const ok = code === 0
      const result = {
        ok,
        command,
        args,
        cwd,
        stdout,
        stderr,
        code,
        timedOut: false
      }
      if (ok || allowFailure) {
        resolve(result)
      } else {
        reject(new Error(`command_failed ${command} ${args.join(' ')} code=${code}`))
      }
    })
  })
}

async function readIfExists(filePath) {
  try {
    return await readFile(filePath, 'utf8')
  } catch {
    return ''
  }
}

async function fileExists(filePath) {
  try {
    await access(filePath)
    return true
  } catch {
    return false
  }
}

function parseServerHttpFromConfig(raw) {
  try {
    const json = JSON.parse(raw)
    const serverUrl = String(json?.serverUrl || '').trim()
    if (!serverUrl) return ''
    if (serverUrl.startsWith('ws://')) return `http://${serverUrl.slice(5)}`
    if (serverUrl.startsWith('wss://')) return `https://${serverUrl.slice(6)}`
    return serverUrl
  } catch {
    return ''
  }
}

async function getDefaultServerHttp() {
  if (DEFAULTS.serverHttp) return DEFAULTS.serverHttp
  const cfgPath = path.resolve(AND_BAK_DIR, 'app', 'src', 'main', 'assets', 'server_config.json')
  const raw = await readIfExists(cfgPath)
  return parseServerHttpFromConfig(raw)
}

async function getSdkDirFromLocalProperties() {
  const localPropertiesPath = path.resolve(AND_BAK_DIR, 'local.properties')
  const raw = await readIfExists(localPropertiesPath)
  const line = raw
    .split(/\r?\n/)
    .map((v) => v.trim())
    .find((v) => v.startsWith('sdk.dir='))
  if (!line) return ''
  return line.slice('sdk.dir='.length).replace(/\\/g, '/').trim()
}

async function resolveAdbPath(explicitAdbPath) {
  const exeName = process.platform === 'win32' ? 'adb.exe' : 'adb'
  const candidates = []

  if (explicitAdbPath) candidates.push(explicitAdbPath)
  if (process.env.RC_ADB_PATH) candidates.push(process.env.RC_ADB_PATH)

  for (const envKey of ['ANDROID_HOME', 'ANDROID_SDK_ROOT']) {
    if (process.env[envKey]) {
      candidates.push(path.join(process.env[envKey], 'platform-tools', exeName))
    }
  }

  const sdkDir = await getSdkDirFromLocalProperties()
  if (sdkDir) {
    candidates.push(path.join(sdkDir, 'platform-tools', exeName))
  }

  candidates.push('adb')

  for (const candidate of candidates) {
    const result = await runCommand(candidate, ['version'], {
      cwd: ROOT_DIR,
      timeoutMs: 6000,
      allowFailure: true
    })
    if (result.ok) {
      return candidate
    }
  }

  throw new Error('ADB not found. Provide --adb or configure Android SDK path.')
}

async function adb(adbPath, args, opts = {}) {
  return runCommand(adbPath, args, opts)
}

async function adbForSerial(adbPath, serial, args, opts = {}) {
  return runCommand(adbPath, ['-s', serial, ...args], opts)
}

function parseAdbDeviceList(raw) {
  const devices = []
  const lines = String(raw || '').split(/\r?\n/)
  for (const line of lines) {
    const trimmed = line.trim()
    if (!trimmed || trimmed.startsWith('List of devices attached')) continue
    const match = trimmed.match(/^(\S+)\s+(\S+)\s*(.*)$/)
    if (!match) continue
    const [, serial, status, extraRaw] = match
    if (status !== 'device') continue
    const extras = {}
    if (extraRaw) {
      for (const token of extraRaw.split(/\s+/)) {
        const [k, v] = token.split(':')
        if (k && v !== undefined) extras[k] = v
      }
    }
    devices.push({
      serial,
      status,
      extras
    })
  }
  return devices
}

function sanitizeFileToken(input) {
  return String(input || 'unknown')
    .replace(/[^\w.-]+/g, '_')
    .replace(/^_+|_+$/g, '')
}

function simplifyTransportEntry(item) {
  return {
    serial: item.serial,
    status: item.status,
    transportId: item.extras?.transport_id || '',
    model: item.extras?.model || '',
    device: item.extras?.device || '',
    product: item.extras?.product || ''
  }
}

async function listOnlineAdbTransports(adbPath) {
  const listResult = await adb(adbPath, ['devices', '-l'], {
    cwd: ROOT_DIR,
    timeoutMs: 10000,
    allowFailure: true
  })
  if (!listResult.ok) {
    return {
      ok: false,
      error: `${listResult.stderr || ''}\n${listResult.stdout || ''}`.trim(),
      transports: []
    }
  }
  return {
    ok: true,
    error: '',
    transports: parseAdbDeviceList(listResult.stdout)
  }
}

function isTransportLostOutput(raw) {
  const text = String(raw || '').toLowerCase()
  if (!text) return false
  return (
    text.includes('device not found') ||
    text.includes('no devices/emulators found') ||
    text.includes('device offline') ||
    text.includes('more than one device/emulator') ||
    text.includes('cannot connect to') ||
    text.includes('closed')
  )
}

async function adbGetProp(adbPath, serial, prop) {
  const result = await adbForSerial(adbPath, serial, ['shell', 'getprop', prop], {
    cwd: ROOT_DIR,
    timeoutMs: 6000,
    allowFailure: true
  })
  if (!result.ok) return ''
  return result.stdout.trim()
}

async function adbGetAndroidId(adbPath, serial) {
  const main = await adbForSerial(adbPath, serial, ['shell', 'settings', 'get', 'secure', 'android_id'], {
    cwd: ROOT_DIR,
    timeoutMs: 6000,
    allowFailure: true
  })
  const value = main.stdout.trim()
  if (value && value.toLowerCase() !== 'null') return value

  const fallback = await adbGetProp(adbPath, serial, 'ro.serialno')
  if (fallback) return fallback
  return serial
}

async function adbReadAppDeviceIdFromLogcat(adbPath, serial) {
  const result = await adbForSerial(adbPath, serial, ['logcat', '-d', '-t', '5000'], {
    cwd: ROOT_DIR,
    timeoutMs: 12000,
    allowFailure: true
  })
  if (!result.ok) return ''

  const raw = `${result.stdout}\n${result.stderr}`
  const regex = /device_register\s*#\d+:\s*deviceId=([0-9a-fA-F]+)/g
  let match = null
  let last = ''
  // eslint-disable-next-line no-cond-assign
  while ((match = regex.exec(raw)) !== null) {
    last = String(match[1] || '').trim()
  }
  return last
}

function transportScore(serial) {
  if (serial.includes(':')) return 20
  if (serial.startsWith('adb-')) return 10
  return 30
}

function selectBestTransport(candidates) {
  return [...candidates].sort((a, b) => {
    const scoreDiff = transportScore(b.serial) - transportScore(a.serial)
    if (scoreDiff !== 0) return scoreDiff
    return a.serial.localeCompare(b.serial)
  })[0]
}

function normalizeDevicePayload(raw) {
  if (Array.isArray(raw)) return raw
  if (Array.isArray(raw?.data)) return raw.data
  if (Array.isArray(raw?.devices)) return raw.devices
  if (Array.isArray(raw?.value)) return raw.value
  return []
}

async function loginServer(serverHttp, username, password) {
  const response = await fetch(`${serverHttp}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password })
  })
  const body = await response.json().catch(() => ({}))
  if (!response.ok || !body?.success || !body?.token) {
    throw new Error(`server_login_failed status=${response.status}`)
  }
  return body.token
}

async function fetchDevices(serverHttp, token) {
  const response = await fetch(`${serverHttp}/api/devices`, {
    headers: { Authorization: `Bearer ${token}` }
  })
  const body = await response.json().catch(() => ({}))
  if (!response.ok) {
    throw new Error(`fetch_devices_failed status=${response.status}`)
  }
  return normalizeDevicePayload(body)
}

async function waitForDeviceOnline(serverHttp, token, candidateDeviceIds, timeoutMs, hint = {}) {
  const deadline = Date.now() + timeoutMs
  const ids = Array.from(new Set((candidateDeviceIds || []).map((v) => String(v || '').trim()).filter(Boolean)))
  let last = null
  while (Date.now() < deadline) {
    try {
      const devices = await fetchDevices(serverHttp, token)
      for (const id of ids) {
        const found = devices.find((item) => String(item?.id || '') === id)
        if (found && String(found?.status || '').toLowerCase() === 'online') {
          return { device: found, matchedBy: id }
        }
        if (found) last = { device: found, matchedBy: id }
      }

      const onlineDevices = devices.filter((item) => String(item?.status || '').toLowerCase() === 'online')
      const hintModel = String(hint?.model || '').trim().toLowerCase()
      if (hintModel) {
        const modelMatches = onlineDevices.filter((item) => String(item?.model || '').trim().toLowerCase() === hintModel)
        if (modelMatches.length === 1) {
          return { device: modelMatches[0], matchedBy: 'model_fallback' }
        }
      }
    } catch {
      // transient server errors should not terminate the full matrix run
    }
    await sleep(3000)
  }
  return last
}

function resolveProfile(device) {
  return [...PROFILE_RULES]
    .sort((a, b) => b.priority - a.priority)
    .find((rule) => rule.match(device)) || PROFILE_RULES[PROFILE_RULES.length - 1]
}

async function buildApkIfNeeded(options) {
  if (!options.buildApk) return
  log('Building APK via Gradle...')
  const gradle = process.platform === 'win32' ? 'gradlew.bat' : './gradlew'
  const result = await runCommand(gradle, [':app:assembleDebug'], {
    cwd: AND_BAK_DIR,
    timeoutMs: 30 * 60 * 1000,
    allowFailure: true
  })
  if (!result.ok) {
    throw new Error(`apk_build_failed: ${result.stderr || result.stdout}`)
  }
  log('APK build finished.')
}

async function installApk(adbPath, serial, apkPath, timeoutMs) {
  const first = await adbForSerial(adbPath, serial, ['install', '-r', '-d', apkPath], {
    cwd: ROOT_DIR,
    timeoutMs,
    allowFailure: true
  })
  if (first.ok && /success/i.test(first.stdout + first.stderr)) {
    return { ok: true, output: first.stdout + first.stderr, mode: 'install-r-d' }
  }

  const fallback = await adbForSerial(adbPath, serial, ['install', '-r', apkPath], {
    cwd: ROOT_DIR,
    timeoutMs,
    allowFailure: true
  })
  if (fallback.ok && /success/i.test(fallback.stdout + fallback.stderr)) {
    return { ok: true, output: fallback.stdout + fallback.stderr, mode: 'install-r' }
  }

  const combined = `${first.stdout}\n${first.stderr}\n${fallback.stdout}\n${fallback.stderr}`
  if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/i.test(combined)) {
    await adbForSerial(adbPath, serial, ['uninstall', APP_PACKAGE], {
      cwd: ROOT_DIR,
      timeoutMs: 45000,
      allowFailure: true
    })
    const clean = await adbForSerial(adbPath, serial, ['install', apkPath], {
      cwd: ROOT_DIR,
      timeoutMs,
      allowFailure: true
    })
    if (clean.ok && /success/i.test(clean.stdout + clean.stderr)) {
      return { ok: true, output: clean.stdout + clean.stderr, mode: 'uninstall-install' }
    }
    return { ok: false, output: `${combined}\n${clean.stdout}\n${clean.stderr}`, mode: 'failed_after_uninstall' }
  }

  return { ok: false, output: combined, mode: 'failed' }
}

async function launchApp(adbPath, serial) {
  await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], {
    cwd: ROOT_DIR,
    timeoutMs: 5000,
    allowFailure: true
  })

  const monkey = await adbForSerial(adbPath, serial, ['shell', 'monkey', '-p', APP_PACKAGE, '-c', 'android.intent.category.LAUNCHER', '1'], {
    cwd: ROOT_DIR,
    timeoutMs: 20000,
    allowFailure: true
  })
  if (monkey.ok) return { ok: true, mode: 'monkey' }

  const fallback = await adbForSerial(adbPath, serial, ['shell', 'am', 'start', '-n', `${APP_PACKAGE}/${APP_MAIN_ACTIVITY}`], {
    cwd: ROOT_DIR,
    timeoutMs: 20000,
    allowFailure: true
  })
  return { ok: fallback.ok, mode: fallback.ok ? 'am_start' : 'launch_failed', output: `${monkey.stderr}\n${fallback.stderr}` }
}

function evaluateSmoke(report, allowedWarn) {
  if (!report || !report.summary) {
    return { pass: false, reason: 'missing_smoke_report' }
  }

  if (Number(report.summary.fail || 0) > 0) {
    return { pass: false, reason: `smoke_fail_${report.summary.fail}` }
  }

  if (Number(report.summary.warn || 0) > allowedWarn) {
    return { pass: false, reason: `smoke_warn_${report.summary.warn}_gt_${allowedWarn}` }
  }

  const failedCritical = []
  const steps = Array.isArray(report.steps) ? report.steps : []
  for (const name of CRITICAL_SMOKE_STEPS) {
    const step = steps.find((item) => item?.name === name)
    if (!step || step.status !== 'ok') {
      failedCritical.push(name)
    }
  }

  if (failedCritical.length > 0) {
    return { pass: false, reason: `critical_steps_failed:${failedCritical.join(',')}` }
  }

  return { pass: true, reason: 'pass' }
}

async function applyRecovery(adbPath, serial, profile) {
  for (const action of profile.recoveryActions || []) {
    switch (action) {
      case 'wake':
        await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '224'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true })
        break
      case 'force_stop':
        await adbForSerial(adbPath, serial, ['shell', 'am', 'force-stop', APP_PACKAGE], { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true })
        break
      case 'home':
        await adbForSerial(adbPath, serial, ['shell', 'input', 'keyevent', '3'], { cwd: ROOT_DIR, timeoutMs: 5000, allowFailure: true })
        break
      case 'launch':
        await launchApp(adbPath, serial)
        break
      case 'reconnect_if_wifi':
        if (serial.includes(':')) {
          await adb(adbPath, ['connect', serial], { cwd: ROOT_DIR, timeoutMs: 15000, allowFailure: true })
        }
        break
      case 'wait_short':
        await sleep(2500)
        break
      case 'wait_medium':
        await sleep(5000)
        break
      case 'wait_long':
        await sleep(9000)
        break
      default:
        break
    }
  }
}

async function runSmokeForDevice(params) {
  const {
    deviceId,
    serverHttp,
    socketUrl,
    username,
    password,
    reportPath,
    timeoutMs,
    stdoutPath,
    stderrPath
  } = params

  const env = {
    ...process.env,
    RC_DEVICE_ID: deviceId,
    RC_SERVER_HTTP: serverHttp,
    RC_SOCKET_URL: socketUrl,
    RC_USER: username,
    RC_PASS: password,
    RC_REPORT_PATH: reportPath
  }

  const result = await runCommand('node', ['_tmp_control_panel_smoke.mjs', deviceId], {
    cwd: WEB_BAK_DIR,
    env,
    timeoutMs,
    allowFailure: true
  })

  const rawReport = await readIfExists(reportPath)
  let parsedReport = null
  if (rawReport) {
    try {
      parsedReport = JSON.parse(rawReport)
    } catch {
      parsedReport = null
    }
  }

  if (stdoutPath) {
    await writeFile(stdoutPath, String(result.stdout || ''), 'utf8')
  }
  if (stderrPath) {
    await writeFile(stderrPath, String(result.stderr || ''), 'utf8')
  }
  return {
    processResult: result,
    report: parsedReport,
    stdoutPath: stdoutPath || '',
    stderrPath: stderrPath || ''
  }
}

async function collectDeviceRuntime(adbPath, baseDevice) {
  const serial = baseDevice.serial
  const brand = await adbGetProp(adbPath, serial, 'ro.product.brand')
  const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer')
  const model = await adbGetProp(adbPath, serial, 'ro.product.model')
  const device = await adbGetProp(adbPath, serial, 'ro.product.device')
  const product = await adbGetProp(adbPath, serial, 'ro.product.name')
  const sdk = await adbGetProp(adbPath, serial, 'ro.build.version.sdk')
  const release = await adbGetProp(adbPath, serial, 'ro.build.version.release')
  const androidId = await adbGetAndroidId(adbPath, serial)
  const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial)

  return {
    ...baseDevice,
    androidId,
    appDeviceId,
    brand,
    manufacturer,
    model,
    device,
    product,
    sdkInt: Number(sdk || 0),
    release
  }
}

async function collectTransportFingerprint(adbPath, baseDevice) {
  const serial = baseDevice.serial
  const androidId = await adbGetAndroidId(adbPath, serial)
  const brand = await adbGetProp(adbPath, serial, 'ro.product.brand')
  const manufacturer = await adbGetProp(adbPath, serial, 'ro.product.manufacturer')
  const model = await adbGetProp(adbPath, serial, 'ro.product.model')
  const appDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, serial)
  return {
    ...baseDevice,
    androidId,
    brand,
    manufacturer,
    model,
    appDeviceId
  }
}

async function resolveDeviceTransport(adbPath, device) {
  const listed = await listOnlineAdbTransports(adbPath)
  const fallback = {
    ok: false,
    serial: device.serial,
    changed: false,
    matchedBy: 'unresolved',
    transports: [],
    message: listed.error || 'adb_list_failed'
  }
  if (!listed.ok) {
    return fallback
  }

  const transports = listed.transports
  const direct = transports.find((item) => item.serial === device.serial)
  if (direct) {
    return {
      ok: true,
      serial: device.serial,
      changed: false,
      matchedBy: 'current_serial',
      transports,
      message: ''
    }
  }

  const knownSerials = new Set([device.serial, ...(device.duplicateSerials || [])])
  const knownOnline = transports.filter((item) => knownSerials.has(item.serial))
  if (knownOnline.length > 0) {
    const selected = selectBestTransport(knownOnline)
    return {
      ok: true,
      serial: selected.serial,
      changed: selected.serial !== device.serial,
      matchedBy: 'known_serial',
      transports,
      message: ''
    }
  }

  const fingerprints = []
  for (const transport of transports) {
    const fp = await collectTransportFingerprint(adbPath, transport)
    fingerprints.push(fp)
  }

  const androidIdMatches = fingerprints.filter((item) => item.androidId && item.androidId === device.androidId)
  if (androidIdMatches.length > 0) {
    const selected = selectBestTransport(androidIdMatches)
    return {
      ok: true,
      serial: selected.serial,
      changed: selected.serial !== device.serial,
      matchedBy: 'android_id',
      transports,
      message: ''
    }
  }

  const modelNeedle = String(device.model || '').trim().toLowerCase()
  if (modelNeedle) {
    const modelMatches = fingerprints.filter((item) => String(item.model || '').trim().toLowerCase() === modelNeedle)
    if (modelMatches.length === 1) {
      return {
        ok: true,
        serial: modelMatches[0].serial,
        changed: modelMatches[0].serial !== device.serial,
        matchedBy: 'model_fallback',
        transports,
        message: ''
      }
    }
  }

  return {
    ok: false,
    serial: device.serial,
    changed: false,
    matchedBy: 'not_found',
    transports,
    message: 'device_transport_not_found'
  }
}

async function adbReadRuntimePermissionState(adbPath, serial, permission) {
  const result = await adbForSerial(adbPath, serial, ['shell', 'pm', 'check-permission', APP_PACKAGE, permission], {
    cwd: ROOT_DIR,
    timeoutMs: 7000,
    allowFailure: true
  })
  if (!result.ok) {
    return {
      permission,
      state: 'unknown',
      output: `${result.stdout}\n${result.stderr}`.trim()
    }
  }
  const output = `${result.stdout}\n${result.stderr}`.trim()
  const normalized = output.toLowerCase()
  const granted = normalized.includes('granted')
  const denied = normalized.includes('denied')
  return {
    permission,
    state: granted ? 'granted' : denied ? 'denied' : 'unknown',
    output
  }
}

async function collectAttemptTriage({
  adbPath,
  serverHttp,
  token,
  runDir,
  device,
  serial,
  attempt,
  failureReason,
  smokeResult,
  triageLogcatLines
}) {
  const tokenSafe = sanitizeFileToken(device.androidId || device.serial)
  const triageDir = path.resolve(runDir, `triage_${tokenSafe}_attempt${attempt}`)
  await mkdir(triageDir, { recursive: true })

  const adbDevicesResult = await adb(adbPath, ['devices', '-l'], {
    cwd: ROOT_DIR,
    timeoutMs: 10000,
    allowFailure: true
  })
  const adbDevicesPath = path.resolve(triageDir, 'adb_devices.txt')
  await writeFile(adbDevicesPath, `${adbDevicesResult.stdout || ''}\n${adbDevicesResult.stderr || ''}`, 'utf8')

  const accessibilityEnabled = await adbForSerial(
    adbPath,
    serial,
    ['shell', 'settings', 'get', 'secure', 'accessibility_enabled'],
    { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true }
  )
  const accessibilityServices = await adbForSerial(
    adbPath,
    serial,
    ['shell', 'settings', 'get', 'secure', 'enabled_accessibility_services'],
    { cwd: ROOT_DIR, timeoutMs: 7000, allowFailure: true }
  )
  const appops = await adbForSerial(
    adbPath,
    serial,
    ['shell', 'cmd', 'appops', 'get', APP_PACKAGE],
    { cwd: ROOT_DIR, timeoutMs: 10000, allowFailure: true }
  )
  const logcat = await adbForSerial(
    adbPath,
    serial,
    ['logcat', '-d', '-t', String(Math.max(200, Number(triageLogcatLines || 2500)))],
    { cwd: ROOT_DIR, timeoutMs: 20000, allowFailure: true }
  )

  const a11yEnabledRaw = (accessibilityEnabled.stdout || accessibilityEnabled.stderr || '').trim()
  const a11yServicesRaw = (accessibilityServices.stdout || accessibilityServices.stderr || '').trim()
  const appopsRaw = `${appops.stdout || ''}\n${appops.stderr || ''}`.trim()
  const logcatRaw = `${logcat.stdout || ''}\n${logcat.stderr || ''}`.trim()
  const targetA11yService = 'com.hikoncont/com.hikoncont.service.AccessibilityRemoteService'.toLowerCase()
  const hasServiceInSettings = String(a11yServicesRaw || '').toLowerCase().includes(targetA11yService)
  const hasServiceLogHint =
    /AccessibilityRemoteService/.test(logcatRaw) ||
    /KeepAliveWorker/.test(logcatRaw)
  const a11yEnabled =
    a11yEnabledRaw === '1' ||
    hasServiceInSettings ||
    hasServiceLogHint
  const appopsPath = path.resolve(triageDir, 'appops.txt')
  const logcatPath = path.resolve(triageDir, 'logcat_tail.txt')
  await writeFile(appopsPath, appopsRaw, 'utf8')
  await writeFile(logcatPath, logcatRaw, 'utf8')

  const runtimePermissions = []
  for (const permission of RUNTIME_PERMISSION_LIST) {
    runtimePermissions.push(await adbReadRuntimePermissionState(adbPath, serial, permission))
  }

  const smokeStdoutPath = path.resolve(triageDir, 'smoke_stdout.log')
  const smokeStderrPath = path.resolve(triageDir, 'smoke_stderr.log')
  if (smokeResult?.processResult) {
    await writeFile(smokeStdoutPath, String(smokeResult.processResult.stdout || ''), 'utf8')
    await writeFile(smokeStderrPath, String(smokeResult.processResult.stderr || ''), 'utf8')
  } else {
    await writeFile(smokeStdoutPath, '', 'utf8')
    await writeFile(smokeStderrPath, '', 'utf8')
  }

  let serverDevices = []
  let serverError = ''
  try {
    serverDevices = await fetchDevices(serverHttp, token)
  } catch (error) {
    serverError = error instanceof Error ? error.message : String(error)
  }
  const serverSnapshotPath = path.resolve(triageDir, 'server_devices.json')
  await writeFile(
    serverSnapshotPath,
    JSON.stringify(
      {
        collectedAt: nowIso(),
        error: serverError,
        total: Array.isArray(serverDevices) ? serverDevices.length : 0,
        devices: Array.isArray(serverDevices) ? serverDevices : []
      },
      null,
      2
    ),
    'utf8'
  )

  const triage = {
    collectedAt: nowIso(),
    failureReason,
    serial,
    androidId: device.androidId,
    brand: device.brand,
    manufacturer: device.manufacturer,
    model: device.model,
    release: device.release,
    sdkInt: device.sdkInt,
    accessibility: {
      enabledRaw: a11yEnabledRaw,
      enabled: a11yEnabled,
      servicesRaw: a11yServicesRaw,
      inferredFromServiceSetting: hasServiceInSettings,
      inferredFromLogcat: hasServiceLogHint
    },
    runtimePermissions,
    files: {
      adbDevicesPath,
      appopsPath,
      logcatPath,
      smokeStdoutPath,
      smokeStderrPath,
      serverSnapshotPath
    }
  }
  const triagePath = path.resolve(triageDir, 'triage.json')
  await writeFile(triagePath, JSON.stringify(triage, null, 2), 'utf8')
  return {
    triagePath,
    triageDir,
    triage,
    smokeStdoutPath,
    smokeStderrPath
  }
}

async function runWithConcurrency(items, maxConcurrency, worker) {
  const results = new Array(items.length)
  const limit = Math.max(1, Math.min(items.length, Number(maxConcurrency || 1)))
  let cursor = 0

  const runners = Array.from({ length: limit }, async () => {
    while (true) {
      const index = cursor
      cursor += 1
      if (index >= items.length) return
      results[index] = await worker(items[index], index)
    }
  })

  await Promise.all(runners)
  return results
}

async function runSingleDeviceMatrix(params) {
  const {
    device,
    adbPath,
    apkPath,
    options,
    token,
    runDir
  } = params

  const profile = resolveProfile(device)
  const maxAttempts = options.attempts || profile.maxAttempts
  const allowedWarn = profile.allowedWarn
  const smokeTimeoutMs = options.smokeTimeoutMs || profile.smokeTimeoutMs
  const waitOnlineMs = options.waitOnlineMs || profile.waitOnlineMs
  const settleMs = options.settleMs || profile.settleMs
  const tokenSafe = sanitizeFileToken(device.androidId || device.serial)

  const deviceState = {
    ...device,
    duplicateSerials: Array.isArray(device.duplicateSerials) ? [...device.duplicateSerials] : []
  }

  log(
    `Testing serial=${deviceState.serial} androidId=${deviceState.androidId} brand=${deviceState.brand} model=${deviceState.model} profile=${profile.id}`
  )

  const deviceResult = {
    serial: deviceState.serial,
    androidId: deviceState.androidId,
    appDeviceId: deviceState.appDeviceId || '',
    duplicateSerials: deviceState.duplicateSerials,
    brand: deviceState.brand,
    manufacturer: deviceState.manufacturer,
    model: deviceState.model,
    sdkInt: deviceState.sdkInt,
    release: deviceState.release,
    profile: profile.id,
    attempts: [],
    finalStatus: 'failed',
    finalReason: 'not_started'
  }

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    log(`  [${deviceState.androidId}] attempt ${attempt}/${maxAttempts}`)
    const attemptRecord = {
      attempt,
      startedAt: nowIso(),
      serialBeforeResolve: deviceState.serial,
      serialInUse: deviceState.serial,
      transportResolution: null,
      install: null,
      launch: null,
      onlineWait: null,
      smoke: null,
      evaluation: null,
      triage: null,
      recoveryApplied: false
    }

    if (options.dryRun) {
      attemptRecord.evaluation = { pass: true, reason: 'dry_run' }
      deviceResult.attempts.push(attemptRecord)
      deviceResult.finalStatus = 'passed'
      deviceResult.finalReason = 'dry_run'
      break
    }

    const transportResolution = await resolveDeviceTransport(adbPath, deviceState)
    attemptRecord.transportResolution = {
      ok: transportResolution.ok,
      matchedBy: transportResolution.matchedBy,
      changed: transportResolution.changed,
      message: transportResolution.message || '',
      transports: transportResolution.transports.map(simplifyTransportEntry)
    }
    if (!transportResolution.ok) {
      attemptRecord.evaluation = { pass: false, reason: 'adb_transport_lost' }
      const triage = await collectAttemptTriage({
        adbPath,
        serverHttp: options.serverHttp,
        token,
        runDir,
        device: deviceState,
        serial: deviceState.serial,
        attempt,
        failureReason: attemptRecord.evaluation.reason,
        smokeResult: null,
        triageLogcatLines: options.triageLogcatLines
      })
      attemptRecord.triage = {
        path: triage.triagePath,
        accessibilityEnabled: triage.triage?.accessibility?.enabled === true
      }
      deviceResult.attempts.push(attemptRecord)
      continue
    }

    if (transportResolution.changed) {
      log(`  [${deviceState.androidId}] transport switched ${deviceState.serial} -> ${transportResolution.serial} (${transportResolution.matchedBy})`)
      if (!deviceState.duplicateSerials.includes(deviceState.serial)) {
        deviceState.duplicateSerials.push(deviceState.serial)
      }
      deviceState.serial = transportResolution.serial
      deviceResult.serial = transportResolution.serial
      deviceResult.duplicateSerials = Array.from(
        new Set([...(deviceResult.duplicateSerials || []), ...(transportResolution.transports || []).map((item) => item.serial)])
      ).filter((serial) => serial !== transportResolution.serial)
    }
    attemptRecord.serialInUse = deviceState.serial

    if (!options.skipInstall) {
      const install = await installApk(adbPath, deviceState.serial, apkPath, options.installTimeoutMs)
      attemptRecord.install = install
      if (!install.ok) {
        const reason = isTransportLostOutput(install.output) ? 'adb_transport_lost_install' : 'install_failed'
        attemptRecord.evaluation = { pass: false, reason }
        const triage = await collectAttemptTriage({
          adbPath,
          serverHttp: options.serverHttp,
          token,
          runDir,
          device: deviceState,
          serial: deviceState.serial,
          attempt,
          failureReason: reason,
          smokeResult: null,
          triageLogcatLines: options.triageLogcatLines
        })
        attemptRecord.triage = {
          path: triage.triagePath,
          accessibilityEnabled: triage.triage?.accessibility?.enabled === true
        }
        deviceResult.attempts.push(attemptRecord)
        await applyRecovery(adbPath, deviceState.serial, profile)
        attemptRecord.recoveryApplied = true
        continue
      }
    }

    const launch = await launchApp(adbPath, deviceState.serial)
    attemptRecord.launch = launch
    if (!launch.ok) {
      const reason = isTransportLostOutput(launch.output) ? 'adb_transport_lost_launch' : 'launch_failed'
      attemptRecord.evaluation = { pass: false, reason }
      const triage = await collectAttemptTriage({
        adbPath,
        serverHttp: options.serverHttp,
        token,
        runDir,
        device: deviceState,
        serial: deviceState.serial,
        attempt,
        failureReason: reason,
        smokeResult: null,
        triageLogcatLines: options.triageLogcatLines
      })
      attemptRecord.triage = {
        path: triage.triagePath,
        accessibilityEnabled: triage.triage?.accessibility?.enabled === true
      }
      deviceResult.attempts.push(attemptRecord)
      await applyRecovery(adbPath, deviceState.serial, profile)
      attemptRecord.recoveryApplied = true
      continue
    }

    await sleep(settleMs)

    const logcatDeviceId = await adbReadAppDeviceIdFromLogcat(adbPath, deviceState.serial)
    const candidateDeviceIds = Array.from(
      new Set([logcatDeviceId, deviceState.appDeviceId, deviceState.androidId].map((v) => String(v || '').trim()).filter(Boolean))
    )
    attemptRecord.candidateDeviceIds = candidateDeviceIds

    const onlineResult = await waitForDeviceOnline(options.serverHttp, token, candidateDeviceIds, waitOnlineMs, {
      brand: deviceState.brand,
      manufacturer: deviceState.manufacturer,
      model: deviceState.model
    })
    const onlineDevice = onlineResult?.device || null
    attemptRecord.onlineWait = {
      found: !!onlineDevice,
      status: onlineDevice?.status || 'missing',
      matchedBy: onlineResult?.matchedBy || null
    }
    if (!onlineDevice || String(onlineDevice?.status || '').toLowerCase() !== 'online') {
      let reason = 'server_device_not_online'
      const triage = await collectAttemptTriage({
        adbPath,
        serverHttp: options.serverHttp,
        token,
        runDir,
        device: deviceState,
        serial: deviceState.serial,
        attempt,
        failureReason: reason,
        smokeResult: null,
        triageLogcatLines: options.triageLogcatLines
      })
      if (triage.triage?.accessibility?.enabled === false) {
        reason = 'a11y_disabled'
      }
      attemptRecord.evaluation = { pass: false, reason }
      attemptRecord.triage = {
        path: triage.triagePath,
        accessibilityEnabled: triage.triage?.accessibility?.enabled === true
      }
      deviceResult.attempts.push(attemptRecord)
      await applyRecovery(adbPath, deviceState.serial, profile)
      attemptRecord.recoveryApplied = true
      continue
    }

    const smokeReportPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.json`)
    const smokeStdoutPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stdout.log`)
    const smokeStderrPath = path.resolve(runDir, `smoke_${tokenSafe}_${attempt}.stderr.log`)
    const smoke = await runSmokeForDevice({
      deviceId: onlineDevice.id,
      serverHttp: options.serverHttp,
      socketUrl: options.socketUrl,
      username: options.username,
      password: options.password,
      reportPath: smokeReportPath,
      timeoutMs: smokeTimeoutMs,
      stdoutPath: smokeStdoutPath,
      stderrPath: smokeStderrPath
    })
    attemptRecord.smoke = {
      deviceId: onlineDevice.id,
      reportPath: smokeReportPath,
      stdoutPath: smoke.stdoutPath || smokeStdoutPath,
      stderrPath: smoke.stderrPath || smokeStderrPath,
      processCode: smoke.processResult.code,
      timedOut: smoke.processResult.timedOut,
      summary: smoke.report?.summary || null
    }

    const evaluation = evaluateSmoke(smoke.report, allowedWarn)
    attemptRecord.evaluation = evaluation
    deviceResult.attempts.push(attemptRecord)

    if (evaluation.pass) {
      deviceResult.finalStatus = 'passed'
      deviceResult.finalReason = evaluation.reason
      break
    }

    const triage = await collectAttemptTriage({
      adbPath,
      serverHttp: options.serverHttp,
      token,
      runDir,
      device: deviceState,
      serial: deviceState.serial,
      attempt,
      failureReason: evaluation.reason,
      smokeResult: smoke,
      triageLogcatLines: options.triageLogcatLines
    })
    attemptRecord.triage = {
      path: triage.triagePath,
      accessibilityEnabled: triage.triage?.accessibility?.enabled === true
    }

    if (attempt < maxAttempts) {
      await applyRecovery(adbPath, deviceState.serial, profile)
      attemptRecord.recoveryApplied = true
    }
  }

  if (deviceResult.finalStatus !== 'passed') {
    const last = deviceResult.attempts[deviceResult.attempts.length - 1]
    deviceResult.finalReason = last?.evaluation?.reason || 'unknown'
  }

  return deviceResult
}

async function main() {
  const cli = parseArgs(process.argv)
  if (cli.help) {
    printHelp()
    return
  }

  const serverHttp = cli.serverHttp || await getDefaultServerHttp()
  if (!serverHttp) {
    throw new Error('server_http_missing: use --server-http or set RC_SERVER_HTTP')
  }
  const socketUrl = cli.socketUrl || cli.serverHttp || serverHttp

  const options = {
    ...DEFAULTS,
    ...cli,
    serverHttp,
    socketUrl,
    smokeTimeoutMs: Number(cli.smokeTimeoutMs || DEFAULTS.smokeTimeoutMs),
    waitOnlineMs: Number(cli.waitOnlineMs || DEFAULTS.waitOnlineMs),
    installTimeoutMs: Number(cli.installTimeoutMs || DEFAULTS.installTimeoutMs),
    settleMs: Number(cli.settleMs || DEFAULTS.settleMs),
    parallelDevices: Number(cli.parallelDevices || DEFAULTS.parallelDevices || 0),
    triageLogcatLines: Number(cli.triageLogcatLines || DEFAULTS.triageLogcatLines || 2500),
    maxDevices: cli.maxDevices ? Number(cli.maxDevices) : undefined,
    attempts: cli.attempts ? Number(cli.attempts) : undefined
  }

  log('Resolving ADB executable...')
  const adbPath = await resolveAdbPath(options.adbPath)
  log(`ADB: ${adbPath}`)

  await buildApkIfNeeded(options)

  const apkPath = path.resolve(options.apkPath)
  const apkExists = await fileExists(apkPath)
  if (!apkExists && !options.skipInstall) {
    throw new Error(`apk_not_found: ${apkPath}`)
  }

  const token = await loginServer(options.serverHttp, options.username, options.password)
  log(`Server login success: ${options.serverHttp}`)

  const adbListResult = await adb(adbPath, ['devices', '-l'], {
    cwd: ROOT_DIR,
    timeoutMs: 10000,
    allowFailure: false
  })
  const adbDevices = parseAdbDeviceList(adbListResult.stdout)
  if (adbDevices.length === 0) {
    throw new Error('no_adb_devices_online')
  }

  const onlySerialSet = new Set(
    String(options.onlySerials || '')
      .split(',')
      .map((v) => v.trim())
      .filter(Boolean)
  )

  const filteredAdb = adbDevices.filter((item) => {
    if (onlySerialSet.size === 0) return true
    return onlySerialSet.has(item.serial)
  })
  if (filteredAdb.length === 0) {
    throw new Error('no_adb_devices_after_filter')
  }

  log(`Detected ${filteredAdb.length} ADB transports. Collecting runtime fingerprints...`)
  const runtimes = []
  for (const item of filteredAdb) {
    runtimes.push(await collectDeviceRuntime(adbPath, item))
  }

  const byAndroidId = new Map()
  for (const runtime of runtimes) {
    const key = runtime.androidId || runtime.serial
    if (!byAndroidId.has(key)) byAndroidId.set(key, [])
    byAndroidId.get(key).push(runtime)
  }

  const devices = []
  for (const [androidId, candidates] of byAndroidId.entries()) {
    const selected = selectBestTransport(candidates)
    devices.push({
      ...selected,
      androidId,
      duplicateSerials: candidates.map((v) => v.serial).filter((v) => v !== selected.serial)
    })
  }

  const selectedDevices = options.maxDevices ? devices.slice(0, options.maxDevices) : devices
  log(`Selected ${selectedDevices.length} physical devices after transport dedupe.`)

  const runDir = path.resolve(ROOT_DIR, `_tmp_adb_control_matrix_runs_${Date.now()}`)
  await mkdir(runDir, { recursive: true })

  const matrixReport = {
    startedAt: nowIso(),
    serverHttp: options.serverHttp,
    socketUrl: options.socketUrl,
    adbPath,
    apkPath,
    runDir,
    options: {
      buildApk: !!options.buildApk,
      skipInstall: !!options.skipInstall,
      dryRun: !!options.dryRun,
      maxDevices: options.maxDevices || null,
      attempts: options.attempts || null,
      parallelDevices: options.parallelDevices || null,
      triageLogcatLines: options.triageLogcatLines || null
    },
    devices: [],
    summary: {
      pass: 0,
      fail: 0
    }
  }

  const parallelDevices = options.parallelDevices && options.parallelDevices > 0
    ? options.parallelDevices
    : selectedDevices.length
  log(`Running matrix with concurrency=${Math.max(1, parallelDevices)} on ${selectedDevices.length} devices.`)
  const deviceResults = await runWithConcurrency(selectedDevices, parallelDevices, async (device) => {
    return runSingleDeviceMatrix({
      device,
      adbPath,
      apkPath,
      options,
      token,
      runDir
    })
  })

  for (const deviceResult of deviceResults) {
    if (!deviceResult) continue
    if (deviceResult.finalStatus === 'passed') {
      matrixReport.summary.pass += 1
    } else {
      matrixReport.summary.fail += 1
    }
    matrixReport.devices.push(deviceResult)
  }

  matrixReport.finishedAt = nowIso()
  await writeFile(options.reportPath, JSON.stringify(matrixReport, null, 2), 'utf8')
  log(`Matrix report saved: ${options.reportPath}`)
  log(`Summary: pass=${matrixReport.summary.pass}, fail=${matrixReport.summary.fail}`)
}

main().catch((error) => {
  console.error(`[${nowIso()}] fatal: ${error instanceof Error ? error.message : String(error)}`)
  process.exit(1)
})
