#!/usr/bin/env node
/**
 * Copyright (c) 2026 Huawei Technologies Co., Ltd.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

/**
 * RNOH C++ gtest Coverage Check Script
 *
 * Builds RNOH C++ tests with coverage instrumentation, runs them, collects
 * runtime profile data, and reports per-source-file LINE and BRANCH coverage.
 *
 * This is a DYNAMIC analysis tool (build + run + parse coverage data), in
 * contrast to gtest-quality-check.js which is a STATIC (file-parse only)
 * tool. The two are intentionally separate: quality-check is fast and has
 * zero build dependencies; coverage-check requires a working toolchain and
 * is run on demand / in CI to obtain real line & branch coverage numbers.
 *
 * Coverage results are reported as INDEPENDENT columns (LineCov%, BranchCov%)
 * and are NOT folded into the static Health score — they measure different
 * things and merging them would double-count "coverage".
 *
 * Toolchain:
 *   - macOS: clang (Xcode). llvm-cov / llvm-profdata resolved via `xcrun`.
 *   - Linux: clang or GCC. clang → source-based; GCC → gcov-style (needs lcov).
 *   - Windows:
 *       • clang/clang-cl (LLVM) — FULL line+branch coverage, same pipeline as
 *         macOS/Linux. Install LLVM (standalone or VS "C++ Clang tools").
 *       • MSVC (cl.exe) + OpenCppCoverage — LINE coverage only (branch
 *         unavailable). Install OpenCppCoverage (choco/scoop) for the fallback.
 *
 * Usage: node scripts/gtest-coverage-check.js [options]
 *   --dir <path>          Source root to FILTER coverage report to (default: CPP_DIR)
 *   --build-dir <path>    CMake build directory (default: <cpp>/tests/build-cov)
 *   --target <name>       CMake/test executable target (default: auto-discover all)
 *   --all-targets         Auto-discover & run all rnoh_* test targets
 *   --no-build            Skip configure+build (use existing build-dir)
 *   --no-run              Skip running tests (use existing profraw/profdata)
 *   --json                Output JSON
 *   --quiet               Summary only (no per-file breakdown)
 *   --help
 *
 * Thresholds (independent of Health):
 *   GREEN  : line >= 70% AND branch >= 60%
 *   YELLOW : line >= 50% OR  branch >= 30%
 *   RED    : below YELLOW
 */

const fs = require('fs');
const os = require('os');
const path = require('path');
const {spawnSync} = require('child_process');

const CPP_DIR = path.join(
  __dirname,
  '..',
  'packages',
  'tester',
  'harmony',
  'react_native_openharmony',
  'src',
  'main',
  'cpp',
);
const TESTS_DIR = path.join(CPP_DIR, 'tests');

// No default target: when --target is not given, the script auto-discovers
// all rnoh_* test targets (same as --all-targets). The old default
// 'rnoh_tests' was removed by the per-module split and no longer exists
// as a buildable target, so hardcoding it would cause MSB1009 / build
// failures on every bare invocation.
const DEFAULT_TARGET = null;

const LINE_GREEN = 70;
const LINE_YELLOW = 50;
const BRANCH_GREEN = 60;
const BRANCH_YELLOW = 30;

let generatorOverride = null;
let jsonMode = false;
function progress(msg) {
  if (!jsonMode) {
    process.stdout.write(msg + '\n');
  }
}
function warn(msg) {
  process.stderr.write('⚠ ' + msg + '\n');
}

function isDarwin() {
  return process.platform === 'darwin';
}
function isWindows() {
  return process.platform === 'win32';
}

// Common Windows install locations for LLVM and OpenCppCoverage, scanned
// when the tool is not on PATH.
const WIN_LLVM_CANDIDATES = [
  'C:\\Program Files\\LLVM\\bin',
  'C:\\Program Files (x86)\\LLVM\\bin',
  // Visual Studio "C++ Clang Compiler for Windows" component
  'C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\Llvm\\x64\\bin',
  'C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\VC\\Tools\\Llvm\\x64\\bin',
  'C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\VC\\Tools\\Llvm\\x64\\bin',
  'C:\\Program Files\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\Llvm\\x64\\bin',
];
const WIN_OPCPP_CANDIDATES = [
  'C:\\Program Files\\OpenCppCoverage',
  'C:\\Program Files (x86)\\OpenCppCoverage',
];

function findTool(name, extraDirs = []) {
  // 1. Try the platform's PATH lookup command.
  //    Windows `where` returns all matches (newline-separated); POSIX `which`
  //    returns one. Both exit non-zero when not found.
  const lookupBin = isWindows() ? 'where' : 'which';
  const direct = spawnSync(lookupBin, [name], {
    encoding: 'utf8',
    shell: isWindows(),
  });
  if (direct.status === 0 && direct.stdout.trim()) {
    // `where` may return multiple lines; take the first.
    return direct.stdout.trim().split(/\r?\n/)[0];
  }
  // 2. macOS: fall back to `xcrun --find` (Xcode-bundled tools).
  if (isDarwin()) {
    const xcrun = spawnSync('xcrun', ['--find', name], {encoding: 'utf8'});
    if (xcrun.status === 0 && xcrun.stdout.trim()) {
      return xcrun.stdout.trim();
    }
  }
  // 3. Windows: scan common install directories.
  if (isWindows()) {
    const exe = name.endsWith('.exe') ? name : name + '.exe';
    for (const dir of [...extraDirs, ...WIN_LLVM_CANDIDATES]) {
      const p = path.join(dir, exe);
      if (fs.existsSync(p)) {
        return p;
      }
    }
  }
  return null;
}

function run(cmd, args, opts = {}) {
  const res = spawnSync(cmd, args, {encoding: 'utf8', ...opts});
  if (res.error) {
    throw new Error(`Failed to execute ${cmd}: ${res.error.message}`);
  }
  return res;
}

function runOrThrow(cmd, args, opts, label) {
  const res = run(cmd, args, opts);
  if (res.status !== 0) {
    throw new Error(
      `[${label}] ${cmd} ${args.join(' ')} exited ${res.status}\n${res.stderr || res.stdout}`,
    );
  }
  return res;
}

function findCmake() {
  const c = findTool('cmake');
  if (!c) {
    throw new Error(
      isWindows()
        ? 'cmake not found. Install: winget install Kitware.CMake'
        : 'cmake not found. Install: brew install cmake  (or apt-get install cmake)',
    );
  }
  return c;
}

function findOpenCppCoverage() {
  // `where OpenCppCoverage` works if it's on PATH (installer adds it).
  const direct = spawnSync('where', ['OpenCppCoverage'], {
    encoding: 'utf8',
    shell: true,
  });
  if (direct.status === 0 && direct.stdout.trim()) {
    return direct.stdout.trim().split(/\r?\n/)[0];
  }
  const exe = 'OpenCppCoverage.exe';
  for (const dir of WIN_OPCPP_CANDIDATES) {
    const p = path.join(dir, exe);
    if (fs.existsSync(p)) {
      return p;
    }
  }
  return null;
}

