/**
 * MIT License
 *
 * Copyright (c) 2020-present, Elastic NV
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 *
 */

import { red, green, yellow, cyan, bold, grey } from 'kleur/colors';
import os from 'os';
import { resolve, join, dirname } from 'path';
import fs from 'fs';
import { lstat, readdir } from 'fs/promises';
import { performance } from 'perf_hooks';
import sourceMapSupport from 'source-map-support';
import {
  HooksArgs,
  HooksCallback,
  NetworkConditions,
  Location,
  ThrottlingOptions,
} from './common_types';
import micromatch from 'micromatch';

const SEPARATOR = '\n';

export function noop() {}

export function indent(lines: string, tab = '   ') {
  return lines.replace(/^/gm, tab);
}

/**
 *  Disable unicode symbols for windows, the underlying
 *  FS stream has a known issue in windows
 */
const NO_UTF8_SUPPORT = process.platform === 'win32';
export const symbols = {
  warning: yellow(NO_UTF8_SUPPORT ? '!' : '⚠'),
  pending: yellow(NO_UTF8_SUPPORT ? '!' : '⚠'),
  skipped: cyan('-'),
  progress: cyan('>'),
  succeeded: green(NO_UTF8_SUPPORT ? 'ok' : '✓'),
  failed: red(NO_UTF8_SUPPORT ? 'x' : '✖'),
};

export function generateUniqueId() {
  return `${Date.now() + Math.floor(Math.random() * 1e13)}`;
}

export function generateTempPath() {
  return join(os.tmpdir(), `synthetics-${generateUniqueId()}`);
}

/**
 * Get Monotonically increasing time in seconds since
 * an arbitrary point in the past.
 *
 * We internally use the monotonically increasing clock timing
 * similar to the chrome devtools protocol network events for
 * journey,step start/end fields to make querying in the UI easier
 */
export function monotonicTimeInSeconds() {
  const hrTime = process.hrtime(); // [seconds, nanoseconds]
  return hrTime[0] * 1 + hrTime[1] / 1e9;
}

/**
 * Converts the trace events timestamp field from microsecond
 * resolution to monotonic seconds timestamp similar to other event types (journey, step, etc)
 * Reference - https://github.com/samccone/chrome-trace-event/blob/d45bc8af3b5c53a3adfa2c5fc107b4fae054f579/lib/trace-event.ts#L21-L22
 *
 * Tested and verified on both Darwin and Linux
 */
export function microSecsToSeconds(ts: number) {
  return ts / 1e6;
}

/**
 * Timestamp at which the current node process began.
 */
export const processStart = performance.timeOrigin;
export function getTimestamp() {
  return (processStart + now()) * 1000;
}

/**
 * Relative current time from the start of the current node process
 */
export function now() {
  return performance.now();
}

/**
 * Execute all the hooks callbacks in parallel using Promise.all
 */
export async function runParallel(
  callbacks: Array<HooksCallback>,
  args: HooksArgs
) {
  const promises = callbacks.map(cb => cb(args));
  return await Promise.all(promises);
}

export function isDepInstalled(dep) {
  try {
    return require.resolve(dep);
  } catch (e) {
    return false;
  }
}

export function isDirectory(path) {
  return fs.existsSync(path) && fs.statSync(path).isDirectory();
}

export function isFile(filePath) {
  return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}

/**
 * Traverse the directory tree up from the cwd until we find
 * package.json file to check if the user is invoking our script
 * from an NPM project.
 */
export function findPkgJsonByTraversing(resolvePath, cwd) {
  const packageJSON = resolve(resolvePath, 'package.json');
  if (isFile(packageJSON)) {
    return packageJSON;
  }
  const parentDirectory = dirname(resolvePath);
  /**
   * We are in the system root and package.json does not exist
   */
  if (resolvePath === parentDirectory) {
    throw red(
      `Could not find package.json file in: "${cwd}"\n` +
        `It is recommended to run the agent in an NPM project.\n` +
        `You can create one by running "npm init -y" in the project folder.`
    );
  }
  return findPkgJsonByTraversing(parentDirectory, cwd);
}

/**
 * Modified version of `totalist` package that handles the symlink issue
 * and avoids infinite recursion
 *
 * Based on code from totalist!
 * https://github.com/lukeed/totalist/blob/44379974e535afe9c38e8d643dd64c59101a14b9/src/async.js#L8
 */
export async function totalist(
  dir: string,
  callback: (relPath: string, absPath: string) => any,
  pre = ''
) {
  dir = resolve('.', dir);
  await readdir(dir).then(arr => {
    return Promise.all(
      arr.map(str => {
        const abs = join(dir, str);
        return lstat(abs).then(stats =>
          stats.isDirectory()
            ? totalist(abs, callback, join(pre, str))
            : callback(join(pre, str), abs)
        );
      })
    );
  });
}

/**
 * Find index of Playwright specific Error logs that is thrown
 * as part of the custom error message/stack
 */
export function findPWLogsIndexes(msgOrStack: string): [number, number] {
  let startIndex = 0;
  let endIndex = 0;
  if (!msgOrStack) {
    return [startIndex, endIndex];
  }
  const lines = String(msgOrStack).split(SEPARATOR);
  const logStart = /[=]{3,} logs [=]{3,}/;
  const logEnd = /[=]{10,}/;
  lines.forEach((line, index) => {
    if (logStart.test(line)) {
      startIndex = index;
    } else if (logEnd.test(line)) {
      endIndex = index;
    }
  });
  return [startIndex, endIndex];
}

export function rewriteErrorMessage(message: string, start: number) {
  if (start === 0) {
    return message;
  }
  return message.split(SEPARATOR).slice(0, start).join(SEPARATOR);
}

