// @ts-check

const {
  reformatJitdumps,
  emitJitdumpLogs,
  filterJitdumpAgentsForPid,
} = require('./jitdump.js');
const {
  getPerfSetting,
  isPerfCapable,
  resolveLoginName,
  isElevatePrivilegeError,
  posixTestWorkload,
} = require('./utils.js');

let bundleVersion = '1.9.8';
let rc = 'build-1';
let readinessMessageCode =
  'engine.recipeparser.js_recipe_stage.READINESS_MESSAGE';

let jitdumpJvmVersion = '0.9.0';

let dotnetAgentVersion = '0.7.0';

/**
 * Resolve deployment paths on the target.
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {{deployPath: string, jitdumpJvmDeployPath: string, dotnetAgentDeployPath: string}}
 */
function getNeoprofPaths(ctx) {
  const toolsRoot = ctx.toolsRoot;
  const paths = {
    deployPath: `${toolsRoot}/sl-collect/${bundleVersion}-${rc}/bin/`,
    jitdumpJvmDeployPath: `${toolsRoot}/jitdump-jvm/${jitdumpJvmVersion}/`,
    dotnetAgentDeployPath: `${toolsRoot}/dotnet-agent/${dotnetAgentVersion}/`,
  };

  return paths;
}

/**
 * @type {import("../recipes/docs/jsdocs").ToolIntegration}
 */