function detectToolchain(cmakeBin, buildDir) {
  const cachePath = path.join(buildDir, 'CMakeCache.txt');
  let compiler = null;
  let compilerId = null;
  if (fs.existsSync(cachePath)) {
    const cache = fs.readFileSync(cachePath, 'utf8');
    const m = cache.match(/CMAKE_CXX_COMPILER:[^=]*=(.+)/);
    if (m) {
      compiler = m[1].trim();
    }
    const idM = cache.match(/CMAKE_CXX_COMPILER_ID:[^=]*=(\S+)/);
    if (idM) {
      compilerId = idM[1].trim();
    }
  }

  const clang =
    findTool('clang++') || findTool('clang') || findTool('clang-cl');
  const gcc = findTool('g++');
  const llvmCov = findTool('llvm-cov');
  const llvmProfdata = findTool('llvm-profdata');
  // The clang source-based path needs (a) a clang-class compiler producing
  // .profraw and (b) the llvm coverage tools to process it. On macOS the
  // default `c++`/`g++` are clang symlinks, so treat any clang-found macOS
  // box as clang. On Linux/Windows the compiler must actually be clang.
  const isClangCompiler =
    (compiler && /clang/i.test(compiler)) ||
    (!compiler && !!clang && !gcc) ||
    (isDarwin() && !!clang);
  const isClangAvailable = isClangCompiler && !!llvmCov && !!llvmProfdata;

  // MSVC fallback (Windows only): OpenCppCoverage does runtime instrumentation,
  // so no compile-time coverage flags are needed — a normal Debug/Release build
  // with PDB debug info is sufficient. Branch coverage is NOT supported here.
  // We treat "MSVC fallback" as: on Windows, the clang/LLVM toolchain is absent
  // but OpenCppCoverage is installed. CMake then picks the MSVC generator by
  // default in that environment (no clang-cl in the toolchain set).
  const openCpp = isWindows() ? findOpenCppCoverage() : null;
  const isMsvcFallback = isWindows() && !isClangAvailable && !!openCpp;

  // B3: GCC path (Linux). When clang and MSVC are both unavailable but
  // gcov + lcov are installed and CMake selected GCC (CMAKE_CXX_COMPILER_ID
  // = GNU, or no cache yet but g++ is the default c++), use the gcov + lcov
  // pipeline (line + branch via --rc branch_coverage=1). CMakeLists.txt
  // already adds --coverage -fprofile-arcs -ftest-coverage for GCC under
  // RNOH_COVERAGE=ON, so no CMake change is needed.
  const gcov = findTool('gcov');
  const lcov = findTool('lcov');
  const isGccCompiler =
    compilerId === 'GNU' || (!compilerId && !!gcc && !isClangCompiler);
  const isGcc =
    !isClangAvailable && !isMsvcFallback && !!gcov && !!lcov && isGccCompiler;

  return {
    isClang: isClangAvailable,
    isMsvcFallback,
    isGcc,
    clang,
    llvmCov,
    llvmProfdata,
    gcov,
    lcov,
    openCpp,
  };
}

// B9 helper: the generator name passed via -G in a cmake arg list, or null.
function generatorFromArgs(genArgs) {
  const i = genArgs.indexOf('-G');
  if (i === -1) {
    return null;
  }
  return genArgs[i + 1] || null;
}

// B6: true when buildDir already holds a CMakeCache.txt whose recorded
// CMAKE_GENERATOR differs from the generator we are about to pass. When no
// -G is given (non-Windows / default), there is no forced change.
function generatorChanged(buildDir, genArgs) {
  const desired = generatorFromArgs(genArgs);
  if (!desired) {
    return false;
  }
  const cachePath = path.join(buildDir, 'CMakeCache.txt');
  if (!fs.existsSync(cachePath)) {
    return false;
  }
  const cache = fs.readFileSync(cachePath, 'utf8');
  const m = cache.match(/^CMAKE_GENERATOR:[^=]*=(.+)$/m);
  if (!m) {
    return false;
  }
  return m[1].trim() !== desired;
}

// B8: wipe buildDir but preserve the FetchContent-downloaded googletest
// source so the reconfigure can skip a fresh network download. The source
// is copied to a temp path, buildDir removed, then the source restored in
// place. cpSync (not renameSync) because os.tmpdir() may live on a
// different volume than buildDir (EXDEV on cross-volume rename). Returns
// true when a googletest source was stashed and restored.
function cleanBuildDirPreservingGtest(buildDir) {
  const gtestSrc = path.join(buildDir, '_deps', 'googletest-src');
  let stashed = null;
  if (fs.existsSync(gtestSrc) && fs.statSync(gtestSrc).isDirectory()) {
    stashed = path.join(os.tmpdir(), `rnoh-gtest-src-${process.pid}`);
    fs.rmSync(stashed, {recursive: true, force: true});
    fs.cpSync(gtestSrc, stashed, {recursive: true});
  }
  fs.rmSync(buildDir, {recursive: true, force: true});
  fs.mkdirSync(buildDir, {recursive: true});
  if (stashed) {
    const restored = path.join(buildDir, '_deps', 'googletest-src');
    fs.mkdirSync(path.dirname(restored), {recursive: true});
    fs.cpSync(stashed, restored, {recursive: true});
    fs.rmSync(stashed, {recursive: true, force: true});
  }
  return stashed !== null;
}

// B8: when a restored googletest source is present, point FetchContent at
// it so CMake skips the download. No source present → no flag.
function gtestSourceDirFlag(buildDir) {
  const gtestSrc = path.join(buildDir, '_deps', 'googletest-src');
  if (fs.existsSync(gtestSrc) && fs.statSync(gtestSrc).isDirectory()) {
    return ['-D', `FETCHCONTENT_SOURCE_DIR_GOOGLETEST=${gtestSrc}`];
  }
  return [];
}

function firstLine(s) {
  if (!s) {
    return '';
  }
  return s.split(/\r?\n/).find(l => l.trim()) || '';
}

function buildGeneratorAttempts(useCoverageFlags) {
  if (!isWindows()) {
    return [[]];
  }
  const userGen = generatorOverride;
  const isVsGen = !userGen || /^Visual Studio/.test(userGen);
  const toolset = useCoverageFlags && isVsGen ? ['-T', 'ClangCL'] : [];
  if (userGen) {
    return [['-G', userGen, ...toolset]];
  }
  return [
    ['-G', 'Visual Studio 17 2022', ...toolset],
    ['-G', 'Visual Studio 16 2019', ...toolset],
  ];
}

function tryConfigureAttempt(
  cmakeBin,
  buildDir,
  flag,
  gen,
  i,
  attemptsLen,
  forceClean,
) {
  let didRestore = false;
  if (forceClean || generatorChanged(buildDir, gen)) {
    didRestore = cleanBuildDirPreservingGtest(buildDir);
  }
  const gtestFlag = didRestore ? gtestSourceDirFlag(buildDir) : [];
  const fallback = attemptsLen > 1 && i > 0 ? ` (fallback: ${gen[1]})` : '';
  progress(`▸ Configuring (cmake ${flag}) → ${buildDir}${fallback}`);
  const res = run(
    cmakeBin,
    [...gen, '-B', buildDir, '-S', TESTS_DIR, flag, ...gtestFlag],
    {stdio: 'inherit'},
  );
  if (res.status === 0) {
    return {ok: true, nextForceClean: false, error: null};
  }
  if (i < attemptsLen - 1) {
    warn(
      `cmake configure with ${gen[1] || 'default generator'} failed — trying fallback. ` +
        `(${firstLine(res.stderr || res.stdout)})`,
    );
  }
  return {ok: false, nextForceClean: i < attemptsLen - 1, error: res};
}

function configureBuild(cmakeBin, buildDir, target, useCoverageFlags = true) {
  fs.mkdirSync(buildDir, {recursive: true});
  const flag = useCoverageFlags ? '-DRNOH_COVERAGE=ON' : '-DRNOH_COVERAGE=OFF';
  const attempts = buildGeneratorAttempts(useCoverageFlags);
  let forceClean = false;
  let lastError = null;
  for (let i = 0; i < attempts.length; i++) {
    const result = tryConfigureAttempt(
      cmakeBin,
      buildDir,
      flag,
      attempts[i],
      i,
      attempts.length,
      forceClean,
    );
    if (result.ok) {
      if (useCoverageFlags) {
        verifyCoverageCompiler(buildDir);
      }
      return;
    }
    forceClean = result.nextForceClean;
    lastError = result.error;
  }
  throw new Error(
    `[cmake-configure] cmake ${flag} exited ${lastError.status}\n${lastError.stderr || lastError.stdout}`,
  );
}