export function rewriteErrorStack(stack: string, indexes: [number, number]) {
  const [start, end] = indexes;
  /**
   * Do not rewrite if its not a playwright error
   */
  if (start === 0 && end === 0) {
    return stack;
  }
  const linesToKeep = start + 3;
  if (start > 0 && linesToKeep < end) {
    const lines = stack.split(SEPARATOR);
    return lines
      .slice(0, linesToKeep)
      .concat(...lines.slice(end))
      .join(SEPARATOR);
  }
  return stack;
}

const cwd = process.cwd();
/**
 * All the settings that are related to the Synthetics is stored
 * under this directory
 * Examples: Screenshots, Project setup
 */
export const SYNTHETICS_PATH = join(cwd, '.synthetics');
/**
 * Synthetics cache path that is based on the process id to make sure
 * each process does not modify the caching layer used by other process
 * once we move to executing journeys in parallel
 */
export const CACHE_PATH = join(SYNTHETICS_PATH, process.pid.toString());

export function getDurationInUs(duration: number) {
  return Math.trunc(duration * 1e6);
}

export function megabitsToBytes(megabytes: number) {
  return (megabytes * 1024 * 1024) / 8;
}

export const DEFAULT_THROTTLING_OPTIONS: ThrottlingOptions = {
  download: 5,
  upload: 3,
  latency: 20,
};

/**
 * Transforms the CLI throttling arguments in to format
 * expected by Chrome devtools protocol NetworkConditions
 */
export function getNetworkConditions(
  throttlingOpts: ThrottlingOptions
): NetworkConditions {
  return {
    downloadThroughput: megabitsToBytes(throttlingOpts.download),
    uploadThroughput: megabitsToBytes(throttlingOpts.upload), // Devtools CDP expects format to be in bytes/second
    latency: throttlingOpts.latency, // milliseconds,
    offline: false,
  };
}

export const THROTTLING_WARNING_MSG = `Throttling may not be active when the tests run - see
https://github.com/elastic/synthetics/blob/main/docs/throttling.md for more details`;

const dstackTraceLimit = 10;

// Uses the V8 Stacktrace API to get the function location
// information - https://v8.dev/docs/stack-trace-api#customizing-stack-traces
export function wrapFnWithLocation<A extends unknown[], R>(
  func: (location: Location, ...args: A) => R
): (...args: A) => R {
  return (...args) => {
    const _prepareStackTrace = Error.prepareStackTrace;
    Error.prepareStackTrace = (_, stackFrames) => {
      // Deafult CallSite would not map to the original transpiled source
      // correctly, So we use source-map-support to map the CallSite to the
      // original source from our cached source map
      const frame: NodeJS.CallSite = sourceMapSupport.wrapCallSite(
        stackFrames[1]
      );
      return {
        file: frame.getFileName(),
        line: frame.getLineNumber(),
        column: frame.getColumnNumber(),
      };
    };
    Error.stackTraceLimit = 2;
    const obj: { stack: Location } = {} as any;
    Error.captureStackTrace(obj);
    const location = obj.stack;
    Error.stackTraceLimit = dstackTraceLimit;
    Error.prepareStackTrace = _prepareStackTrace;
    return func(location, ...args);
  };
}

// Safely parse ND JSON (Newline delimited JSON) chunks
export function safeNDJSONParse(data: string | string[]) {
  // data may not be at proper newline boundaries, so we make sure everything is split
  // on proper newlines
  const chunks = Array.isArray(data) ? data : [data];
  const lines = chunks.join('\n').split(/\r?\n/);
  return lines
    .filter(l => l.match(/\S/)) // remove blank lines
    .map(line => {
      try {
        return JSON.parse(line);
      } catch (e) {
        throw `Error ${e} could not parse data '${line}'`;
      }
    });
}

// Console helpers
export function write(message: string, live?: boolean) {
  process.stderr.write(message + (live ? '\r' : '\n'));
}

export function progress(message: string) {
  write(cyan(bold(`${symbols.progress} ${message}`)));
}

export async function liveProgress(promise: Promise<any>, message: string) {
  const start = now();
  const interval = setInterval(() => {
    apiProgress(`${message} (${Math.trunc(now() - start)}ms)`, true);
  }, 500);
  promise.finally(() => clearInterval(interval));
  const result = await promise;
  apiProgress(`${message} (${Math.trunc(now() - start)}ms)`);
  return result;
}

export function apiProgress(message: string, live = false) {
  write(grey(`> ${message}`), live);
}

export function error(message: string) {
  write(red(message));
}

export function done(message: string) {
  write(bold(green(`${symbols['succeeded']} ${message}`)));
}

export function warn(message: string) {
  write(bold(yellow(`${symbols['warning']} ${message}`)));
}

export function removeTrailingSlash(url = '') {
  return url.replace(/\/+$/, '');
}

export function getMonitorManagementURL(url: string) {
  return removeTrailingSlash(url) + '/app/synthetics/monitors';
}

export function getProjectApiKeyURL(url: string) {
  return removeTrailingSlash(url) + '/app/synthetics/settings/api-keys';
}

/**
 * Matches tests based on the provided args. Proitize tags over match
 * - tags pattern that matches only tags
 * - match pattern that matches both name and tags
 */
export function isMatch(
  tags: Array<string>,
  name: string,
  tagsPattern?: Array<string>,
  matchPattern?: string
) {
  if (tagsPattern) {
    return tagsMatch(tags, tagsPattern);
  }
  if (matchPattern) {
    return (
      micromatch.isMatch(name, matchPattern) || tagsMatch(tags, matchPattern)
    );
  }
  return true;
}

export function tagsMatch(tags, pattern) {
  const matchess = micromatch(tags || ['*'], pattern);
  return matchess.length > 0;
}