let tool = {
  name: 'neoprof',
  version: '1.1.0',
  supportsWorkloadLaunch: true,
  deployments: [
    // Tool bundle: sl-collect
    // Only available on Linux aarch64 and x86_64
    {
      appliesTo: [
        { architecture: 'aarch64', os: 'Linux' },
        { architecture: 'x86_64', os: 'Linux' },
      ],
      dependencies: [
        {
          type: 'tool_bundle',
          name: 'sl-collect',
          version: `${bundleVersion}-${rc}`,
          requiredWhen: { type: 'always' },
        },
      ],
    },
    // Tool bundle: jitdump-jvm
    // Only available on Linux aarch64
    // Will be ignored on other platforms
    {
      appliesTo: [
        { architecture: 'aarch64', os: 'Linux' },
        { architecture: 'x86_64', os: 'Linux' },
      ],
      dependencies: [
        {
          type: 'tool_bundle',
          name: 'jitdump-jvm',
          version: jitdumpJvmVersion,
          requiredWhen: {
            type: 'param_is_set',
            parameters: [{ collect_java_stacks: true }],
          },
        },
      ],
    },
    // Tool bundle: dotnet-agent
    // Available on Linux aarch64 and x86_64
    {
      appliesTo: [
        { architecture: 'aarch64', os: 'Linux' },
        { architecture: 'x86_64', os: 'Linux' },
      ],
      dependencies: [
        {
          type: 'tool_bundle',
          name: 'dotnet-agent',
          version: dotnetAgentVersion,
          requiredWhen: {
            type: 'param_is_set',
            parameters: [{ collect_dotnet_stacks: true }],
          },
        },
      ],
    },
  ],
  migrations: [
    // v1.1.0: Tool‐name rename from "streamline-cli" to "neoprof"
    {
      type: 'renameTool',
      from: 'streamline-cli',
      to: 'neoprof',
      version: '1.1.0',
    },
  ],
  description: {
    short:
      'Neoprof tool for performance analysis and profiling of applications.',
    long: 'The Neoprof tool is a command-line utility for performance analysis and profiling of applications. It provides insights into application behavior, resource utilization, and performance bottlenecks by collecting and analyzing various performance metrics.',
  },
  parameters: [
    {
      id: 'mode',
      label: 'Profiling mode',
      description:
        'Select the profiling mode used when invoking `sl-record`. Choose `samples` to collect CPU samples, `metrics` to gather counter metrics, or `spe` for Arm Statistical Profiling Extension data.',
      config: {
        type: 'radio',
        defaultValue: 'samples',
        options: ['samples', 'spe', 'metrics'],
      },
    },
    {
      id: 'metrics_group',
      label: 'Metrics group',
      description:
        'Select the metrics groups to collect when running in metrics mode.',
      config: {
        type: 'input',
      },
    },
    {
      id: 'sampling_frequency',
      label: 'Sampling Frequency',
      description:
        "Select the sampling frequency for the CPU microarchitecture analysis. The 'normal' frequency is suitable for most workloads, while 'high' provides more detailed information at the cost of increased overhead.",
      config: {
        type: 'radio',
        options: ['low', 'normal', 'high'],
        defaultValue: 'normal',
      },
    },
    {
      id: 'metric_mode',
      label: 'Metric mode',
      description: 'Select the metric mode to use for metrics captures.',
      config: {
        type: 'input',
      },
    },
    {
      id: 'spe_workflow',
      label: 'SPE workflow',
      description:
        'Provide the SPE workflow string used when running in `spe` mode.',
      config: {
        type: 'input',
      },
    },
    {
      id: 'spe_sample_rate',
      label: 'SPE sample rate',
      description:
        'Specify the SPE periodic sampling rate (operations between each sample). Must be a non-zero positive integer.',
      config: {
        type: 'input',
      },
    },
    {
      id: 'collect_java_stacks',
      label: 'Collect Java stacks',
      description:
        'Enable collection of Java stack traces when profiling JVM workloads.',
      config: {
        type: 'checkbox',
        defaultValue: false,
      },
    },
    {
      id: 'collect_dotnet_stacks',
      label: 'Collect .NET stacks',
      description:
        'Enable collection of .NET stack traces when profiling .NET workloads.',
      config: {
        type: 'checkbox',
        defaultValue: false,
      },
    },
    {
      id: 'get_ipc_metric_name',
      label: 'IPC metric name',
      description: 'The name of the IPC metric.',
      config: {
        type: 'checkbox',
        defaultValue: false,
      },
    },
  ],

  probe: async (engine, ctx) => {
    // Get neoprof privilege requirement
    let neoprofAsRoot = await isPrivilegeRequired(engine, ctx);
    engine.log('info', `Neoprof privilege requirement: ${neoprofAsRoot}`);
    ctx.metadata.neoprofAsRoot = neoprofAsRoot;

    // Generate the initial probe report using sl-record
    let probeResponse = null;
    try {
      const slRecordProbe = await probeSlRecord(engine, ctx);
      probeResponse = JSON.parse(slRecordProbe.stdout);
    } catch (err) {
      if (isElevatePrivilegeError(err)) {
        throw {
          code: 'tool_integrations.common.INSUFFICIENT_PRIVILEGES',
          metadata: { tool: tool.name },
        };
      }
      throw err;
    }

    // Probe the IPC metric name
    let ipcMetricProbe = await probeIpcMetric(engine, ctx);
    if (ipcMetricProbe.level !== 'ready') {
      probeResponse.advice.push({
        message: ipcMetricProbe.message,
        severity: ipcMetricProbe.level,
      });
    }

    // Probe sl-analyze
    let slAnalyzeProbe = await probeSlAnalyze(engine, ctx);
    if (slAnalyzeProbe.level !== 'ready') {
      probeResponse.advice.push({
        message: slAnalyzeProbe.message,
        severity: slAnalyzeProbe.level,
      });
    }

    // Probe jitdump-jvm
    if (ctx.params['collect_java_stacks']) {
      let jitdumpJvmProbe = await probejitdumpJvm(engine, ctx);
      if (jitdumpJvmProbe.level !== 'ready') {
        probeResponse.advice.push({
          message: jitdumpJvmProbe.message,
          severity: jitdumpJvmProbe.level,
        });
      }
    }

    if (ctx.params['collect_dotnet_stacks']) {
      let dotnetAgentProbe = await probeDotnetAgent(engine, ctx);
      if (dotnetAgentProbe.level !== 'ready') {
        probeResponse.advice.push({
          message: dotnetAgentProbe.message,
          severity: dotnetAgentProbe.level,
        });
      }
    }

    return {
      available: true,
      capabilities: {
        supports_strobing: probeResponse.supports_strobing,
        supports_event_inherit: probeResponse.supports_event_inherit,
      },
      advice: probeResponse.advice.map((a) => {
        return {
          level: a.severity,
          messageCode: readinessMessageCode,
          metadata: { message: a.message },
        };
      }),
    };
  },

  run: async (engine, ctx) => {
    const paths = getNeoprofPaths(ctx);
    const jitdumpJvmDeployPath = paths.jitdumpJvmDeployPath;
    const dotnetAgentDeployPath = paths.dotnetAgentDeployPath;
    // Probe neoprof privilege requirement
    let neoprofAsRoot = await isPrivilegeRequired(engine, ctx);
    engine.log('info', `Neoprof privilege requirement: ${neoprofAsRoot}`);
    ctx.metadata.neoprofAsRoot = neoprofAsRoot;

    // This is a workaround for the fact that we cannot control where the sl-record log file is written to.
    // The file is always written to the bin directory. If we could choose to write it to the run working
    // directory there wouldn't be a problem.
    // We create the file as a non-root user before running sl-record. If we didn't do this and neoprofAsRoot is true
    // then the file would get created with the owner as root. Then a subsequent run with asRoot false would not have
    // permission to write the file.
    await engine.execCommand(
      ['bash', '-c', `touch ${paths.deployPath}/gator-log.txt`],
      { asRoot: false },
    );

    let outputDirectory = await engine.createTempDir();
    ctx.metadata.outputDirectory = outputDirectory;
    let captureDirectory = outputDirectory + '/capture.apc';
    ctx.metadata.captureDirectory = captureDirectory;

    let slRecordPath = paths.deployPath + 'sl-record';
    let ipcMetricName = null;

    if (ctx.params['get_ipc_metric_name']) {
      let ipcCommand = `${slRecordPath} --print counters`;
      let ipcResult = await engine.execCommand(
        [slRecordPath, '--print', 'counters'],
        { asRoot: ctx.metadata.neoprofAsRoot },
      );
      ipcMetricName =
        ipcResult.stdout.match(/\b\w*metric_ipc(?:_\w+)*\b/)?.[0] || null;

      if (!ipcMetricName) {
        engine.log(
          'warn',
          `Could not determine IPC metric name: rc=${ipcResult.rc}, stdout=${ipcResult.stdout.trim()}, stderr=${ipcResult.stderr.trim()}`,
        );
      }
    }

    let processArgs = [
      slRecordPath,
      '-o',
      ctx.metadata.captureDirectory,
      '-t',
      ctx.timeout.toString(),
      ...(ipcMetricName ? ['-C', ipcMetricName] : []),
      '--capture-log',
    ];

    let recordArgs = buildRecordArgs(ctx);
    processArgs.push(...recordArgs);
    // Set the workload last so that workload command arguments are correctly passed down
    processArgs.push(...workloadToSLArgs(ctx.workload));
    engine.log(
      'info',
      `Launch env vars: ${JSON.stringify(ctx.workload.environment)}`,
    );

    emitCommonFiles(engine, captureDirectory);
    emitDisassemblyFiles(engine, captureDirectory);

    let mode = ctx.params['mode'];
    if (mode === 'samples') {
      emitHotspotFiles(engine, captureDirectory);
    } else if (mode === 'spe') {
      emitSPEFiles(engine, captureDirectory);
    } else if (mode === 'metrics') {
      emitMetricsFiles(engine, captureDirectory);
    } else {
      throw {
        code: 'tool_integrations.neoprof.INVALID_MODE_PARAM',
        metadata: { value: mode },
      };
    }

    if (engine.isFullCaptureSupportEnabled()) {
      emitCaptureDir(engine, captureDirectory);
    }

    // Agent support (.NET + JVM)
    //
    // Both agents generate jitdump files as the target process user (not necessarily root),
    // so the output directories must be writable by the session owner.

    let collectJavaStacks = ctx.params['collect_java_stacks'];
    let collectDotnetStacks = ctx.params['collect_dotnet_stacks'];

    if (ctx.workload.type === 'attach' && ctx.workload.pid != null) {
      const pid = ctx.workload.pid;
      const filterResult = await filterJitdumpAgentsForPid(
        engine,
        pid,
        ctx.metadata.neoprofAsRoot,
      );

      if (collectJavaStacks && !filterResult.isJvmPid) {
        engine.log(
          'warn',
          `Attach pid ${pid} does not appear to be a JVM process; skipping jitdump-jvm.`,
        );
      }
      if (collectDotnetStacks && !filterResult.isDotnetPid) {
        engine.log(
          'warn',
          `Attach pid ${pid} does not appear to be a .NET process; skipping dotnet-agent.`,
        );
      }

      collectJavaStacks = collectJavaStacks && filterResult.isJvmPid;
      collectDotnetStacks = collectDotnetStacks && filterResult.isDotnetPid;
    }

    const requireJitdumps = collectJavaStacks || collectDotnetStacks;

    /** @type {string | null} */
    let currUser = null;

    // Default .NET jitdump dir (used for `-o`). Some runtimes may emit jitdumps elsewhere
    // (e.g. via DOTNET_PerfMapJitDumpPath); staging will consolidate from all reported directories.
    let dotnetJitdumpDir = `${ctx.metadata.outputDirectory}/dotnet-jitdumps`;
    const dotnetActionsFile = `${ctx.metadata.outputDirectory}/dotnet-user-actions`;
    const jvmActionsFile = `${ctx.metadata.outputDirectory}/jvm-user-actions`;
    const jvmJitdumpDir = `${ctx.metadata.outputDirectory}/jvm-jitdumps`;

    if (requireJitdumps) {
      const results = await Promise.allSettled([
        resolveSessionOwner(engine),
        engine.makeWritable(outputDirectory, true),
      ]);

      if (results[0].status === 'fulfilled') {
        currUser = results[0].value;
        engine.log('info', `Current user (session owner) is ${currUser}`);
      } else {
        // If we can't determine the session owner, let later agent setup fail with a clearer error.
        engine.log(
          'warn',
          `Failed to determine session owner for agent output permissions: ${String(
            results[0].reason,
          )}`,
        );
      }

      const setupTasks = [];
      const chownTasks = [];

      if (collectJavaStacks) {
        setupTasks.push(
          () => assertJitdumpJvm(engine, ctx),
          () => engine.mkDir(jvmJitdumpDir),
        );
        if (currUser) {
          const user = currUser;
          chownTasks.push(() => engine.chown(jvmJitdumpDir, user, true));
        }
      }

      if (collectDotnetStacks) {
        setupTasks.push(
          () => assertDotnetAgent(engine, ctx),
          () => engine.mkDir(dotnetJitdumpDir),
        );
        if (currUser) {
          const user = currUser;
          chownTasks.push(() => engine.chown(dotnetJitdumpDir, user, true));
        }
      }

      await Promise.all(setupTasks.map((t) => t()));
      await Promise.all(chownTasks.map((t) => t()));
    }

    // .NET support
    if (collectDotnetStacks) {
      ctx.metadata.dotnetAgentAvailable = true;
      ctx.metadata.dotnetJitdumpDir = dotnetJitdumpDir;
      ctx.metadata.dotnetActionsFile = dotnetActionsFile;

      let dotnetAgentProcessArgs = [
        `${dotnetAgentDeployPath}/jitdump-dotnet`,
        '-o',
        dotnetJitdumpDir,
        `--user-actions-file`,
        dotnetActionsFile,
      ];
      dotnetAgentProcessArgs.push(...workloadToAgentHelperArgs(ctx.workload));

      engine.log(
        'info',
        `Starting dotnet-agent with args: ${dotnetAgentProcessArgs.join(' ')}`,
      );

      // Start capturing on .NET processes
      let dotnetAgentProcHandle = await engine.startProcess(
        dotnetAgentProcessArgs,
        {
          asRoot: ctx.metadata.neoprofAsRoot,
          environment: { DOTNET_EnableDiagnostics: '0' },
          stdout: {
            redirect: 'file',
            path: `${ctx.metadata.outputDirectory}/jitdumpdotnet.log`,
          },
          stderr: {
            redirect: 'file',
            path: `${ctx.metadata.outputDirectory}/jitdumpdotnet_stderr.txt`,
          },
        },
      );
      ctx.metadata.dotnetAgentProcHandle = dotnetAgentProcHandle;
    }

    // Java support
    if (collectJavaStacks) {
      // Ensure jitdump-jvm has a writable output directory
      //
      // Context:
      // The jitdump-jvm injects libjitdump_jvm_agent.so into the running JVM process,
      // inheriting the JVM's effective user ID (EUID) and file permissions.
      // The library generates a jitdump file under that EUID, so we must ensure
      // the session owner (the user launching the JVM) owns a writable directory
      // to allow successful file creation.
      //
      ctx.metadata.jitdumpJvmAvailable = true;
      ctx.metadata.jvmJitdumpDir = jvmJitdumpDir;
      ctx.metadata.jvmActionsFile = jvmActionsFile;

      // jitdump-jvm output directory setup is already performed under the `requireJitdumps` block above.
      // We rely on `resolveSessionOwner()` + `engine.makeWritable()` early in the run to ensure the
      // session owner can write jitdump files into `jvmJitdumpDir`.

      let jitdumpJvmProcessArgs = [
        `${jitdumpJvmDeployPath}/jitdump-jvm`,
        '--agent-path',
        `${jitdumpJvmDeployPath}/libjitdump_jvm_agent.so`,
        '-o',
        jvmJitdumpDir,
        `--user-actions-file`,
        jvmActionsFile,
      ];
      jitdumpJvmProcessArgs.push(...workloadToAgentHelperArgs(ctx.workload));

      engine.log(
        'info',
        `Starting jitdump-jvm with args: ${jitdumpJvmProcessArgs.join(' ')}`,
      );

      // Start capturing on JVM processes
      // It's important that this is started BEFORE neoprof so that
      // any JVM processes is captured from the very beginning
      let jitdumpJvmProcHandle = await engine.startProcess(
        jitdumpJvmProcessArgs,
        {
          asRoot: ctx.metadata.neoprofAsRoot,
          stdout: {
            redirect: 'file',
            path: `${ctx.metadata.outputDirectory}/jitdumpjvm.log`,
          },
          stderr: {
            redirect: 'file',
            path: `${ctx.metadata.outputDirectory}/jitdumpjvm_stderr.txt`,
          },
        },
      );
      ctx.metadata.jitdumpJvmProcHandle = jitdumpJvmProcHandle;
    }

    emitJitdumpLogs(engine, ctx);

    let runSlRecordErr = false;
    try {
      await runSlRecord(engine, ctx, processArgs);
    } catch (err) {
      runSlRecordErr = true;
      throw err;
    } finally {
      await stopJitdumpAgents(engine, ctx, runSlRecordErr);
    }
  },

  reformat: async (engine, ctx) => {
    const paths = getNeoprofPaths(ctx);
    engine.startProgressTracker('Analyzing collection');

    ctx.metadata.jitdumpsAvailable =
      ctx.metadata.jitdumpJvmAvailable || ctx.metadata.dotnetAgentAvailable;

    // Reformat any jitdump files generated during capture.
    // Do this before starting sl-analyze so that jitdumps are correctly placed in the APC directory.
    await reformatJitdumps(engine, ctx);

    let slAnalyzePath = paths.deployPath + 'sl-analyze';
    let args = [
      slAnalyzePath,
      '-o',
      ctx.metadata.captureDirectory, // output csv files
      '--collect-images',
      '--all-images',
      '--apap-export',
      '--group-by',
      'none',
      '--include-empty-columns',
      '--annotate-source',
      '--disassemble',
    ];

    if (ctx.workload.type === 'attach') {
      args.push(
        '--pid',
        ctx.workload.pid.toString(),
        '--include-child-processes',
      );
    }

    // Tell sl-analyze to include the jitdumps when symbolising
    if (ctx.metadata.jitdumpsAvailable) {
      args.push('--all-jitdumps');
    }

    args.push(ctx.metadata.captureDirectory);

    engine.log('info', `Starting sl-analyze with args: ${args.join(' ')}`);

    let errFileHandle = await createRunFile(engine, 'analysis_stderr.txt', {
      name: 'log-text',
      version: '1.0',
    });
    let analyzeHandle = await engine.startProcess(args, {
      stdout: {
        redirect: 'file',
        path: `${ctx.metadata.outputDirectory}/analysis.log`,
      },
      stderr: { redirect: 'stream' },
      asRoot: ctx.metadata.neoprofAsRoot,
    });
    ctx.metadata.analyzeHandle = analyzeHandle;

    if (ctx.metadata.requestCancel) {
      ctx.metadata.analyzeHandle.kill();
    }

    await drainStreamToFileAndClose(
      errFileHandle,
      ctx.metadata.analyzeHandle.stderr,
    );
    let result = await ctx.metadata.analyzeHandle.wait();
    ctx.metadata.analyzeHandle = null;

    // Check analysis stderr for any error cases, even if sl-analyze succeeded, as some errors are reported there but don't cause a non-zero exit code
    await checkToolFailure(
      engine,
      ctx,
      errFileHandle.path,
      result.exitCode,
      'sl-analyze',
    );
    engine.endProgress('Analyzing collection');
  },

  onCancel: async (engine, ctx) => {
    ctx.metadata.requestCancel = true;
    if (ctx.metadata.recordHandle) {
      ctx.metadata.recordHandle.kill();
    }
    if (ctx.metadata.analyzeHandle) {
      ctx.metadata.analyzeHandle.kill();
    }
    if (ctx.metadata.jitdumpJvmProcHandle) {
      await ctx.metadata.jitdumpJvmProcHandle.kill();
    }
    if (ctx.metadata.dotnetAgentProcHandle) {
      await ctx.metadata.dotnetAgentProcHandle.kill();
    }
  },

  onStop: async (engine, ctx) => {
    ctx.metadata.requestStop = true;
    if (ctx.metadata.recordHandle && ctx.metadata.allowStop) {
      await ctx.metadata.recordHandle.interrupt();
    }
    if (ctx.metadata.procHandle) {
      await ctx.metadata.procHandle.interrupt();
    }
  },
};