// B7: read the compiler CMake actually selected, from CMakeCache.txt. Tries
// CMAKE_CXX_COMPILER first; VS generators may leave that as NOTFOUND but set
// CMAKE_CXX_COMPILER_AR / CMAKE_CXX_COMPILER_RANLIB (llvm-ar / llvm-ranlib,
// which ship in the same bin dir as clang-cl). Returns {path, key} or null.
function readCmakeCxxCompiler(buildDir) {
  const cachePath = path.join(buildDir, 'CMakeCache.txt');
  if (!fs.existsSync(cachePath)) {
    return null;
  }
  const cache = fs.readFileSync(cachePath, 'utf8');
  const keys = [
    'CMAKE_CXX_COMPILER',
    'CMAKE_CXX_COMPILER_AR',
    'CMAKE_CXX_COMPILER_RANLIB',
  ];
  for (const key of keys) {
    const m = cache.match(new RegExp(`^${key}:[^=]*=(.+)$`, 'm'));
    if (!m) {
      continue;
    }
    const p = m[1].trim();
    if (!p || /NOTFOUND$/i.test(p)) {
      continue;
    }
    return {path: p, key};
  }
  return null;
}

// B7: find llvm-profdata / llvm-cov in the same bin dir as the compiler.
// Returns {llvmProfdata, llvmCov} with nulls when not present.
function findLlvmToolsForCompiler(compilerPath) {
  if (!compilerPath) {
    return {llvmProfdata: null, llvmCov: null};
  }
  const dir = path.dirname(compilerPath);
  const exe = isWindows() ? '.exe' : '';
  const profdata = path.join(dir, `llvm-profdata${exe}`);
  const cov = path.join(dir, `llvm-cov${exe}`);
  return {
    llvmProfdata: fs.existsSync(profdata) ? profdata : null,
    llvmCov: fs.existsSync(cov) ? cov : null,
  };
}

// Verify the configured compiler is actually clang when coverage was
// requested, and (B7) that the llvm coverage tools ship alongside it. Only
// meaningful on Windows (the VS generator can silently pick MSVC, and a
// PATH-resolved llvm-profdata/llvm-cov may be from a different LLVM than the
// clang-cl CMake selected — causing profraw version mismatch). macOS/Linux
// resolve tools via xcrun/PATH from the same source as the default clang.
function verifyCoverageCompiler(buildDir) {
  if (!isWindows()) {
    return;
  }
  const ci = readCmakeCxxCompiler(buildDir);
  if (!ci) {
    return;
  }
  const compiler = ci.path;
  // The non-clang name check only applies when CMAKE_CXX_COMPILER itself was
  // set. VS generators reach the toolchain via CMAKE_CXX_COMPILER_AR /
  // _RANLIB (llvm-ar / llvm-ranlib), whose names are not "clang" — rely on
  // the same-dir tool check below for those.
  if (ci.key === 'CMAKE_CXX_COMPILER' && !/clang/i.test(compiler)) {
    throw new Error(
      `RNOH_COVERAGE=ON but CMake selected a non-clang compiler: ${compiler}. ` +
        'On Windows the VS generator defaults to MSVC, which silently disables ' +
        'coverage instrumentation. Install the VS "C++ Clang Compiler for ' +
        'Windows" component (the script passes -T ClangCL), or use the ' +
        'MSVC + OpenCppCoverage fallback path (install OpenCppCoverage).',
    );
  }
  // B7: the llvm coverage tools must live in the same bin dir as the
  // compiler, else llvm-profdata/llvm-cov from a different LLVM install will
  // reject the .profraw with a version-mismatch error (profraw vN vs
  // llvm-profdata vM). runClangCoverage overrides tc with these; if none are
  // alongside the compiler, alignment is impossible — fail fast.
  const aligned = findLlvmToolsForCompiler(compiler);
  if (!aligned.llvmProfdata && !aligned.llvmCov) {
    throw new Error(
      `llvm-profdata/llvm-cov not found alongside the CMake-selected ` +
        `compiler ${compiler} (resolved via ${ci.key}). A PATH-resolved ` +
        'llvm-profdata/llvm-cov from a different LLVM install would reject ' +
        'the .profraw with a version-mismatch error. Install the LLVM ' +
        'matching the compiler, or add its bin dir to PATH ahead of any ' +
        'other LLVM install.',
    );
  }
}

function buildTarget(cmakeBin, buildDir, target) {
  progress(`▸ Building target: ${target}`);
  // MSVC multi-config generators require --config.
  const config = isWindows() ? ['--config', 'Release'] : [];
  runOrThrow(
    cmakeBin,
    ['--build', buildDir, '--target', target, ...config],
    {stdio: 'inherit'},
    'cmake-build',
  );
}

// Auto-discover all RNOH test targets from a configured build directory.
// This makes the script zero-maintenance: newly added test targets (from
// any contributor's commit) are picked up automatically, no script edit
// needed. SDK-gated targets (guarded by `if(OH_SDK_NATIVE)` in CMakeLists)
// only appear when the SDK is actually detected, so the script never tries
// to build a target that CMake didn't generate.
//
// Must be called AFTER configureBuild (needs a configured build dir).
function discoverTestTargets(cmakeBin, buildDir) {
  const res = run(cmakeBin, ['--build', buildDir, '--target', 'help'], {
    encoding: 'utf8',
  });
  if (res.status !== 0) {
    // B5: the "help" target can fail on some generator states (notably
    // Visual Studio). Fall back to parsing the .sln files CMake wrote into
    // buildDir, extracting every Project whose name starts with rnoh_.
    const slnTargets = parseSlnTargets(buildDir);
    if (slnTargets.length > 0) {
      return slnTargets;
    }
    throw new Error(
      `Failed to enumerate targets (cmake --build ${buildDir} --target help):\n${res.stderr || res.stdout}`,
    );
  }
  const out = res.stdout || '';
  const set = new Set();
  for (const line of out.split(/\r?\n/)) {
    // Target-list lines look like "... rnoh_xxx_test" (Makefiles) or
    // "rnoh_xxx_test" (Ninja). Match the rnoh_ token, then exclude
    // object/assembly pseudo-targets (rnoh_xxx.dir, *.o/*.i/*.s) that
    // some generators also list.
    const m = line.match(/\b(rnoh_[a-z0-9_]+)\b/);
    if (!m) continue;
    const t = m[1];
    if (/\.(o|i|s|dir)$/.test(t)) continue;
    set.add(t);
  }
  const list = [...set].sort();
  if (list.length === 0) {
    throw new Error(
      `No rnoh_* test targets discovered in ${buildDir}. ` +
        'Ensure tests/CMakeLists.txt defines at least one add_executable(rnoh_...).',
    );
  }
  return list;
}

