// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

/* eslint-env node */

import path from 'node:path';

import lodashFp from 'lodash/fp.js';
import lodash from 'lodash';

import type { ExtendedStorageID } from '../types/StorageService.d.ts';
import type { ConversationModel } from '../models/conversations.preload.js';

export const APP_ROOT_PATH = path.join(__dirname, '..', '..');
const { escapeRegExp, isString, isRegExp } = lodash;

const { compose } = lodashFp;

const PHONE_NUMBER_PATTERN = /\+\d{7,12}(\d{3})/g;
// The additional 0 in [0-8] and [089AB] are to include MY_STORY_ID
const UUID_OR_STORY_ID_PATTERN =
  /[0-9A-F]{8}-[0-9A-F]{4}-[0-8][0-9A-F]{3}-[089AB][0-9A-F]{3}-[0-9A-F]{9}([0-9A-F]{3})/gi;
const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g;
const GROUP_V2_ID_PATTERN = /(groupv2\()([^=)]+)(=?=?\))/g;
const CALL_LINK_ROOM_ID_PATTERN = /[0-9A-F]{61}([0-9A-F]{3})/gi;
const CALL_LINK_ROOT_KEY_PATTERN =
  /([A-Z]{4})-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}/gi;
const ATTACHMENT_URL_KEY_PATTERN = /(attachment:\/\/[^\s]+key=)([^\s]+)/gi;
const REDACTION_PLACEHOLDER = '[REDACTED]';

// Since card number pattern has 11-16 decimal digits we cannot use lookback
// assertion to prevent all-decimal stack traces from matching. For this reason
// we include both stack trace and number in the same regex and detect stack
// traces below when doing the replacement.
const CARD_NUMBER_OR_STACK_TRACE_PATTERN =
  /0x[0-9a-f]{16}|\d\d(\d[-]?){11,16}\d/g;
const SAFE_STACK_TRACE_PATTERN = /^0x[0-9a-f]{16}$/;

export type RedactFunction = (value: string) => string;

export function redactStorageID(
  storageID: string,
  version?: number | null,
  conversation?: ConversationModel
): string {
  const convoId = conversation ? ` ${conversation?.idForLogging()}` : '';
  return `${version ?? '?'}:${storageID.substring(0, 3)}${convoId}`;
}

export function redactExtendedStorageID({
  storageID,
  storageVersion,
}: ExtendedStorageID): string {
  return redactStorageID(storageID, storageVersion);
}

export const _redactPath = (filePath: string): RedactFunction => {
  if (!isString(filePath)) {
    throw new TypeError("'filePath' must be a string");
  }

  const filePathPattern = _pathToRegExp(filePath);

  return (text: string): string => {
    if (!isString(text)) {
      throw new TypeError("'text' must be a string");
    }

    if (!isRegExp(filePathPattern)) {
      return text;
    }

    return text.replace(filePathPattern, REDACTION_PLACEHOLDER);
  };
};

export const _pathToRegExp = (filePath: string): RegExp | undefined => {
  try {
    return new RegExp(
      // Any possible prefix that we want to include
      `(${escapeRegExp('file:///')})?${
        // The rest of the file path
        filePath
          // Split by system path seperator ("/" or "\\")
          // (split by both for tests)
          .split(/\/|\\/)
          // Escape all special characters in each part
          .map(part => {
            // This segment may need to be URI encoded
            const urlEncodedPart = encodeURI(part);
            // If its the same, then we don't need to worry about it
            if (urlEncodedPart === part) {
              return escapeRegExp(part);
            }
            // Otherwise, we need to test against both
            return `(${escapeRegExp(part)}|${escapeRegExp(urlEncodedPart)})`;
          })
          // Join the parts back together with any possible path seperator
          .join(
            `(${[
              // Posix (Linux, macOS, etc.)
              path.posix.sep,
              // Windows
              path.win32.sep,
              // Windows (URI encoded)
              encodeURI(path.win32.sep),
            ]
              // Escape the parts for use in a RegExp (e.g. "/" -> "\/")
              .map(sep => escapeRegExp(sep))
              // In case separators are repeated in the path (e.g. "\\\\")
              .map(sep => `${sep}+`)
              // Join all the possible separators together
              .join('|')})`
          )
      }`,
      'g'
    );
  } catch (error) {
    return undefined;
  }
};

// Public API

// As part of supporting credit card donations, we also want to include an extra safety
// layer to prevent CC #s from being logged even if a bug occurs in the payment interface.
export const redactCardNumbers = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(CARD_NUMBER_OR_STACK_TRACE_PATTERN, match => {
    if (SAFE_STACK_TRACE_PATTERN.test(match)) {
      return match;
    }
    return '[REDACTED]';
  });
};

export const redactPhoneNumbers = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(PHONE_NUMBER_PATTERN, `+${REDACTION_PLACEHOLDER}$1`);
};

export const redactUuids = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(UUID_OR_STORY_ID_PATTERN, `${REDACTION_PLACEHOLDER}$1`);
};

export const redactGroupIds = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text
    .replace(
      GROUP_ID_PATTERN,
      (_, before, id, after) =>
        `${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(
          -3
        )}${after}`
    )
    .replace(
      GROUP_V2_ID_PATTERN,
      (_, before, id, after) =>
        `${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(
          -3
        )}${after}`
    );
};

export const redactCallLinkRoomIds = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(CALL_LINK_ROOM_ID_PATTERN, `${REDACTION_PLACEHOLDER}$1`);
};

export const redactCallLinkRootKeys = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(CALL_LINK_ROOT_KEY_PATTERN, `${REDACTION_PLACEHOLDER}$1`);
};

export const redactAttachmentUrlKeys = (text: string): string => {
  if (!isString(text)) {
    throw new TypeError("'text' must be a string");
  }

  return text.replace(ATTACHMENT_URL_KEY_PATTERN, `$1${REDACTION_PLACEHOLDER}`);
};

export const redactCdnKey = (cdnKey: string): string => {
  return `${REDACTION_PLACEHOLDER}${cdnKey.slice(-3)}`;
};

export const redactGenericText = (text: string): string => {
  return `${REDACTION_PLACEHOLDER}${text.slice(-3)}`;
};

export const redactAttachmentUrl = (urlString: string): string => {
  try {
    const url = new URL(urlString);
    url.search = '';
    return url.toString();
  } catch {
    return REDACTION_PLACEHOLDER;
  }
};

const createRedactSensitivePaths = (
  paths: ReadonlyArray<string>
): RedactFunction => {
  return compose(paths.map(filePath => _redactPath(filePath)));
};

const sensitivePaths: Array<string> = [];

let redactSensitivePaths: RedactFunction = (text: string) => text;

export const addSensitivePath = (filePath: string): void => {
  sensitivePaths.push(filePath);
  redactSensitivePaths = createRedactSensitivePaths(sensitivePaths);
};

addSensitivePath(APP_ROOT_PATH);

export const redactAll: RedactFunction = text => {
  let result = text;

  // Do these first, to retain last characters
  result = redactAttachmentUrlKeys(result);
  result = redactCallLinkRoomIds(result);
  result = redactCallLinkRootKeys(result);
  result = redactGroupIds(result);
  result = redactPhoneNumbers(result);
  result = redactUuids(result);

  // These leave nothing
  result = redactCardNumbers(result);
  result = redactSensitivePaths(result);

  return result;
};

const removeNewlines: RedactFunction = text => text.replace(/\r?\n|\r/g, '');