/**
 * Checks that `sl-record` exists and runs on the target.
 * Invokes `sl-record --probe-report` to get probe advice.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<import("../recipes/docs/jsdocs").CommandResult>}
 */
async function probeSlRecord(engine, ctx) {
  const paths = getNeoprofPaths(ctx);
  let slRecordPath = paths.deployPath + 'sl-record';
  let args = [slRecordPath, '--probe-report', '-o', 'platform_probe'];

  let recordArgs = buildRecordArgs(ctx);
  args.push(...recordArgs);

  // Set the workload last so that workload command arguments are correctly passed down
  args.push(...workloadToSLArgs(ctx.workload));

  let commandResult = await engine.execCommand(args, {
    asRoot: ctx.metadata.neoprofAsRoot,
  });
  if (commandResult.rc !== 0) {
    throw {
      code: 'tool_integrations.neoprof.NEOPROF_FAILED',
      metadata: { tool: 'sl-record', code: commandResult.rc },
      cause: `failed to create probe report`,
    };
  }
  let probeResult = await engine.execCommand(
    ['cat', paths.deployPath + '/' + 'probe_report.json'],
    { asRoot: ctx.metadata.neoprofAsRoot },
  );
  if (probeResult.rc !== 0) {
    throw {
      code: 'tool_integrations.neoprof.NEOPROF_FAILED',
      metadata: { tool: 'sl-record', code: probeResult.rc },
      cause: `failed to fetch probe report`,
    };
  }

  return probeResult;
}

