/**
 * An entry in a jitdump user actions file.
 * @typedef {Object} JitdumpActionEntry
 * @property {string} timestamp
 * @property {string} code
 * @property {Object.<string, any>} payload
 */

/**
 * A validated jitdump user action.
 * @typedef {Object} JitdumpAction
 * @property {string} code
 * @property {Object.<string, any>} params
 */

/**
 * Reformats files generated during jitdump captures.
 * Any jitdump files are moved into the APC directory as expected by sl-analyze.
 * Any user actions reported during the capture are handled appropriately.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {Promise<void>}
 */
async function reformatJitdumps(engine, ctx) {
  const jitdumpTargetDir = `${ctx.metadata.captureDirectory}/jitdumps`;

  await ensureJitdumpTargetDir(engine, ctx, jitdumpTargetDir);

  const stageTasks = [];

  // JVM support: handle jitdumps + actions
  if (ctx.metadata.jitdumpJvmAvailable) {
    stageTasks.push(
      () => handleJvmJitdumps(engine, ctx, jitdumpTargetDir),
      () => handleJvmActions(engine, ctx.metadata.jvmActionsFile),
    );
  }

  // .NET support: stage jitdumps from all reported output directories.
  if (ctx.metadata.dotnetAgentAvailable) {
    stageTasks.push(async () => {
      const dotnetDirs = await getDotnetJitdumpDirsFromActions(
        engine,
        ctx.metadata.dotnetActionsFile,
        ctx.metadata.dotnetJitdumpDir,
      );

      await handleDotnetJitdumps(engine, ctx, jitdumpTargetDir, dotnetDirs);
      await handleDotnetActions(engine, ctx.metadata.dotnetActionsFile);
    });
  }

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

/**
 * Ensures the APC jitdumps target directory exists.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string} jitdumpTargetDir
 * @returns {Promise<void>}
 */
async function ensureJitdumpTargetDir(engine, ctx, jitdumpTargetDir) {
  // Note: we use a shell since the target filesystem is remote.
  const mkdirResult = await engine.execCommand(
    ['bash', '-c', `mkdir -p "${jitdumpTargetDir}"`],
    { asRoot: ctx.metadata.neoprofAsRoot },
  );
  if (mkdirResult.rc !== 0) {
    throw {
      code: 'tool_integrations.neoprof.JITDUMP_MOVE_FAILED',
      metadata: {
        rc: mkdirResult.rc,
        sourceDir: '(mkdir)',
        destinationDir: jitdumpTargetDir,
      },
      cause: mkdirResult.stderr
        ? mkdirResult.stderr.trim()
        : `failed to create jitdump target dir ${jitdumpTargetDir}`,
    };
  }
}

/**
 * Stages JVM jitdumps into the APC jitdumps directory.
 * Also processes any JVM user actions and removes agent_properties* (sl-analyze expects only jitdump files).
 *
 * Provides an engine warning if no jitdump files were produced.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string} jitdumpTargetDir
 * @returns {Promise<void>}
 */
async function handleJvmJitdumps(engine, ctx, jitdumpTargetDir) {
  // If no files were produced, skip moving jitdumps and continue (warning-only).
  const jvmCheck = await engine.execCommand(
    ['bash', '-c', `compgen -G "${ctx.metadata.jvmJitdumpDir}/*" >/dev/null`],
    { asRoot: ctx.metadata.neoprofAsRoot },
  );
  if (jvmCheck.rc !== 0) {
    engine.log(
      'warn',
      `No JVM jitdump files were produced; skipping jitdump staging from '${ctx.metadata.jvmJitdumpDir}'.`,
    );
    return;
  }

  const jvmMvResult = await engine.execCommand(
    [
      'bash',
      '-c',
      `mv "${ctx.metadata.jvmJitdumpDir}/"* "${jitdumpTargetDir}/"`,
    ],
    { asRoot: ctx.metadata.neoprofAsRoot },
  );
  if (jvmMvResult.rc !== 0) {
    throw {
      code: 'tool_integrations.neoprof.JITDUMP_MOVE_FAILED',
      metadata: {
        rc: jvmMvResult.rc,
        sourceDir: ctx.metadata.jvmJitdumpDir,
        destinationDir: jitdumpTargetDir,
      },
      cause: jvmMvResult.stderr
        ? jvmMvResult.stderr.trim()
        : `failed to move JVM jitdump files from ${ctx.metadata.jvmJitdumpDir} to ${jitdumpTargetDir}`,
    };
  }

  const rmResult = await engine.execCommand(
    ['bash', '-c', `rm -f ${jitdumpTargetDir}/agent_properties*`],
    { asRoot: ctx.metadata.neoprofAsRoot },
  );
  if (rmResult.rc !== 0) {
    throw {
      code: 'tool_integrations.neoprof.JITDUMP_MOVE_FAILED',
      metadata: {
        rc: rmResult.rc,
        sourceDir: ctx.metadata.jvmJitdumpDir,
        destinationDir: jitdumpTargetDir,
      },
      cause: rmResult.stderr
        ? rmResult.stderr.trim()
        : `failed to remove agent_properties file from ${jitdumpTargetDir}`,
    };
  }
}

/**
 * Stages .NET jitdumps into the APC jitdumps directory.
 * Best-effort: failures to move from a location are logged as warnings.
 *
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @param {string} jitdumpTargetDir
 * @param {string[]} jitdumpDirs
 * @returns {Promise<void>}
 */
async function handleDotnetJitdumps(
  engine,
  ctx,
  jitdumpTargetDir,
  jitdumpDirs,
) {
  const dirs =
    Array.isArray(jitdumpDirs) && jitdumpDirs.length > 0
      ? jitdumpDirs
      : [ctx.metadata.dotnetJitdumpDir];

  let movedAny = false;

  for (const dir of dirs) {
    const dotnetCheck = await engine.execCommand(
      ['bash', '-c', `compgen -G "${dir}/*" >/dev/null`],
      { asRoot: ctx.metadata.neoprofAsRoot },
    );
    if (dotnetCheck.rc !== 0) {
      continue;
    }

    const dotnetMvResult = await engine.execCommand(
      ['bash', '-c', `mv "${dir}/"* "${jitdumpTargetDir}/"`],
      { asRoot: ctx.metadata.neoprofAsRoot },
    );
    if (dotnetMvResult.rc !== 0) {
      engine.log(
        'warn',
        dotnetMvResult.stderr
          ? dotnetMvResult.stderr.trim()
          : `Failed to move .NET jitdump files from ${dir} to ${jitdumpTargetDir}`,
      );
      continue;
    }

    movedAny = true;
  }

  if (!movedAny) {
    const where =
      dirs.length === 1
        ? `'${dirs[0]}'`
        : `[${dirs.map((d) => `'${d}'`).join(', ')}]`;
    engine.log(
      'warn',
      `No .NET jitdump files were produced; skipping jitdump staging from ${where}.`,
    );
  }
}

/**
 * Handles any JVM actions reported during a JVM jitdump capture.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @returns {Promise<void>}
 */
async function handleJvmActions(engine, path) {
  const actionsMap = new Map([
    [
      'MustAddPreserveFramePointerOption',
      {
        params: ['pid', 'must_add'],
        message: `JVM process {pid} was not run with the {must_add} option. Without this option, JVM stack traces may be incomplete or inaccurate. Rerun with this option to ensure accurate JVM stack traces.`,
        severity: 'warn',
      },
    ],
    [
      'ShouldAddEnableDynamicAgentLoading',
      {
        params: ['pid', 'should_add'],
        message: `JVM process {pid} was not run with the {should_add} option. Newer JVMs may remove the ability to dynamically load agents without this option. Include this option to ensure profiling will continue to work in future JVM versions.`,
        severity: 'info',
      },
    ],
  ]);

  await emitUserActionMessage(engine, path, actionsMap);
}

/**
 * Handles any .NET actions reported during a .NET jitdump capture.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @returns {Promise<void>}
 */
async function handleDotnetActions(engine, path) {
  const actionsMap = new Map([
    [
      'ShouldDisableWriteXORExecuteMemoryProtection',
      {
        params: ['pid', 'environmentVariable', 'expectedValue', 'currentValue'],
        message: `.NET process {pid} should set {environmentVariable}={expectedValue} (current value: {currentValue}). Without this setting, .NET stack/jitdump collection may be incomplete or fail.`,
        severity: 'warn',
      },
    ],
    [
      'MustSetJitFramedCompilationDisabled',
      {
        params: ['pid', 'environmentVariable', 'expectedValue', 'currentValue'],
        message: `.NET process {pid} must set {environmentVariable}={expectedValue} (current value: {currentValue}). Without this setting, .NET stack/jitdump collection may be incomplete or fail.`,
        severity: 'warn',
      },
    ],
    [
      'JitdumpOutputDirectory',
      {
        params: ['pid', 'environmentVariable', 'outputDirectory'],
        message: `.NET process {pid} is using {environmentVariable}={outputDirectory} for jitdump output.`,
        severity: 'info',
      },
    ],
  ]);

  await emitUserActionMessage(engine, path, actionsMap);
}

/**
 *
 * Formats user action messages and then emits them to the user message system.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @param {Map<string, {params: string[], message: string}>} actionsMap
 * @returns {Promise<void>}
 */
async function emitUserActionMessage(engine, path, actionsMap) {
  let actions = await getJitdumpActions(engine, path, actionsMap);
  for (const action of actions) {
    const actionInfo = actionsMap.get(action.code);
    if (!actionInfo) {
      continue;
    }
    let message = actionInfo.message;
    for (const key of actionInfo.params) {
      message = message.replaceAll(`{${key}}`, action.params[key]);
    }
    engine.writeUserMessage(actionInfo.severity, message);
  }
}

/**
 * Loads the jitdump user actions from the given path and validates the contained actions based on the actions map.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @param {Object.<string, any>} actionsMap
 * @returns {Promise<import("../recipes/docs/jsdocs").JitdumpAction[]>} Validated jitdump actions
 */
async function getJitdumpActions(engine, path, actionsMap) {
  const actions = await parseJitdumpActionsFile(engine, path);
  if (actions.length === 0) {
    return [];
  }

  const unexpectedPayloadMsg = `Payload for {code} jitdump user action is missing keys: {keys}`;
  const unexpectedCodeMsg = `Unknown jitdump user action code '{code}' reported`;

  let validatedActions = [];

  for (const action of actions) {
    const code = action.code;
    const payload = action.payload || {};

    const actionInfo = actionsMap.get(code);
    if (!actionInfo) {
      engine.log('error', unexpectedCodeMsg.replace('{code}', code));
      continue;
    }

    const expectedParams = actionInfo.params || [];
    let missingParams = [];
    for (const key of expectedParams) {
      if (!(key in payload)) {
        missingParams.push(key);
      }
    }
    if (missingParams.length > 0) {
      engine.log(
        'error',
        unexpectedPayloadMsg
          .replace('{code}', code)
          .replace('{keys}', missingParams.join(', ')),
      );
      continue;
    }

    validatedActions.push({ code: code, params: payload });
  }
  return validatedActions;
}

/**
 * Collects unique .NET jitdump output directories from user actions.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} actionsFilePath
 * @param {string} fallbackDir
 * @returns {Promise<string[]>}
 */
async function getDotnetJitdumpDirsFromActions(
  engine,
  actionsFilePath,
  fallbackDir,
) {
  const entries = await parseJitdumpActionsFile(engine, actionsFilePath);

  /** @type {Set<string>} */
  const dirs = new Set();

  if (fallbackDir) {
    dirs.add(fallbackDir);
  }

  for (const entry of entries) {
    if (entry.code !== 'JitdumpOutputDirectory') {
      continue;
    }

    const outDir = entry.payload && entry.payload.outputDirectory;
    if (typeof outDir === 'string') {
      const trimmed = outDir.trim();
      if (trimmed) {
        dirs.add(trimmed);
      }
    }
  }

  return Array.from(dirs);
}

/**
 * Parses a jitdump user actions file.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {string} path
 * @returns {Promise<import("../recipes/docs/jsdocs").JitdumpActionEntry[]>}
 */
async function parseJitdumpActionsFile(engine, path) {
  const contents = await engine.execCommand(['cat', path], {});
  if (contents.rc !== 0) {
    return [];
  }

  let actions = [];
  const lines = contents.stdout.split('\n');
  for (let idx = 0; idx < lines.length; idx++) {
    const line = lines[idx].trim();
    if (!line) {
      continue;
    }
    try {
      let parsed = JSON.parse(line);
      actions.push(parsed);
    } catch (err) {
      engine.log(
        'error',
        `Failed to parse jitdump user action on line ${idx + 1} of '${path}': ${err.message}`,
      );
    }
  }
  return actions;
}

/**
 * Filters jitdump agent collection flags based on runtime detection for an attach PID.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {number} pid
 * @param {boolean} asRoot
 * @returns {Promise<{ isJvmPid: boolean, isDotnetPid: boolean }>}
 */
async function filterJitdumpAgentsForPid(engine, pid, asRoot) {
  const [isJvmPid, isDotnetPid] = await Promise.all([
    isJvmProcessPid(engine, pid, asRoot),
    isDotnetProcessPid(engine, pid, asRoot),
  ]);

  return { isJvmPid, isDotnetPid };
}

/**
 * Checks if a JVM hsperfdata file exists for the given PID.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {number} pid
 * @param {boolean} asRoot
 * @returns {Promise<boolean>}
 */

// TODO: This check needs to be replaced with an error code check from the JVM Agent.
//       This is the same check that is made in the JVM agent, we do not want to duplicate this logic.
async function isJvmProcessPid(engine, pid, asRoot) {
  const pidStr = String(pid);
  const check = await engine.execCommand(
    ['find', '/tmp', '-path', `/tmp/hsperfdata_*/${pidStr}`, '-print', '-quit'],
    { asRoot: asRoot },
  );
  return check.rc === 0 && check.stdout.trim().length > 0;
}

/**
 * Checks if a .NET diagnostic socket exists for the given PID.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {number} pid
 * @param {boolean} asRoot
 * @returns {Promise<boolean>}
 */

// TODO: This check needs to be replaced with an error code check from the .NET Agent.
//       This is the same check that is made in the .NET agent, we do not want to duplicate this logic.
async function isDotnetProcessPid(engine, pid, asRoot) {
  const pidStr = String(pid);
  const check = await engine.execCommand(
    [
      'find',
      '/tmp',
      '-path',
      `/tmp/dotnet-diagnostic-${pidStr}*`,
      '-print',
      '-quit',
    ],
    { asRoot: asRoot },
  );
  return check.rc === 0 && check.stdout.trim().length > 0;
}

/**
 * Registers jitdump log files as run artifacts.
 * @param {import("../recipes/docs/jsdocs").Engine} engine
 * @param {import("../recipes/docs/jsdocs").ToolContext} ctx
 * @returns {void}
 */
function emitJitdumpLogs(engine, ctx) {
  let files = [];
  if (ctx.metadata.jitdumpJvmAvailable) {
    files.push('jitdumpjvm.log', 'jitdumpjvm_stderr.txt');
  }
  if (ctx.metadata.dotnetAgentAvailable) {
    files.push('jitdumpdotnet.log', 'jitdumpdotnet_stderr.txt');
  }

  const outputDir = ctx.metadata.outputDirectory;
  for (const file of files) {
    engine.emitOutput(`${outputDir}/${file}`, file, {
      name: 'log-text',
      version: '1.0',
    });
  }
}

module.exports = {
  reformatJitdumps,
  emitJitdumpLogs,
  isJvmProcessPid,
  isDotnetProcessPid,
  filterJitdumpAgentsForPid,
};
