// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview Worker to convert V8 coverage to IstanbulJS
 * compliant coverage files.
 */

import {createHash} from 'node:crypto';
import {join, dirname, normalize} from 'node:path';
import {readdir, readFile, writeFile} from 'node:fs/promises';
import {workerData, parentPort} from 'node:worker_threads';
import V8ToIstanbul from '../../../third_party/js_code_coverage/node_modules/v8-to-istanbul/index.js';
import convertSourceMap from '../../../third_party/js_code_coverage/node_modules/convert-source-map/index.js';
import sourceMap from '../../../third_party/js_code_coverage/node_modules/source-map/source-map.js';

/**
 * Validate that the mapping in the sourcemaps is valid.
 * @param mapping Individual mapping to validate.
 * @param sourcesMap Map of the sources in the mappings to it's content.
 * @param instrumentedFilePath Path to the instrumented file.
 * @returns true if mapping is valid, false otherwise.
 */
function validateMapping(mapping, sourcesMap, instrumentedFilePath) {
  if (!mapping.generatedLine || !mapping.originalLine || !mapping.source) {
    console.log(`Invalid mapping found for ${instrumentedFilePath}`);
    return false;
  }

  // Verify that we have file contents.
  if (!sourcesMap[mapping.source]) {
    return false;
  }

  // Verify that the mapping line numbers refers to actual lines in source.
  const origLine = sourcesMap[mapping.source][mapping.originalLine - 1];
  const genLine = sourcesMap[instrumentedFilePath][mapping.generatedLine - 1];
  if (origLine === undefined || genLine === undefined) {
    return false;
  }

  // Verify that the mapping columns refers to actual column bounds in source.
  if (mapping.generatedColumn > genLine.length ||
      mapping.originalColumn > origLine.length) {
    return false;
  }

  return true;
}

/**
 * Validate the sourcemap by looking at:
 * 1. Existence of the source files in sourcemap
 * 2. Verify original and generated lines are within bounds.
 * @param instrumentedFilePath Path to the file with source map.
 * @returns true if sourcemap is valid, false otherwise
 */
async function validateSourceMaps(instrumentedFilePath) {
  const rawSource = await readFile(instrumentedFilePath, 'utf8');
  const rawSourceMap = convertSourceMap.fromSource(rawSource) ||
      convertSourceMap.fromMapFileSource(
          rawSource, dirname(instrumentedFilePath));

  if (!rawSourceMap || rawSourceMap.sourcemap.sources.length < 1) {
    console.log(`No valid source map found for ${instrumentedFilePath}`);
    return false;
  }

  let sourcesMap = {};
  sourcesMap[instrumentedFilePath] = rawSource.toString().split('\n');
  for (const source of rawSourceMap.sourcemap.sources) {
    const sourcePath =
        normalize(join(rawSourceMap.sourcemap.sourceRoot, source));
    try {
      const content = await readFile(sourcePath, 'utf-8');
      sourcesMap[sourcePath] = content.toString().split('\n');
    } catch (error) {
      if (error.code === 'ENOENT') {
        console.error(`Original missing for ${sourcePath}`);
        return false;
      } else {
        throw error;
      }
    }
  }

  let validMap = true;
  const consumer =
      await new sourceMap.SourceMapConsumer(rawSourceMap.sourcemap);
  consumer.eachMapping(function(mapping) {
    if (!validMap ||
        !validateMapping(mapping, sourcesMap, instrumentedFilePath)) {
      validMap = false;
    }
  });

  // Destroy consumer as we dont need it anymore.
  consumer.destroy();
  return validMap;
}

/**
 * Helper function to provide a unique file name for resultant istanbul reports.
 * @param str File contents
 * @return A sha1 hash to be used as a file name.
 */
function createSHA1HashFromFileContents(contents) {
  return createHash('sha1').update(contents).digest('hex');
}

/**
 * Extracts the raw coverage data from the v8 coverage reports and converts
 * them into IstanbulJS compliant reports.
 * @param coverageDirectory Directory containing the raw v8 output.
 * @param instrumentedDirectoryRoot Directory containing the source
 *    files where the coverage was instrumented from.
 * @param outputDir Directory to store the istanbul coverage reports.
 * @param urlToPathMap A mapping of URL observed during
 *    test execution to the on-disk location created in previous steps.
 */
async function extractCoverage(
    coverageDirectory, instrumentedDirectoryRoot, outputDir, urlToPathMap) {
  const start = Math.floor(Date.now() / 1000)
  const coverages = await readdir(coverageDirectory);
  for (const fileName of coverages) {
    if (!fileName.endsWith('.cov.json'))
      continue;

    const filePath = join(coverageDirectory, fileName);
    const contents = await readFile(filePath, 'utf-8');
    const {result: scriptCoverages} = JSON.parse(contents);
    if (!scriptCoverages)
      throw new Error(`result key missing for file: ${filePath}`);

    for (const coverage of scriptCoverages) {
      if (!urlToPathMap[coverage.url])
        continue;

      const instrumentedFilePath =
          join(instrumentedDirectoryRoot, urlToPathMap[coverage.url]);
      const validSourceMap = await validateSourceMaps(instrumentedFilePath)
      if (!validSourceMap) {
        continue;
      }

      const converter = V8ToIstanbul(instrumentedFilePath);
      await converter.load();
      converter.applyCoverage(coverage.functions);
      const convertedCoverage = converter.toIstanbul();

      const jsonString = JSON.stringify(convertedCoverage);
      await writeFile(
          join(outputDir, createSHA1HashFromFileContents(jsonString) + '.json'),
          jsonString);
    }
  }

  const end = Math.floor(Date.now() / 1000) - start
  parentPort.postMessage(
      `Successfully converted for ${workerData.coverageDir} in ${end}s`);
}

extractCoverage(
    workerData.coverageDir, workerData.sourceDir, workerData.outputDir,
    workerData.urlToPathMap)