/**
 * Checks that IPC metric name can be gathered from the target.
 * This metric gets passed to sl-record later during the run.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function probeIpcMetric(engine, ctx) {
  const paths = getNeoprofPaths(ctx);
  let slRecordPath = paths.deployPath + 'sl-record';
  let ipcMetricName = null;

  if (ctx.params['get_ipc_metric_name']) {
    let args = [slRecordPath, '--print', 'counters'];

    let ipcResult = await engine.execCommand(args, {
      asRoot: ctx.metadata.neoprofAsRoot,
    });
    ipcMetricName =
      ipcResult.stdout.match(/\b\w*metric_ipc(?:_\w+)*\b/)?.[0] || null;

    if (!ipcMetricName) {
      engine.log(
        'warn',
        `Could not determine IPC metric name: rc=${ipcResult.rc}, stdout=${ipcResult.stdout.trim()}, stderr=${ipcResult.stderr.trim()}`,
      );
      return {
        level: 'warning',
        message: `The name of the IPC metric for the target could not be determined. This means that the IPC metric will not be included in the profiling data collected.`,
      };
    }
  }

  return {
    level: 'ready',
    message: '',
  };
}

/**
 * Checks that `sl-analyze` exists and runs on the target.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function probeSlAnalyze(engine, ctx) {
  const paths = getNeoprofPaths(ctx);
  let slAnalyzePath = paths.deployPath + 'sl-analyze';
  let args = [slAnalyzePath, '--help'];

  let slAnalyzeCheck = await engine.execCommand(args, {});
  const glibcVersionNotFoundStr = /version `GLIBC_(\d+\.\d+)' not found/;
  let glibcMatch = slAnalyzeCheck.stderr.match(glibcVersionNotFoundStr);
  if (slAnalyzeCheck.rc !== 0 && glibcMatch) {
    engine.log(
      'error',
      `Incompatible version of glibc on target (${glibcMatch[1]}).`,
    );
    return {
      level: 'error',
      message: `The target is using a version of the GNU C Library which is incompatible with the '${tool.name}' tool. Upgrade the GNU C Library on the target machine to at least version GLIBC_${glibcMatch[1]}, or use a different target machine with a newer operating system.`,
    };
  } else if (slAnalyzeCheck.rc !== 0) {
    // For all other errors, return a generic failure message
    throw {
      code: 'tool_integrations.neoprof.NEOPROF_FAILED',
      metadata: { tool: 'sl-analyze', code: slAnalyzeCheck.rc },
    };
  }

  return {
    level: 'ready',
    message: '',
  };
}

/**
 *  Runs sl-record with the given arguments.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string[]} processArgs
 * @returns {Promise<void>}
 */
async function runSlRecord(engine, ctx, processArgs) {
  engine.log('info', `Starting sl-record with args: ${processArgs.join(' ')}`);

  engine.startProgressTracker('Collecting data');

  let errFilehandle = await createRunFile(engine, 'capture_log_err.txt', {
    name: 'log-text',
    version: '1.0',
  });
  /** @type {import("../recipes/docs/jsdocs").ProcessOptions} */
  let recordProcessOptions = {
    stdout: {
      redirect: 'file',
      path: `${ctx.metadata.outputDirectory}/capture_log.txt`,
    },
    stderr: { redirect: 'stream' },
    asRoot: ctx.metadata.neoprofAsRoot,
    environment: {
      ...(ctx.env || {}), // General tool environment
      // Workload-specified environment - for sl-record, these are just provided to the tool in the same way as the
      // env vars above, as there's currently no way to provide env vars specifically to the workload
      ...(ctx.workload.environment || {}),
    },
  };

  // Set environment variables for .NET stack collection
  if (ctx.params['collect_dotnet_stacks']) {
    recordProcessOptions.environment = {
      ...(recordProcessOptions.environment || {}),
      DOTNET_EnableWriteXorExecute: '0',
      DOTNET_JitFramed: '1',
    };
  }

  let recordHandle = await engine.startProcess(
    processArgs,
    recordProcessOptions,
  );
  ctx.metadata.recordHandle = recordHandle;

  if (ctx.metadata.requestCancel) {
    ctx.metadata.recordHandle.kill();
  }

  // Wait for a minimum period before allowing the capture to be stopped.
  // If sl-record receives SIGINT immediately it fails to produce valid output and sl-analyze will fail.
  // This is a hacky workaround. There's no guarantee that sl-record will
  // have been running for sufficient time when this timeout expires.
  setTimeout(() => {
    ctx.metadata.allowStop = true;
    if (ctx.metadata.requestStop && ctx.metadata.recordHandle) {
      ctx.metadata.recordHandle.interrupt();
    }
  }, 2000);

  await drainStreamToFileAndClose(
    errFilehandle,
    ctx.metadata.recordHandle.stderr,
  );
  let result = await ctx.metadata.recordHandle.wait();
  ctx.metadata.recordHandle = null;

  await checkToolFailure(
    engine,
    ctx,
    errFilehandle.path,
    result.exitCode,
    'sl-record',
  );
  engine.endProgress('Collecting data');
}