// B5: discover rnoh_* targets from Visual Studio .sln files when the
// `cmake --build --target help` enumeration is unavailable (the help
// target can fail on VS generators).
function parseSlnTargets(buildDir) {
  const set = new Set();
  let slns = [];
  try {
    slns = fs.readdirSync(buildDir).filter(f => f.endsWith('.sln'));
  } catch {
    return [];
  }
  const re = /Project\("[^"]*"\)\s*=\s*"(rnoh_[^"]+)"/g;
  for (const sln of slns) {
    let text = '';
    try {
      text = fs.readFileSync(path.join(buildDir, sln), 'utf8');
    } catch {
      continue;
    }
    let m;
    while ((m = re.exec(text)) !== null) {
      const t = m[1];
      if (/\.(o|i|s|dir)$/.test(t)) continue;
      set.add(t);
    }
  }
  return [...set].sort();
}

function binaryPath(buildDir, target) {
  const ext = process.platform === 'win32' ? '.exe' : '';
  const name = target + ext;
  // Covers: single-config (Makefile/Ninja) and Windows multi-config (VS),
  // including the VS per-target subfolder layout.
  const candidates = [
    path.join(buildDir, name),
    path.join(buildDir, target, name),
    path.join(buildDir, 'Release', name),
    path.join(buildDir, 'Release', target, name),
    path.join(buildDir, 'Debug', name),
    path.join(buildDir, 'Debug', target, name),
  ];
  for (const c of candidates) {
    if (fs.existsSync(c)) {
      return c;
    }
  }
  return candidates[0];
}

function runTests(binary, buildDir) {
  const profrawGlob = path.join(buildDir, 'cov-%p.profraw');
  progress(
    `▸ Running ${path.basename(binary)} (LLVM_PROFILE_FILE=${profrawGlob})`,
  );
  const res = run(binary, [], {
    encoding: 'utf8',
    stdio: 'inherit',
    env: {...process.env, LLVM_PROFILE_FILE: profrawGlob},
  });
  if (res.status !== 0) {
    console.warn(
      `⚠ ${path.basename(binary)} exited ${res.status} — some tests failed, collecting partial coverage`,
    );
  }
  return res;
}

// --- MSVC + OpenCppCoverage path (Windows-only, LINE coverage only) ---
// OpenCppCoverage instruments at RUNTIME by hooking the binary (no
// compile-time coverage flags needed). It reads PDB debug info for line
// mapping and emits Cobertura XML. Branch coverage is not supported.
function runOpenCppCoverage(openCpp, binary, buildDir, reportDir, xmlPath) {
  progress(
    `▸ Running ${path.basename(binary)} under OpenCppCoverage → ${path.basename(xmlPath)}`,
  );
  // B1: pass reportDir itself as the --sources pattern (prefix match).
  // Previously this passed reportDir/RNOH and reportDir/RNOHCorePackage,
  // which broke when --dir pointed at a subdir (the constructed --sources
  // path did not exist → empty report). Filtering to the report root is
  // now done by parseCoberturaXML's isUnderReportDir.
  const args = [
    '--export_type',
    'cobertura',
    '--export_filename',
    xmlPath,
    '--sources',
    reportDir,
    '--',
    binary,
  ];
  const res = run(openCpp, args, {
    encoding: 'utf8',
    stdio: 'inherit',
    shell: isWindows(),
  });
  if (res.status !== 0) {
    throw new Error(
      `OpenCppCoverage exited ${res.status} — coverage not collected`,
    );
  }
  if (!fs.existsSync(xmlPath)) {
    throw new Error(`OpenCppCoverage did not produce ${xmlPath}`);
  }
  return xmlPath;
}

function parseCoberturaXML(xml, reportDir) {
  const rows = [];
  // Lightweight Cobertura XML parsing (no external deps). Each <class>
  // filename is relativized + filtered; line/branch counts come from the
  // nested <lines> block (parsed in parseClassLines to keep depth flat).
  const classRe = /<class\b[^>]*\bfilename="([^"]+)"[^>]*>/gi;
  let m;
  while ((m = classRe.exec(xml)) !== null) {
    const classTag = m[0];
    const filename = m[1];
    const rel = relativize(filename);
    if (!isRnohSource(rel)) {
      continue;
    }
    // B1: also restrict to the --dir report root (matches the clang path in
    // summarizeCoverage). runOpenCppCoverage passes reportDir itself as
    // --sources, so this filter handles subdir --dir requests.
    if (!isUnderReportDir(rel, reportDir)) {
      continue;
    }
    const lineRate = parseFloat(getAttr(classTag, 'line-rate') || '0');
    const branchRate = parseFloat(getAttr(classTag, 'branch-rate') || '0');
    const linesBlock = extractBlock(xml, m.index, 'lines');
    const acc = parseClassLines(linesBlock);
    const linePercent = acc.lineCount
      ? (acc.lineCovered / acc.lineCount) * 100
      : lineRate * 100;
    const branchPercent = acc.branchCount
      ? (acc.branchCovered / acc.branchCount) * 100
      : acc.lineCovered > 0
        ? 100
        : 0;
    rows.push({
      source: rel,
      lineCount: acc.lineCount,
      lineCovered: acc.lineCovered,
      linePercent,
      branchCount: acc.branchCount,
      branchCovered: acc.branchCovered,
      branchPercent,
      coLocatedTest: coLocatedTestFile(rel),
    });
  }
  return rows.sort((a, b) => a.source.localeCompare(b.source));
}

// Parse a Cobertura <lines> block into line/branch counters. Extracted from
// parseCoberturaXML so the while→if→while→if→if nesting (depth 6) flattens.
function parseClassLines(linesBlock) {
  const acc = {
    lineCount: 0,
    lineCovered: 0,
    branchCount: 0,
    branchCovered: 0,
  };
  if (!linesBlock) {
    return acc;
  }
  const lineRe = /<line\b[^>]*\/>/gi;
  let lm;
  while ((lm = lineRe.exec(linesBlock)) !== null) {
    const lt = lm[0];
    const hits = parseInt(getAttr(lt, 'hits') || '0', 10);
    acc.lineCount++;
    if (hits > 0) {
      acc.lineCovered++;
    }
    if (getAttr(lt, 'branch') === 'true') {
      // condition-coverage="50% (1/2)" → covered/total
      const cc = getAttr(lt, 'condition-coverage') || '';
      const ccMatch = cc.match(/(\d+)\s*\/\s*(\d+)/);
      if (ccMatch) {
        acc.branchCovered += parseInt(ccMatch[1], 10);
        acc.branchCount += parseInt(ccMatch[2], 10);
      } else {
        acc.branchCount += 2; // assume 2 branches if unparseable
      }
    }
  }
  return acc;
}

function getAttr(tag, name) {
  const re = new RegExp(`\\b${name}="([^"]*)"`, 'i');
  const m = tag.match(re);
  return m ? m[1] : null;
}

function extractBlock(xml, startIdx, tag) {
  const openRe = new RegExp(`<${tag}\\b[^>]*>`, 'gi');
  openRe.lastIndex = startIdx;
  const om = openRe.exec(xml);
  if (!om) {
    return null;
  }
  const closeTag = `</${tag}>`;
  const closeIdx = xml.indexOf(closeTag, om.index + om[0].length);
  if (closeIdx === -1) {
    return null;
  }
  return xml.substring(om.index + om[0].length, closeIdx);
}

function collectProfraw(buildDir) {
  const all = fs
    .readdirSync(buildDir)
    .filter(f => f.endsWith('.profraw'))
    .map(f => path.join(buildDir, f));
  return all;
}

// Remove stale profraw/profdata from a previous run so the current run's
// coverage reflects ONLY the current execution. llvm-profdata writes one
// cov-<pid>.profraw per run (LLVM_PROFILE_FILE=cov-%p.profraw); without
// cleanup, orphaned files from prior runs get merged in and inflate
// coverage (e.g. deleted tests' lines still show as covered).
// Unlink a file, swallowing errors (file may not exist / be locked).
function safeUnlink(p) {
  try {
    fs.unlinkSync(p);
  } catch {
    /* ignore unlink failures */
  }
}

function cleanStaleCoverage(buildDir, profdataPath) {
  if (fs.existsSync(buildDir)) {
    const stale = fs.readdirSync(buildDir).filter(f => f.endsWith('.profraw'));
    for (const f of stale) {
      safeUnlink(path.join(buildDir, f));
    }
  }
  if (profdataPath && fs.existsSync(profdataPath)) {
    safeUnlink(profdataPath);
  }
}

function mergeProfraw(profdataBin, profrawFiles, profdataPath) {
  if (profrawFiles.length === 0) {
    throw new Error(
      'No .profraw files found — did the test run produce coverage data?',
    );
  }
  progress(
    `▸ Merging ${profrawFiles.length} profraw → ${path.basename(profdataPath)}`,
  );
  runOrThrow(
    profdataBin,
    ['merge', '-sparse', ...profrawFiles, '-o', profdataPath],
    {stdio: 'pipe'},
    'llvm-profdata',
  );
}