/**
 * Stops any running jitdump agents at the end of an sl-record run.
 * Errors encountered during stopping are not thrown if the sl-record run failed.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {boolean} runErr
 */
async function stopJitdumpAgents(engine, ctx, runErr) {
  // Attempt to stop both agents in parallel, even if one fails.
  const [jvmRes, dotnetRes] = await Promise.allSettled([
    stopJvmAgent(engine, ctx),
    stopDotnetAgent(engine, ctx),
  ]);

  const stopJvmAgentErr =
    jvmRes.status === 'rejected' ? jvmRes.reason : jvmRes.value;
  const stopDotnetAgentErr =
    dotnetRes.status === 'rejected' ? dotnetRes.reason : dotnetRes.value;

  if (!runErr) {
    if (stopJvmAgentErr) {
      throw stopJvmAgentErr;
    }
    if (stopDotnetAgentErr) {
      throw stopDotnetAgentErr;
    }
  }
}

/**
 * Converts workload configuration to streamline-cli arguments.
 * @param {import("../recipes/docs/jsdocs").WorkloadLaunch |
 * import("../recipes/docs/jsdocs").WorkloadAttach |
 * import("../recipes/docs/jsdocs").WorkloadSystemWide} workload
 * @returns {string[]}
 */
function workloadToSLArgs(workload) {
  // Set the workload last so that workload command arguments are correctly passed down
  if (workload.type === 'launch') {
    let command = workload.command ?? '';
    let trimmed = command.trim();
    let args = trimmed.length === 0 ? [] : trimmed.split(/\s+/);
    return ['-A', ...args];
  } else if (workload.type === 'attach') {
    // sl-record uses system-wide capture during attach mode, to avoid file descriptor issues,
    // so we need to enable system-wide here as well. Filtering to just the target PID is done during
    // reformat by sl-analyze.
    return ['-S', 'yes'];
  } else if (workload.type === 'systemWide') {
    return ['-S', 'yes'];
  }
  // This throw is fine as it will be scooped up into a SCRIPTED_STAGE_ERROR later (and there's no advice here anyway)
  throw new Error(
    `Invalid workload type '${/** @type {any} */ (workload).type}'`,
  );
}

/**
 * Converts workload configuration to jitdump-jvm / dotnet-agent CLI arguments.
 * @param {import("../recipes/docs/jsdocs").WorkloadLaunch |
 * import("../recipes/docs/jsdocs").WorkloadAttach |
 * import("../recipes/docs/jsdocs").WorkloadSystemWide} workload
 * @returns {string[]}
 */
function workloadToAgentHelperArgs(workload) {
  if (workload.type === 'attach') {
    return ['--pid', workload.pid.toString()];
  } else if (workload.type === 'systemWide') {
    return ['--attach-all'];
  }

  return [];
}

/**
 * Builds the arguments for sl-record based on the given parameters.
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {string[]}
 */
function buildRecordArgs(ctx) {
  let args = [];

  const mode = ctx.params['mode'];
  const samplingFrequency = ctx.params['sampling_frequency'];
  const metricsGroup = ctx.params['metrics_group'];
  const metricMode = ctx.params['metric_mode'];
  const speWorkflow = ctx.params['spe_workflow'];
  const speSampleRate = ctx.params['spe_sample_rate'];

  if (mode === 'metrics') {
    if (!metricsGroup) {
      throw {
        code: 'tool_integrations.neoprof.MISSING_REQUIRED_PARAM',
        metadata: { param: 'metrics_group', mode: 'metrics' },
      };
    }
    if (!samplingFrequency) {
      throw {
        code: 'tool_integrations.neoprof.MISSING_REQUIRED_PARAM',
        metadata: { param: 'sampling_frequency', mode: 'metrics' },
      };
    }
    args.push('-M', metricsGroup);
    args.push('-r', samplingFrequency);
    if (metricMode) {
      args.push('--metric-mode', metricMode);
    }
  } else if (mode === 'samples') {
    if (!samplingFrequency) {
      throw {
        code: 'tool_integrations.neoprof.MISSING_REQUIRED_PARAM',
        metadata: { param: 'sampling_frequency', mode: 'samples' },
      };
    }
    args.push('-r', samplingFrequency);
  } else if (mode === 'spe') {
    const speSampleRateStr =
      speSampleRate === undefined || speSampleRate === null
        ? ''
        : speSampleRate.toString().trim();

    if (speSampleRateStr.length > 0) {
      const parsedSpeSampleRate = Number(speSampleRateStr);
      if (
        !Number.isInteger(parsedSpeSampleRate) ||
        parsedSpeSampleRate <= 0 ||
        parsedSpeSampleRate > 16777215
      ) {
        throw {
          code: 'tool_integrations.neoprof.INVALID_SPE_SAMPLE_RATE',
          metadata: { value: speSampleRate },
        };
      }
      args.push('-F', speSampleRateStr);
    }

    if (speWorkflow) {
      args.push('-X', speWorkflow);
    }
  } else {
    throw {
      code: 'tool_integrations.neoprof.INVALID_MODE_PARAM',
      metadata: { value: mode },
    };
  }

  return args;
}

/**
 * Scrapes the stderr output for known failure signatures and raises catalog errors.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string} hostPath
 * @param {number} exitCode
 * @returns {Promise<void>}
 */
async function scrapeStdErr(engine, ctx, hostPath, exitCode) {
  // ERR_MAP maps stderr patterns to catalog errors.
  // throwOnZeroExit: true => throw even when exitCode == 0 (defaults to false)
  const ERR_MAP = new Map([
    // m flag means ^ and $ match start and end of each line, rather than whole string
    [
      /^ERROR: The specified command does not exist\. Please verify this executable exists\.$/m,
      {
        msgCode: 'tool_integrations.common.WORKLOAD_NOT_EXIST',
        metadataProvider: (match) => ({
          workload: ctx.workload.command,
          executable: ctx.workload.command.split(' ')[0],
        }),
      },
    ],
    [
      /^ERROR: Failed to run command .*: Permission denied or is a directory$/m,
      {
        msgCode: 'tool_integrations.common.WORKLOAD_NOT_EXECUTABLE',
        metadataProvider: (match) => ({
          workload: ctx.workload.command,
          executable: ctx.workload.command.split(' ')[0],
        }),
      },
    ],
    [
      /^WARN: Nonexistent process, pid: .*\. Ensure process will exist on capture\.$/m,
      {
        msgCode: 'tool_integrations.neoprof.PID_NOT_EXIST',
        metadataProvider: (match) => ({
          pid: ctx.workload.pid.toString(),
        }),
      },
    ],
    [
      /^.*No file descriptors available$/m,
      {
        msgCode: 'tool_integrations.neoprof.FILE_DESCRIPTORS',
        metadataProvider: (match) => ({}),
      },
    ],
    [
      /No space left on device/m,
      {
        msgCode: 'tool_integrations.neoprof.INSUFFICIENT_DISK_SPACE',
        metadataProvider: (match) => ({
          outPath: ctx.metadata.outputDirectory,
        }),
      },
    ],
    [
      /^ERROR: Failed writing binary file\s+\S+/m,
      {
        msgCode: 'tool_integrations.neoprof.WRITE_FAILURE',
        metadataProvider: (match) => ({
          outPath: ctx.metadata.outputDirectory,
        }),
      },
    ],
    [
      /^ERROR: Unable to create directory\s+\S+/m,
      {
        msgCode: 'tool_integrations.neoprof.WRITE_FAILURE',
        metadataProvider: (match) => ({
          outPath: ctx.metadata.outputDirectory,
        }),
      },
    ],
    [
      /^ERROR: Error writing\s+\S+/m,
      {
        msgCode: 'tool_integrations.neoprof.WRITE_FAILURE',
        metadataProvider: (match) => ({
          outPath: ctx.metadata.outputDirectory,
        }),
      },
    ],
    // TODO: edit this catalog message to reference the user guide once more information on PMU counters and this issue exists
    //  see https://jira.arm.com/browse/APAP-2323
    [
      /^ERROR: Insufficient counters to collect metrics. Minimum of (?<minCounters>\d+?) counters required, found (?<actualCounters>\d+?) for cpu (?<cpuNum>\d+?)$/m,
      {
        msgCode: 'tool_integrations.neoprof.INSUFFICIENT_PMU_COUNTERS',
        metadataProvider: (match) => ({
          minCounters: match[1],
          actualCounters: match[2],
          cpuNum: match[3],
        }),
      },
    ],
    [
      /^WARN: Invalid value for --metric-group \((?<value>.+?)\):$/m,
      {
        msgCode: 'tool_integrations.neoprof.INVALID_METRICS_GROUP',
        metadataProvider: (match) => ({
          value: match[1],
        }),
      },
    ],
    [
      /^ERROR: SPE requested but the Arm SPE driver was not detected on this machine.$/m,
      {
        msgCode: 'tool_integrations.neoprof.SPE_NOT_CONFIGURED',
        metadataProvider: (match) => ({}),
      },
    ],
    [
      /^ERROR: Failed to bind socket due to Address in use.*$/m,
      {
        msgReason: 'TARGET_IN_USE',
        metadataProvider: (match) => ({}),
      },
    ],
    [
      /^Exception -- Error, invalid jitdump record, required data is not present.$/m,
      {
        msgCode: 'tool_integrations.neoprof.JITDUMP_INVALID_RECORD',
        metadataProvider: (/** @type {any} */ match) => ({}),
      },
    ],
    [
      /^Skipping symbols.json export because backtrace report is empty$/m,
      {
        msgCode: 'tool_integrations.neoprof.NO_SAMPLES_COLLECTED',
        throwOnZeroExit: true,
        metadataProvider: (/** @type {any} */ match) => ({}),
      },
    ],
  ]);

  let stdErr = await readHostFile(engine, hostPath);
  if (!stdErr) {
    return;
  }

  ERR_MAP.forEach((msgInfo, regex) => {
    if (exitCode === 0 && msgInfo.throwOnZeroExit !== true) {
      return;
    }
    let match = stdErr.match(regex);
    if (!match) {
      return;
    }
    // Known error message was found in stdErr output, throw error with corresponding code
    let metadata = msgInfo.metadataProvider(match);
    throw { code: msgInfo.msgCode, metadata: metadata };
  });
}

/**
 * Checks stderr for known error signatures and throws a generic error if the tool exited non-zero.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string} hostPath
 * @param {number} exitCode
 * @param {string} tool
 * @returns {Promise<void>}
 */
async function checkToolFailure(engine, ctx, hostPath, exitCode, tool) {
  await scrapeStdErr(engine, ctx, hostPath, exitCode);
  if (exitCode !== 0) {
    // If no known error was found, return a generic error if still didn't complete successfully
    throw {
      code: 'tool_integrations.neoprof.NEOPROF_FAILED',
      metadata: { tool: tool, code: exitCode },
    };
  }
}

/**
 * Create a file in the run directory.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @returns {Promise<import("../recipes/docs/jsdocs").FileHandle>}
 */
async function createRunFile(engine, path, meta) {
  try {
    return await engine.createRunFile(path, meta);
  } catch {
    throw {
      code: 'tool_integrations.neoprof.HOST_FILE_CREATE',
      metadata: { file: path },
    };
  }
}

/**
 * Read the contents of a file on the host.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @returns {Promise<string>}
 */
async function readHostFile(engine, path) {
  try {
    return await engine.readHostFile(path);
  } catch {
    engine.log(
      'warn',
      `Failed to read host file '${path}', falling back to generic error`,
    );
    return '';
  }
}

/**
 * Drains a stream to a file and closes the file when complete.
 * @param {import("../recipes/docs/jsdocs").FileHandle} handle
 * @param {import("../recipes/docs/jsdocs").StreamRedirect} stream
 * @returns {Promise<void>}
 */
async function drainStreamToFileAndClose(handle, stream) {
  try {
    await forAwait(stream, (chunk) => handle.append(chunk));
  } catch {
    throw {
      code: 'tool_integrations.neoprof.WRITE_STREAM',
      metadata: { file: handle.path },
    };
  } finally {
    await handle.close().catch(() => {});
  }
}