function exportCoverage(llvmCovBin, binaries, profdataPath) {
  progress(
    `▸ Exporting coverage JSON (llvm-cov export, ${binaries.length} binary/binaries)`,
  );
  // Pass ALL binaries to a single llvm-cov export call so LLVM merges
  // coverage mappings at the region level (correct union of covered
  // lines/branches). The old per-binary approach used Math.max in JS,
  // which fails for complementary coverage (Test1 covers lines 1-50,
  // Test2 covers 51-100 → Math.max(50,50)=50% instead of 100%).
  // Syntax: llvm-cov export <bin1> -object <bin2> -object <bin3> ...
  const args = [
    'export',
    '-format=json',
    `-instr-profile=${profdataPath}`,
    binaries[0],
  ];
  for (let i = 1; i < binaries.length; i++) {
    args.push('-object', binaries[i]);
  }
  const withFlag = run(llvmCovBin, args, {
    encoding: 'utf8',
    maxBuffer: 256 * 1024 * 1024,
  });
  const candidate = withFlag.status === 0 ? withFlag.stdout : null;
  let out = candidate;
  if (!out) {
    // Fallback: Apple's llvm-cov rejects -format=json but emits JSON by default.
    const noFlagArgs = [
      'export',
      `-instr-profile=${profdataPath}`,
      binaries[0],
    ];
    for (let i = 1; i < binaries.length; i++) {
      noFlagArgs.push('-object', binaries[i]);
    }
    const noFlag = runOrThrow(
      llvmCovBin,
      noFlagArgs,
      {encoding: 'utf8', maxBuffer: 256 * 1024 * 1024},
      'llvm-cov-export',
    );
    out = noFlag.stdout;
  }
  const trimmed = out.trim();
  if (!trimmed.startsWith('{')) {
    throw new Error(
      'llvm-cov export did not produce JSON. On GCC use lcov instead (not yet supported in v1).',
    );
  }
  return JSON.parse(trimmed);
}

function relativize(absPath) {
  if (!absPath) {
    return absPath;
  }
  let p = absPath;
  if (path.isAbsolute(p)) {
    const rel = path.relative(CPP_DIR, p);
    if (!rel.startsWith('..')) {
      return rel;
    }
  }
  return p;
}

function isRnohSource(relPath) {
  if (!relPath) {
    return false;
  }
  const norm = relPath.replace(/\\/g, '/');
  if (norm.startsWith('third-party/')) {
    return false;
  }
  if (norm.startsWith('tests/mock')) {
    return false;
  }
  if (norm.startsWith('tests/mock_poc')) {
    return false;
  }
  if (norm.startsWith('tests/stub')) {
    return false;
  }
  if (norm.includes('/tests/')) {
    return false;
  }
  if (/Test[s]?\.cpp$/i.test(norm)) {
    return false;
  }
  return (
    norm.startsWith('RNOH/') ||
    norm.startsWith('RNOHCorePackage/') ||
    norm === 'RNOH'
  );
}

// --dir filter: restrict the report to files under reportDir. relPath is
// relative to CPP_DIR (guaranteed by isRnohSource filtering upstream).
// Default reportDir === CPP_DIR → everything passes.
function isUnderReportDir(relPath, reportDir) {
  const absFile = path.resolve(CPP_DIR, relPath);
  const absReport = path.resolve(reportDir || CPP_DIR);
  if (absFile === absReport) {
    return true;
  }
  return absFile.startsWith(absReport + path.sep);
}

function coLocatedTestFile(relSourcePath) {
  // relSourcePath is relative to CPP_DIR; resolve against CPP_DIR for fs ops.
  const dir = path.dirname(path.join(CPP_DIR, relSourcePath));
  const testsDir = path.join(dir, 'tests');
  if (fs.existsSync(testsDir)) {
    const candidates = fs
      .readdirSync(testsDir)
      .filter(f => /Test[s]?\.cpp$/i.test(f))
      .sort();
    if (candidates.length > 0) {
      const relDir = path.join(path.dirname(relSourcePath), 'tests');
      return `${relDir}/ (${candidates.length}: ${candidates.join(', ')})`;
    }
  }
  return null;
}

function collectCoverageFileRows(data, reportDir) {
  const fileRows = [];
  for (const unit of data) {
    const files = unit.files || [];
    for (const f of files) {
      const rel = relativize(f.filename);
      if (!isRnohSource(rel)) {
        continue;
      }
      if (!isUnderReportDir(rel, reportDir)) {
        continue;
      }
      const s = f.summary || {};
      const lines = s.lines || {count: 0, covered: 0, percent: 0};
      const branches = s.branches || {count: 0, covered: 0, percent: 0};
      const bCount = branches.count;
      const bCovered = branches.covered;
      const bPercent = bCount
        ? (bCovered / bCount) * 100
        : lines.covered > 0
          ? 100
          : 0;
      fileRows.push({
        source: rel,
        lineCount: lines.count,
        lineCovered: lines.covered,
        linePercent: lines.percent,
        branchCount: bCount,
        branchCovered: bCovered,
        branchPercent: bPercent,
        coLocatedTest: coLocatedTestFile(rel),
      });
    }
  }
  return fileRows;
}

function mergeRowsBySource(fileRows) {
  const bySource = new Map();
  for (const r of fileRows) {
    const ex = bySource.get(r.source);
    if (!ex) {
      bySource.set(r.source, r);
    } else {
      ex.lineCovered = Math.max(ex.lineCovered, r.lineCovered);
      ex.lineCount = r.lineCount || ex.lineCount;
      ex.linePercent = ex.lineCount ? (ex.lineCovered / ex.lineCount) * 100 : 0;
      ex.branchCovered = Math.max(ex.branchCovered, r.branchCovered);
      ex.branchCount = r.branchCount || ex.branchCount;
      ex.branchPercent = ex.branchCount
        ? (ex.branchCovered / ex.branchCount) * 100
        : ex.lineCovered > 0
          ? 100
          : 0;
    }
  }
  return [...bySource.values()];
}

function summarizeCoverage(covJson, reportDir) {
  const data = Array.isArray(covJson.data) ? covJson.data : [];
  const fileRows = collectCoverageFileRows(data, reportDir);
  return mergeRowsBySource(fileRows).sort((a, b) =>
    a.source.localeCompare(b.source),
  );
}

function rate(row) {
  const line = row.linePercent;
  // When branchCount == 0 (no branch points), branch coverage is only
  // meaningful if lines were actually executed. If lineCovered == 0,
  // nothing was tested → treat branch as 0 so the file rates RED, not
  // YELLOW. This prevents vacuous 100%(0/0) from masking 0% line coverage.
  const branch =
    row.branchCount === 0 && row.lineCovered === 0 ? 0 : row.branchPercent;
  if (line >= LINE_GREEN && branch >= BRANCH_GREEN) {
    return {rating: 'GREEN', color: '🟢'};
  }
  if (line >= LINE_YELLOW || branch >= BRANCH_YELLOW) {
    return {rating: 'YELLOW', color: '🟡'};
  }
  return {rating: 'RED', color: '🔴'};
}

function computeTotals(rows) {
  const totalLine = rows.reduce(
    (a, r) => ({
      count: a.count + r.lineCount,
      covered: a.covered + r.lineCovered,
    }),
    {count: 0, covered: 0},
  );
  const totalBranch = rows.reduce(
    (a, r) => ({
      count: a.count + r.branchCount,
      covered: a.covered + r.branchCovered,
    }),
    {count: 0, covered: 0},
  );
  const linePct = totalLine.count
    ? (totalLine.covered / totalLine.count) * 100
    : 0;
  const branchPct = totalBranch.count
    ? (totalBranch.covered / totalBranch.count) * 100
    : 0;
  const overall = rate({linePercent: linePct, branchPercent: branchPct});
  return {totalLine, totalBranch, linePct, branchPct, overall};
}

function formatRow(r) {
  const rt = rate(r);
  const lineStr = r.lineCount
    ? `${r.linePercent.toFixed(1)}% (${r.lineCovered}/${r.lineCount})`
    : '—';
  const brStr = r.branchCount
    ? `${r.branchPercent.toFixed(1)}% (${r.branchCovered}/${r.branchCount})`
    : r.lineCovered > 0
      ? '100.0% (0/0)'
      : '0.0% (0/0)';
  const lines = [
    `📄 ${r.source}`,
    `   Line: ${lineStr} | Branch: ${brStr} | ${rt.color} ${rt.rating}`,
  ];
  if (r.coLocatedTest) {
    lines.push(`   ↳ co-located test: ${r.coLocatedTest}`);
  }
  return lines.join('\n');
}

function printJsonReport(rows, totals) {
  console.log(
    JSON.stringify(
      {
        summary: {
          files: rows.length,
          lineCoverage: Math.round(totals.linePct),
          branchCoverage: Math.round(totals.branchPct),
          rating: totals.overall.rating,
          thresholds: {
            lineGreen: LINE_GREEN,
            lineYellow: LINE_YELLOW,
            branchGreen: BRANCH_GREEN,
            branchYellow: BRANCH_YELLOW,
          },
        },
        files: rows.map(r => ({...r, rating: rate(r).rating})),
      },
      null,
      2,
    ),
  );
}

function printTextReport(rows, totals, quiet) {
  console.log(
    '\n╔════════════════════════════════════════════════════════════════╗',
  );
  console.log(
    '║          RNOH C++ gtest Coverage Report (line + branch)        ║',
  );
  console.log(
    '╚════════════════════════════════════════════════════════════════╝\n',
  );
  if (!quiet) {
    for (const r of rows) {
      console.log(formatRow(r));
    }
  }
  console.log(
    '\n═══════════════════════════════════════════════════════════════',
  );
  console.log(
    `Summary: ${rows.length} files | Line: ${totals.linePct.toFixed(1)}% | Branch: ${totals.branchPct.toFixed(1)}% | ${totals.overall.color} ${totals.overall.rating}`,
  );
  console.log(
    '═══════════════════════════════════════════════════════════════\n',
  );
}

function printReport(rows, json, quiet) {
  if (rows.length === 0) {
    console.log('No RNOH source files found in coverage report.');
    return;
  }
  const totals = computeTotals(rows);
  if (json) {
    printJsonReport(rows, totals);
    return;
  }
  printTextReport(rows, totals, quiet);
}

function printHelp() {
  console.log('Usage: node scripts/gtest-coverage-check.js [options]');
  console.log(
    '  --dir <path>          Source root to FILTER coverage report to (default: RNOH cpp dir)',
  );
  console.log(
    '  --build-dir <path>    CMake build dir (default: <cpp>/tests/build-cov)',
  );
  console.log(
    '  --generator <name>    CMake -G override (default: VS2022→VS2019)',
  );
  console.log(
    '  --target <name>       Executable target (default: auto-discover all)',
  );
  console.log(
    '  --all-targets         Auto-discover & run all rnoh_* test targets (zero-maintenance)',
  );
  console.log('  --no-build            Skip configure+build');
  console.log('  --no-run              Skip running tests');
  console.log('  --json                Output JSON');
  console.log('  --quiet               Summary only (no per-file breakdown)');
  console.log('  --help                Show this help');
}

function parseArgs(argv) {
  const opts = {
    reportDir: CPP_DIR,
    buildDir: path.join(TESTS_DIR, 'build-cov'),
    target: DEFAULT_TARGET,
    allTargets: false,
    noBuild: false,
    noRun: false,
    json: false,
    quiet: false,
    generator: null,
  };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--dir' && argv[i + 1]) {
      opts.reportDir = argv[++i];
    } else if (a === '--build-dir' && argv[i + 1]) {
      opts.buildDir = argv[++i];
    } else if (a === '--target' && argv[i + 1]) {
      opts.target = argv[++i];
    } else if (a === '--all-targets') {
      opts.allTargets = true;
    } else if (a === '--no-build') {
      opts.noBuild = true;
    } else if (a === '--no-run') {
      opts.noRun = true;
    } else if (a === '--json') {
      opts.json = true;
    } else if (a === '--quiet') {
      opts.quiet = true;
    } else if (a === '--generator' && argv[i + 1]) {
      opts.generator = argv[++i];
    } else if (a === '--help' || a === '-h') {
      printHelp();
      process.exit(0);
    } else {
      console.error(`Unknown option: ${a}`);
      process.exit(2);
    }
  }
  return opts;
}

function main() {
  const opts = parseArgs(process.argv.slice(2));
  jsonMode = opts.json;
  generatorOverride = opts.generator;

  // When no --target is explicitly given, auto-discover all targets
  // (same as --all-targets). The old default 'rnoh_tests' was removed by
  // the per-module split and no longer exists as a buildable target.
  if (!opts.target && !opts.allTargets) {
    opts.allTargets = true;
  }

  const cmakeBin = findCmake();
  const tc = detectToolchain(cmakeBin, opts.buildDir);
  // NOTE: --all-targets now AUTO-DISCOVERS all rnoh_* test targets from the
  // configured build dir (see discoverTestTargets). There is no hardcoded
  // list to maintain — newly committed test targets are picked up for free,
  // and SDK-gated targets only appear when the SDK is detected.
  const targets = opts.allTargets || !opts.target ? [] : [opts.target];

  let rows;
  if (tc.isClang) {
    rows = runClangCoverage(
      cmakeBin,
      tc,
      opts.buildDir,
      targets,
      opts.reportDir,
      opts.noBuild,
      opts.noRun,
      opts.allTargets,
    );
  } else if (tc.isMsvcFallback) {
    rows = runMsvcCoverage(
      cmakeBin,
      tc,
      opts.buildDir,
      targets,
      opts.reportDir,
      opts.noBuild,
      opts.noRun,
      opts.allTargets,
    );
  } else if (tc.isGcc) {
    rows = runGccCoverage(
      cmakeBin,
      tc,
      opts.buildDir,
      targets,
      opts.reportDir,
      opts.noBuild,
      opts.noRun,
      opts.allTargets,
    );
  } else {
    printToolchainMissingError(tc);
    process.exit(1);
  }

  printReport(rows, opts.json, opts.quiet);
}

if (require.main === module) {
  main();
}

module.exports = {
  parseCoberturaXML,
  mergeCoberturaXML,
  parseLcovInfo,
  summarizeCoverage,
  isRnohSource,
  isUnderReportDir,
  relativize,
  coLocatedTestFile,
  rate,
  cleanStaleCoverage,
};

function discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild) {
  if (allTargets) {
    targets = discoverTestTargets(cmakeBin, buildDir);
    progress(
      `▸ Auto-discovered ${targets.length} test target(s):\n   ${targets.join(', ')}`,
    );
  }
  if (!noBuild) {
    for (const t of targets) {
      try {
        buildTarget(cmakeBin, buildDir, t);
      } catch (e) {
        warn(
          `Build failed for target ${t} — skipping. ` +
            `(${String(e.message || e).split('\n')[0]})`,
        );
      }
    }
  }
  return targets;
}

function alignLlvmTools(tc, buildDir) {
  const ci = readCmakeCxxCompiler(buildDir);
  if (!ci) {
    return;
  }
  const aligned = findLlvmToolsForCompiler(ci.path);
  if (!aligned.llvmProfdata && !aligned.llvmCov) {
    if (tc.llvmProfdata) {
      warn(
        `No llvm-profdata/llvm-cov alongside compiler ${ci.path}; ` +
          `falling back to ${tc.llvmProfdata} (profraw version mismatch may occur).`,
      );
    }
    return;
  }
  if (tc.llvmProfdata !== aligned.llvmProfdata) {
    progress(
      `  llvm-profdata aligned to compiler (${ci.key}): ${aligned.llvmProfdata}`,
    );
  }
  if (aligned.llvmProfdata) {
    tc.llvmProfdata = aligned.llvmProfdata;
  }
  if (aligned.llvmCov) {
    tc.llvmCov = aligned.llvmCov;
  }
}

function clangRunAndCollect(tc, buildDir, targets, noRun, profdataPath) {
  if (!noRun) {
    cleanStaleCoverage(buildDir, profdataPath);
    for (const t of targets) {
      const bin = binaryPath(buildDir, t);
      if (!fs.existsSync(bin)) {
        warn(`Binary not found for target ${t}: ${bin} — skipping`);
        continue;
      }
      runTests(bin, buildDir);
    }
    const allProfraw = collectProfraw(buildDir);
    if (allProfraw.length === 0) {
      throw new Error(
        'No .profraw produced. Ensure tests ran with LLVM_PROFILE_FILE set.',
      );
    }
    mergeProfraw(tc.llvmProfdata, allProfraw, profdataPath);
  } else if (!fs.existsSync(profdataPath)) {
    throw new Error(`--no-run but no profdata at ${profdataPath}`);
  }
}