/**
 * Registers the capture directory artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitCaptureDir(engine, outputDir) {
  engine.emitOutput(outputDir + '/**/*', 'capture.apc/*', {
    name: 'capture_apc',
    version: '1.0',
  });
}

/**
 * Registers the common artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitCommonFiles(engine, outputDir) {
  engine.emitOutput(outputDir + '/symbols.json', 'output/symbols.json', {
    name: 'sl-collect-symbols',
    version: '1.1',
  });
  engine.emitOutput(outputDir + '/db/state.xml', 'state.xml', {
    name: 'state',
    version: '1.1',
  });
  engine.emitOutput(
    outputDir + '/sources-capture-periodic_sampling*',
    'output/sources-capture-periodic_sampling*',
    { name: 'sl-collect-source-line-attribution', version: '1.0' },
  );
  // applications.xml contains processes and threads info
  engine.emitOutput(outputDir + '/db/applications.xml', 'applications.xml', {
    name: 'applications',
    version: '1.0',
  });
  // logs
  engine.emitOutput(outputDir + '/../capture_log.txt', 'capture_log.txt', {
    name: 'log-text',
    version: '1.0',
  });
  engine.emitOutput(outputDir + '/../analysis.log', 'analysis.log', {
    name: 'log-text',
    version: '1.0',
  });
  engine.emitOutput(outputDir + '/gator-log.txt', 'gator-log.txt', {
    name: 'log-text-debug',
    version: '1.0',
  });
}

/**
 * Registers SPE-specific artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitSPEFiles(engine, outputDir) {
  // This file is not always available (see https://jira.arm.com/browse/NEOPROF-321) - if it's not produced at recipe run time, then let's
  // ensure that recipe run will fail
  engine.emitOutput(
    outputDir + '/functions-capture-spe.csv',
    'output/functions-capture-spe.csv',
    { name: 'sl-collect-functions-spe-csv', version: '1.1' },
  );
  engine.emitOutput(
    outputDir + '/symbols-spe.json',
    'output/symbols-spe.json',
    {
      name: 'sl-collect-symbols',
      version: '1.1',
    },
  );
}

/**
 * Registers hotspots-specific artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitHotspotFiles(engine, outputDir) {
  engine.emitOutput(
    outputDir + '/call_tree_samples.json',
    'output/call_tree_samples.json',
    { name: 'sl-collect-call-tree', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/callpath_self_samples.json',
    'output/callpath_self_samples.json',
    { name: 'sl-collect-metrics', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/callpath_total_samples.json',
    'output/callpath_total_samples.json',
    { name: 'sl-collect-metrics', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/callpaths-capture-periodic_sampling.csv',
    'output/callpaths-capture-periodic_sampling.csv',
    { name: 'sl-collect', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/functions-capture-periodic_sampling.csv',
    'output/functions-capture-periodic_sampling.csv',
    { name: 'sl-collect-flat-functions-csv', version: '1.1' },
  );
}

/**
 * Registers metrics-specific artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitMetricsFiles(engine, outputDir) {
  engine.emitOutput(outputDir + '/call_tree.json', 'output/call_tree.json', {
    name: 'sl-collect-call-tree',
    version: '1.0',
  });
  engine.emitOutput(
    outputDir + '/callpath_self_metrics.json',
    'output/callpath_self_metrics.json',
    { name: 'sl-collect-metrics', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/callpath_total_metrics.json',
    'output/callpath_total_metrics.json',
    { name: 'sl-collect-metrics', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/callpaths-capture-metrics.csv',
    'output/callpaths-capture-metrics.csv',
    { name: 'sl-collect', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/functions-capture-metrics.csv',
    'output/functions-capture-metrics.csv',
    { name: 'sl-collect-flat-functions-csv', version: '1.1' },
  );
}

/**
 * Registers disassembly artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} outputDir
 * @returns {void}
 */
function emitDisassemblyFiles(engine, outputDir) {
  engine.emitOutput(
    outputDir + '/disassembly-capture-periodic_sampling*',
    'output/disassembly-capture-periodic_sampling*',
    { name: 'disassembly_capture_samples', version: '1.0' },
  );
  engine.emitOutput(
    outputDir + '/disassembly-capture-metrics*',
    'output/disassembly-capture-metrics*',
    { name: 'disassembly_capture_metrics', version: '1.0' },
  );
}

/**
 * Checks jitdump-jvm is available on the target machine,
 * either returning advice or throwing an error based on throwOnError.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {boolean} throwOnError
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function checkJvmAgent(engine, ctx, throwOnError) {
  // Ideally we'd get this info via the exposed APIs on engine/ctxs
  // For now, this is fine, but refactor when those APIs are available
  // TODO: See APAP-2528
  const paths = getNeoprofPaths(ctx);
  const jitdumpJvmFile = paths.jitdumpJvmDeployPath + 'jitdump-jvm';
  let jitdumpJvmCheck = await engine.execCommand(['stat', jitdumpJvmFile], {});
  if (jitdumpJvmCheck.rc !== 0) {
    return handleError(
      'error',
      'The Java agent tool is not deployed on the target machine. Use the --deploy-tools flag when running the recipe to deploy it.',
      'JITDUMP_JVM_NOT_DEPLOYED',
      { deployPath: jitdumpJvmFile },
      throwOnError,
    );
  }

  return {
    level: 'ready',
    message: '',
  };
}

/**
 * Probes jitdump-jvm on the target machine.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function probejitdumpJvm(engine, ctx) {
  return await checkJvmAgent(engine, ctx, false);
}

/**
 * Asserts jitdump-jvm is available on the target machine.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<void>}
 */
async function assertJitdumpJvm(engine, ctx) {
  await checkJvmAgent(engine, ctx, true);
}

/**
 * Helper for returning probe advice or throwing an integration error.
 * @param {string} level
 * @param {string} message
 * @param {string} code
 * @param {Object.<string, string>} metadata
 * @param {boolean} throwOnError
 * @returns {import("../recipes/docs/jsdocs").ProbeAdvice}
 */
function handleError(level, message, code, metadata, throwOnError) {
  if (throwOnError) {
    throw { code: 'tool_integrations.neoprof.' + code, metadata: metadata };
  }
  return {
    level: level,
    message: message,
  };
}

/**
 * Checks the dotnet agent is available on the target machine,
 * either returning advice or throwing an error based on throwOnError.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {boolean} throwOnError
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function checkDotnetAgent(engine, ctx, throwOnError) {
  const paths = getNeoprofPaths(ctx);
  const dotnetAgentFile = paths.dotnetAgentDeployPath + 'jitdump-dotnet';
  let dotnetAgentCheck = await engine.execCommand(
    ['stat', dotnetAgentFile],
    {},
  );
  if (dotnetAgentCheck.rc !== 0) {
    return handleError(
      'error',
      'The .NET agent tool is not deployed on the target machine. Use the --deploy-tools flag when running the recipe to deploy it.',
      'DOTNET_AGENT_NOT_DEPLOYED',
      { deployPath: dotnetAgentFile },
      throwOnError,
    );
  }

  return {
    level: 'ready',
    message: '',
  };
}

/**
 * Probes the dotnet agent on the target machine.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<import("../recipes/docs/jsdocs").ProbeAdvice>}
 */
async function probeDotnetAgent(engine, ctx) {
  return await checkDotnetAgent(engine, ctx, false);
}

/**
 * Asserts the dotnet agent is available on the target machine.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<void>}
 */
async function assertDotnetAgent(engine, ctx) {
  await checkDotnetAgent(engine, ctx, true);
}

/**
 * Tries to determine the session owner (current user) on the target machine.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @returns {Promise<string>} The username of the session owner.
 */
async function resolveSessionOwner(engine) {
  const lognameCmd = await engine.execCommand(['logname'], {});
  if (lognameCmd.rc === 0) {
    return lognameCmd.stdout.trim();
  }

  // Fallback: SUDO_USER or USER (in that order)
  const envCmd = await engine.execCommand(['env'], {});
  if (envCmd.rc === 0) {
    const envLines = envCmd.stdout.split('\n');
    for (const key of ['SUDO_USER=', 'USER=']) {
      const line = envLines.find((l) => l.startsWith(key));
      if (line) {
        return line.substring(key.length).trim();
      }
    }
  }

  throw {
    code: 'tool_integrations.neoprof.SESSION_OWNER_NOT_FOUND',
    metadata: { lognameRc: lognameCmd.rc, envRc: envCmd.rc },
  };
}