function clangExport(tc, buildDir, targets, profdataPath, reportDir) {
  const allBins = [];
  for (const t of targets) {
    const bin = binaryPath(buildDir, t);
    if (fs.existsSync(bin)) {
      allBins.push(bin);
    } else {
      warn(`Binary not found for target ${t}: ${bin} — skipping`);
    }
  }
  if (allBins.length === 0) {
    throw new Error('No test binaries found to export coverage from.');
  }
  const covJson = exportCoverage(tc.llvmCov, allBins, profdataPath);
  return summarizeCoverage(covJson, reportDir);
}

function runClangCoverage(
  cmakeBin,
  tc,
  buildDir,
  targets,
  reportDir,
  noBuild,
  noRun,
  allTargets,
) {
  progress(`Toolchain: clang source-based (line + branch)`);
  progress(`  clang       : ${tc.clang}`);
  progress(`  llvm-cov    : ${tc.llvmCov}`);
  progress(`  llvm-profdata: ${tc.llvmProfdata}`);
  if (!noBuild) {
    configureBuild(cmakeBin, buildDir, '', true);
  }
  if (isWindows()) {
    alignLlvmTools(tc, buildDir);
  }
  targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
  const profdataPath = path.join(buildDir, 'coverage.profdata');
  clangRunAndCollect(tc, buildDir, targets, noRun, profdataPath);
  return clangExport(tc, buildDir, targets, profdataPath, reportDir);
}

// B4: merge multiple OpenCppCoverage Cobertura XMLs (one per test target)
// into a single per-source row set. OpenCppCoverage does NOT aggregate
// cobertura exports across runs into the same file — its documented
// aggregation path is --input_coverage with --export_type binary, which
// would require binary export + chaining. Instead each target writes its
// own coverage-<target>.xml and this function unions them. Line coverage
// is unioned (a line is covered if covered in any run); branch counts use
// the max of per-line condition-coverage (c/t) because Cobertura's
// line-level string carries no branch ids for a true set union.
function parseCoberturaLines(linesBlock, lineMap) {
  const lineRe = /<line\b[^>]*\/>/gi;
  let lm;
  while ((lm = lineRe.exec(linesBlock)) !== null) {
    const lt = lm[0];
    const num = parseInt(getAttr(lt, 'number') || '0', 10);
    if (!num) {
      continue;
    }
    const hits = parseInt(getAttr(lt, 'hits') || '0', 10);
    const isBranch = getAttr(lt, 'branch') === 'true';
    let ccCovered = 0;
    let ccTotal = 0;
    if (isBranch) {
      const cc = getAttr(lt, 'condition-coverage') || '';
      const ccMatch = cc.match(/(\d+)\s*\/\s*(\d+)/);
      if (ccMatch) {
        ccCovered = parseInt(ccMatch[1], 10);
        ccTotal = parseInt(ccMatch[2], 10);
      } else {
        ccTotal = 2;
      }
    }
    let e = lineMap.get(num);
    if (!e) {
      e = {hits: 0, branch: false, ccCovered: 0, ccTotal: 0};
      lineMap.set(num, e);
    }
    if (hits > e.hits) {
      e.hits = hits;
    }
    if (isBranch) {
      e.branch = true;
      if (ccCovered > e.ccCovered) {
        e.ccCovered = ccCovered;
      }
      if (ccTotal > e.ccTotal) {
        e.ccTotal = ccTotal;
      }
    }
  }
}

function aggregateCoberturaRows(byFile, reportDir) {
  const rows = [];
  for (const [filename, lineMap] of byFile) {
    const rel = relativize(filename);
    if (!isRnohSource(rel)) {
      continue;
    }
    if (!isUnderReportDir(rel, reportDir)) {
      continue;
    }
    let lineCount = 0;
    let lineCovered = 0;
    let branchCount = 0;
    let branchCovered = 0;
    for (const e of lineMap.values()) {
      lineCount++;
      if (e.hits > 0) {
        lineCovered++;
      }
      if (e.branch) {
        branchCount += e.ccTotal || 2;
        branchCovered += e.ccCovered;
      }
    }
    const linePercent = lineCount ? (lineCovered / lineCount) * 100 : 0;
    const branchPercent = branchCount
      ? (branchCovered / branchCount) * 100
      : lineCovered > 0
        ? 100
        : 0;
    rows.push({
      source: rel,
      lineCount,
      lineCovered,
      linePercent,
      branchCount,
      branchCovered,
      branchPercent,
      coLocatedTest: coLocatedTestFile(rel),
    });
  }
  return rows.sort((a, b) => a.source.localeCompare(b.source));
}

function mergeCoberturaXML(xmls, reportDir) {
  const byFile = new Map();
  const classRe = /<class\b[^>]*\bfilename="([^"]+)"[^>]*>/gi;
  for (const xml of xmls) {
    let m;
    classRe.lastIndex = 0;
    while ((m = classRe.exec(xml)) !== null) {
      const filename = m[1];
      const linesBlock = extractBlock(xml, m.index, 'lines');
      if (!linesBlock) {
        continue;
      }
      let lineMap = byFile.get(filename);
      if (!lineMap) {
        lineMap = new Map();
        byFile.set(filename, lineMap);
      }
      parseCoberturaLines(linesBlock, lineMap);
    }
  }
  return aggregateCoberturaRows(byFile, reportDir);
}

function cleanStaleCoverageXml(buildDir) {
  if (!fs.existsSync(buildDir)) {
    return;
  }
  for (const f of fs.readdirSync(buildDir)) {
    if (/^coverage.*\.xml$/i.test(f)) {
      safeUnlink(path.join(buildDir, f));
    }
  }
}

function msvcRunAndCollect(tc, buildDir, targets, reportDir, noRun) {
  if (!noRun) {
    cleanStaleCoverageXml(buildDir);
    const xmlFiles = [];
    let ran = false;
    for (const t of targets) {
      const bin = binaryPath(buildDir, t);
      if (!fs.existsSync(bin)) {
        warn(`Binary not found for target ${t}: ${bin} — skipping`);
        continue;
      }
      const tx = path.join(buildDir, `coverage-${t}.xml`);
      runOpenCppCoverage(tc.openCpp, bin, buildDir, reportDir, tx);
      xmlFiles.push(tx);
      ran = true;
    }
    if (!ran) {
      throw new Error('No test binary found to run under OpenCppCoverage.');
    }
    return mergeCoberturaXML(
      xmlFiles.map(p => fs.readFileSync(p, 'utf8')),
      reportDir,
    );
  }
  const perTarget = fs.existsSync(buildDir)
    ? fs
        .readdirSync(buildDir)
        .filter(f => /^coverage-.*\.xml$/i.test(f))
        .map(f => path.join(buildDir, f))
        .sort()
    : [];
  if (perTarget.length > 0) {
    return mergeCoberturaXML(
      perTarget.map(p => fs.readFileSync(p, 'utf8')),
      reportDir,
    );
  }
  const xmlPath = path.join(buildDir, 'coverage.xml');
  if (!fs.existsSync(xmlPath)) {
    throw new Error(`--no-run but no coverage XML at ${xmlPath}`);
  }
  return parseCoberturaXML(fs.readFileSync(xmlPath, 'utf8'), reportDir);
}

function runMsvcCoverage(
  cmakeBin,
  tc,
  buildDir,
  targets,
  reportDir,
  noBuild,
  noRun,
  allTargets,
) {
  progress(
    `Toolchain: MSVC + OpenCppCoverage (LINE only — branch unavailable on MSVC)`,
  );
  progress(`  OpenCppCoverage: ${tc.openCpp}`);
  warn(
    'Branch coverage is unavailable on the MSVC+OpenCppCoverage path. ' +
      'Install LLVM (clang/clang-cl) for full line+branch coverage.',
  );
  if (!noBuild) {
    configureBuild(cmakeBin, buildDir, '', false);
  }
  targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
  return msvcRunAndCollect(tc, buildDir, targets, reportDir, noRun);
}

// B3: remove stale .gcda (runtime coverage data) so the current run reflects
// ONLY this execution. .gcno (compile-time) is left in place. Recursive
// because CMake scatters .gcda under CMakeFiles/<target>.dir/... subdirs.
function cleanStaleGcda(buildDir) {
  if (!fs.existsSync(buildDir)) {
    return;
  }
  const stack = [buildDir];
  while (stack.length) {
    const dir = stack.pop();
    let entries;
    try {
      entries = fs.readdirSync(dir, {withFileTypes: true});
    } catch {
      continue;
    }
    for (const ent of entries) {
      const p = path.join(dir, ent.name);
      if (ent.isDirectory()) {
        stack.push(p);
      } else if (ent.name.endsWith('.gcda')) {
        safeUnlink(p);
      }
    }
  }
}

// B3: parse a lcov .info tracefile into per-source rows. Per source file lcov
// emits: SF:<path>; DA:<line>,<hits> per line; BRDA:<line>,<block>,<branch>,
// <taken> per branch (taken = count, or '-' if never reached); LF/LH/BRF/BRH
// summaries. Counts are recomputed from DA/BRDA (summaries can be stale on
// old lcov). A line is covered if hits > 0; a branch is covered if taken >= 1
// (taken '-' or 0 → not covered). 0/0 branches → 100% when lines were
// covered (same convention as the clang path).
function handleLcovLine(line, byFile, cur) {
  if (line.startsWith('SF:')) {
    const rel = relativize(line.slice(3));
    const existing = byFile.get(rel);
    if (existing) {
      return existing;
    }
    const entry = {rel, lines: new Map(), branches: []};
    byFile.set(rel, entry);
    return entry;
  }
  if (!cur) {
    return cur;
  }
  if (line.startsWith('DA:')) {
    const parts = line.slice(3).split(',');
    const num = parseInt(parts[0], 10);
    const hits = parseInt(parts[1] || '0', 10);
    if (num) {
      cur.lines.set(num, (cur.lines.get(num) || 0) + hits);
    }
  } else if (line.startsWith('BRDA:')) {
    const parts = line.slice(5).split(',');
    const num = parseInt(parts[0], 10);
    const taken = parts[3];
    if (num) {
      cur.branches.push({line: num, taken});
    }
  } else if (line.startsWith('end_of_record')) {
    return null;
  }
  return cur;
}

function aggregateLcovRow(rel, entry, reportDir) {
  if (!isRnohSource(rel)) {
    return null;
  }
  if (!isUnderReportDir(rel, reportDir)) {
    return null;
  }
  let lineCount = 0;
  let lineCovered = 0;
  let branchCount = 0;
  let branchCovered = 0;
  for (const hits of entry.lines.values()) {
    lineCount++;
    if (hits > 0) {
      lineCovered++;
    }
  }
  for (const b of entry.branches) {
    branchCount++;
    if (b.taken !== '-' && parseInt(b.taken, 10) >= 1) {
      branchCovered++;
    }
  }
  const linePercent = lineCount ? (lineCovered / lineCount) * 100 : 0;
  const branchPercent = branchCount
    ? (branchCovered / branchCount) * 100
    : lineCovered > 0
      ? 100
      : 0;
  return {
    source: rel,
    lineCount,
    lineCovered,
    linePercent,
    branchCount,
    branchCovered,
    branchPercent,
    coLocatedTest: coLocatedTestFile(rel),
  };
}

function parseLcovInfo(info, reportDir) {
  const byFile = new Map();
  let cur = null;
  for (const line of info.split(/\r?\n/)) {
    cur = handleLcovLine(line, byFile, cur);
  }
  const rows = [];
  for (const [rel, e] of byFile) {
    const row = aggregateLcovRow(rel, e, reportDir);
    if (row) {
      rows.push(row);
    }
  }
  return rows.sort((a, b) => a.source.localeCompare(b.source));
}

// B3: GCC gcov + lcov path (Linux, parallel to the clang/MSVC paths).
// CMakeLists.txt already adds --coverage -fprofile-arcs -ftest-coverage for
// GCC when RNOH_COVERAGE=ON, so configureBuild(useCoverageFlags=true) is
// reused. Each test binary emits .gcda at runtime; lcov --capture (with
// --rc branch_coverage=1) rolls them into one .info, parsed by parseLcovInfo.
function gccRunAndCapture(tc, buildDir, targets, noRun, infoPath) {
  if (!noRun) {
    cleanStaleGcda(buildDir);
    let ran = false;
    for (const t of targets) {
      const bin = binaryPath(buildDir, t);
      if (!fs.existsSync(bin)) {
        warn(`Binary not found for target ${t}: ${bin} — skipping`);
        continue;
      }
      progress(`▸ Running ${path.basename(bin)}`);
      const res = run(bin, [], {encoding: 'utf8', stdio: 'inherit'});
      if (res.status !== 0) {
        warn(
          `⚠ ${path.basename(bin)} exited ${res.status} — collecting partial coverage`,
        );
      }
      ran = true;
    }
    if (!ran) {
      throw new Error('No test binary found to run for GCC coverage.');
    }
    progress(
      `▸ Capturing coverage (lcov --capture) → ${path.basename(infoPath)}`,
    );
    const lcovRes = run(
      tc.lcov,
      [
        '--capture',
        '--directory',
        buildDir,
        '--rc',
        'branch_coverage=1',
        '--output-file',
        infoPath,
      ],
      {stdio: 'inherit'},
    );
    if (lcovRes.status !== 0) {
      throw new Error(
        `lcov --capture exited ${lcovRes.status}\n${lcovRes.stderr || lcovRes.stdout}`,
      );
    }
    if (!fs.existsSync(infoPath)) {
      throw new Error(`lcov did not produce ${infoPath}`);
    }
  } else if (!fs.existsSync(infoPath)) {
    throw new Error(`--no-run but no coverage.info at ${infoPath}`);
  }
}

function runGccCoverage(
  cmakeBin,
  tc,
  buildDir,
  targets,
  reportDir,
  noBuild,
  noRun,
  allTargets,
) {
  progress('Toolchain: GCC gcov + lcov (line + branch)');
  progress(`  gcov: ${tc.gcov}`);
  progress(`  lcov: ${tc.lcov}`);
  if (!noBuild) {
    configureBuild(cmakeBin, buildDir, '', true);
  }
  targets = discoverAndBuild(cmakeBin, buildDir, targets, allTargets, noBuild);
  const infoPath = path.join(buildDir, 'coverage.info');
  gccRunAndCapture(tc, buildDir, targets, noRun, infoPath);
  const info = fs.readFileSync(infoPath, 'utf8');
  if (!/^\s*BRDA:/m.test(info)) {
    warn(
      'lcov produced no branch (BRDA) records — branch coverage shown as 0/0. ' +
        'Ensure lcov >= 1.10 and that --rc branch_coverage=1 is supported.',
    );
  }
  return parseLcovInfo(info, reportDir);
}

function printToolchainMissingError(tc) {
  console.error('No supported coverage toolchain found.');
  if (isWindows()) {
    console.error('');
    console.error('Option A (recommended — full line + branch coverage):');
    console.error('  Install LLVM: winget install LLVM.LLVM');
    console.error(
      "  (or VS Installer → 'C++ Clang Compiler for Windows' + 'C++ Clang-cl'",
    );
    console.error('   then build with clang-cl via cmake -T ClangCL)');
    console.error('');
    console.error('Option B (line coverage only):');
    console.error('  Install OpenCppCoverage: choco install opencppcoverage');
    console.error('  (branch coverage not supported on the MSVC path)');
  } else if (isDarwin()) {
    console.error(
      'clang source-based coverage requires llvm-cov + llvm-profdata.',
    );
    console.error(
      'Install Xcode Command Line Tools, or run via `xcrun` (script does this).',
    );
  } else if (tc.gcov && !tc.lcov) {
    console.error(
      'gcov found but lcov is missing — lcov is required for the GCC path.',
    );
    console.error('  apt-get install lcov');
  } else {
    console.error(
      'Install clang + llvm tools, or gcov + lcov for the GCC path.',
    );
    console.error('  apt-get install clang llvm lcov');
  }
}