/**
 * Interrupts an agent helper process and waits for it to exit.
 * @param {string} agentName - Used only for error messages (e.g. "jitdump-jvm", "dotnet-agent")
 * @param {import("../recipes/docs/jsdocs").ProcessHandle} procHandle
 * @returns {Promise<Error | null>}
 */
async function interruptAgent(agentName, procHandle) {
  if (!procHandle) {
    return null;
  }

  /** @type {unknown} */
  let interruptErr = null;
  try {
    await procHandle.interrupt();
  } catch (err) {
    interruptErr = err;
  }

  let result;
  try {
    result = await procHandle.wait();
  } catch (err) {
    const waitErr = err instanceof Error ? err : new Error(String(err));
    if (interruptErr) {
      const interruptMessage =
        interruptErr instanceof Error
          ? interruptErr.message
          : String(interruptErr);
      return new Error(
        `${agentName} interrupt failed: ${interruptMessage}; wait failed: ${waitErr.message}`,
      );
    }
    return waitErr;
  }

  // -1 indicates the process already stopped - safe to ignore
  if (result.exitCode != -1 && result.exitCode != 0) {
    return new Error(`${agentName} exited with code ${result.exitCode}`);
  }

  return null;
}

/**
 * Stops the jitdump-jvm process via interrupt and resets its process handle.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<import("../recipes/docs/jsdocs").CatalogMessage|null>}
 */
async function stopJvmAgent(engine, ctx) {
  if (!ctx.metadata.jitdumpJvmProcHandle) {
    return null;
  }

  let err = await interruptAgent(
    'jitdump-jvm',
    ctx.metadata.jitdumpJvmProcHandle,
  );

  if (err) {
    engine.log('warn', `jitdump-jvm stop reported: ${err.message}`);
    return {
      code: 'tool_integrations.neoprof.JITDUMP_JVM_STOP_FAILED',
      cause: `failed to stop jitdump-jvm: ${err.message}`,
    };
  }

  ctx.metadata.jitdumpJvmProcHandle = null;
  return null;
}

/**
 * Stops the dotnet-agent process via interrupt and resets its process handle.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<import("../recipes/docs/jsdocs").CatalogMessage|null>}
 */
async function stopDotnetAgent(engine, ctx) {
  if (!ctx.metadata.dotnetAgentProcHandle) {
    return null;
  }

  let err = await interruptAgent(
    'dotnet-agent',
    ctx.metadata.dotnetAgentProcHandle,
  );

  if (err) {
    engine.log('warn', `dotnet-agent stop reported: ${err.message}`);
    return {
      code: 'tool_integrations.neoprof.DOTNET_AGENT_STOP_FAILED',
      cause: `failed to stop dotnet-agent: ${err.message}`,
    };
  }

  ctx.metadata.dotnetAgentProcHandle = null;
  return null;
}

/**
 * Determines if neoprof requires privileged access to run sl-record, sl-analyze
 * and the jitdump-jvm on the target machine.
 *
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<boolean>}
 *
 */
async function isPrivilegeRequired(engine, ctx) {
  // 1.a Check workload type
  // **systemWide** profiling requires root
  // **attach** profiling uses systemWide under the hood; it also requires root
  if (ctx.workload.type === 'systemWide' || ctx.workload.type === 'attach') {
    engine.log(
      'info',
      `Workload type is ${ctx.workload.type}, privileged access is required.`,
    );
    return true;
  }

  // 1.b Check profiling mode
  // **spe** workflow with physical address (PA) capture requires root (default SPE behavior)
  // TODO: Need to revisit this when we add PA support for the memory sharing recipe.
  if (ctx.params['mode'] === 'spe') {
    engine.log(
      'info',
      `SPE workflow requires physical address capture; privileged access is required.`,
    );
    return true;
  }

  // 2. Check file permissions
  const currUser = await resolveLoginName(engine);
  const isReadable = await posixTestWorkload(engine, ctx.workload, ['-r']);
  if (!isReadable) {
    engine.log(
      'info',
      `Current user '${currUser}' does not have read permissions on the workload; privileged access is required.`,
    );
    return true;
  }

  const isExecutable = await posixTestWorkload(engine, ctx.workload, ['-x']);
  if (!isExecutable) {
    engine.log(
      'info',
      `Current user '${currUser}' does not have execute permissions on the workload; privileged access is required.`,
    );
    return true;
  }

  // 3. Check capabilities
  // We only care about CAP_PERFMON (Linux 5.8+) and CAP_SYS_ADMIN (pre-5.8 fallback) here.
  let capable = await isPerfCapable(engine);
  if (capable) {
    engine.log(
      'info',
      'Target platform grants performance monitoring capabilities, privileged access is not required.',
    );
    return false;
  }

  // 4. Check perf_event_paranoid setting
  // **launch** profiling may require root, depending on target platform config

  // Below logic was adapted from Linux perf documentation
  // See: https://www.kernel.org/doc/html/latest/admin-guide/perf-security.html

  let perfSetting = await getPerfSetting(engine);
  if (perfSetting === null) {
    engine.log(
      'info',
      'Target platform is not Linux or the perf_event_paranoid setting could not be determined. Assuming privileged access is required.',
    );
    return true;
  }

  switch (perfSetting) {
    case -1:
      // No restrictions
      engine.log(
        'info',
        `Target platform has no restrictions on performance monitoring, privileged access is not required. perf_event_paranoid=${perfSetting}`,
      );
      return false;
    case 0:
      // Allows access to CPU-specific data but not raw tracepoint samples.
      engine.log(
        'info',
        `Target platform allows per-process and system wide performance monitoring, but excludes raw tracepoints and ftrace function tracepoints monitoring. perf_event_paranoid=${perfSetting}`,
      );
      return false;
    case 1:
      // Allows both user-space and kernel-space measurements.
      engine.log(
        'info',
        `Target platform allows per-process performance monitoring of both user-space and kernel-space only, but excludes system wide monitoring. perf_event_paranoid=${perfSetting}`,
      );
      break;
    case 2:
      // Allows only user-space measurements - limited access to hardware counters (default).
      engine.log(
        'info',
        `Target platform allows per-process performance monitoring of user-space only, but excludes system-wide and kernel-space monitoring. perf_event_paranoid=${perfSetting}`,
      );
      break;
    case 3:
      // Disallows all performance monitoring for unprivileged users.
      engine.log(
        'info',
        `Target platform disallows all performance monitoring for unprivileged users. perf_event_paranoid=${perfSetting}`,
      );
      break;
    default:
      engine.log(
        'warn',
        `Target platform does not allow performance monitoring or has an unknown setting. perf_event_paranoid=${perfSetting}`,
      );
      break;
  }

  engine.log(
    'info',
    `Target platform performance monitoring requirements are not met; defaulting to requiring privileged access for ${tool.name} on this target.`,
  );
  return true;
}
