// See: ./errorScenarios.md for details about error messages and stack traces

// NOTE: If you modify the logic relating to this file, ensure the
// UI for error code frames works as expected with the binary. This includes each
// browser, as well as e2e and CT testing types. Stack patterns differ in Chrome
// between the binary and dev mode, so Cy in Cy tests cannot catch them proactively.

// Various stack patterns are saved as scenario fixtures in ./driver/test
// to prevent regressions.

import _ from 'lodash'
import path from 'path'
import errorStackParser from 'error-stack-parser'
import { codeFrameColumns } from '@babel/code-frame'

import $utils from './utils'
import $sourceMapUtils from './source_map_utils'
import { toPosix } from './util/to_posix'
// Intentionally deep-importing from @packages/errors so as to not bundle the entire @packages/errors in the client unnecessarily
import { getStackLines, replacedStack, stackWithoutMessage, splitStack, unsplitStack, stackLineRegex } from '@packages/errors/src/stackUtils'

const whitespaceRegex = /^(\s*)*/
const percentNotEncodedRegex = /%(?![0-9A-F][0-9A-F])/g
const webkitStackLineRegex = /(.*)@(.*)(\n?)/g

const STACK_REPLACEMENT_MARKER = '__stackReplacementMarker'

const hasCrossFrameStacks = (specWindow) => {
  // get rid of the top lines since they naturally have different line:column
  const normalize = (stack) => {
    return stack.replace(/^.*\n/, '')
  }

  const topStack = normalize((new Error()).stack)
  const specStack = normalize((new specWindow.Error()).stack)

  return topStack === specStack
}

const stackWithContentAppended = (err, stack: string | undefined) => {
  const usableStack = stack ?? ''
  const appendToStack = err.appendToStack

  if (!appendToStack || !appendToStack.content) return usableStack

  delete err.appendToStack

  // if the content is a stack trace, which is should be, then normalize the
  // indentation, then indent it a little further than the rest of the stack
  const normalizedContent = normalizeStackIndentation(appendToStack.content)
  const content = $utils.indent(normalizedContent, 2)

  return `${usableStack}\n\n${appendToStack.title}:\n${content}`
}

const stackWithLinesRemoved = (stack, cb) => {
  const [messageLines, stackLines] = splitStack(stack)
  const remainingStackLines = cb(stackLines)

  return unsplitStack(messageLines, remainingStackLines)
}

const stackTrimmedToTestInvocation = (stack: string, specWindow) => {
  const modifiedStack = stackWithLinesRemoved(stack, (lines: string[]) => {
    // Guard against Cypress being undefined/null (can happen when users quickly reload tests)
    if (!specWindow?.Cypress) {
      return lines
    }

    const originalLines = lines
    let processedLines: string[]

    if (specWindow.Cypress.isBrowser({ family: 'chromium' })) {
      // The actual test invocation line starts with either 'at eval' or 'at Suite.eval',
      // so remove all lines until we reach the test invocation line
      processedLines = _.dropWhile(lines, (line) => {
        return !(
          line.trim().startsWith('at eval ') ||
          line.trim().startsWith('at Suite.eval ')
        )
      })
    } else if (specWindow.Cypress.isBrowser({ family: 'firefox' })) {
      const isTestInvocationLine = (line: string) => {
        const splitAtAt = line.split('@')

        // firefox stacks traces look like:
        // functionName@http://localhost:3000/__cypress/tests?p=cypress/support/e2e.js:444:14
        // @http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:43:3
        // @http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:45:12
        // evalScripts/<@cypress:///../driver/src/cypress/script_utils.ts:38:23
        //
        // the actual invocation details will be at the first line with no function name
        return splitAtAt.length > 1 && splitAtAt[0].trim().length === 0
      }

      processedLines = _.dropWhile(lines, (line) => {
        return !isTestInvocationLine(line)
      })
    } else {
      processedLines = lines
    }

    // if we removed all the lines then something went wrong with parsing. Return the original lines instead
    if (processedLines.length === 0) {
      return originalLines
    }

    return processedLines
  })

  return modifiedStack
}

const stackWithLinesDroppedFromMarker = (stack, marker, includeLast = false) => {
  return stackWithLinesRemoved(stack, (lines) => {
    // drop lines above the marker
    const withAboveMarkerRemoved = _.dropWhile(lines, (line: any) => {
      return !_.includes(line, marker)
    })

    return includeLast ? withAboveMarkerRemoved : withAboveMarkerRemoved.slice(1)
  })
}

const stackWithReplacementMarkerLineRemoved = (stack) => {
  return stackWithLinesRemoved(stack, (lines) => {
    return _.reject(lines, (line) => _.includes(line, STACK_REPLACEMENT_MARKER))
  })
}

const stackPriorToReplacementMarker = (stack) => {
  return _.chain(stack).split('\n')
  .takeWhile((line) => !line.includes(STACK_REPLACEMENT_MARKER))
  .join('\n')
  .value()
}

export type StackAndCodeFrameIndex = {
  stack: string
  index?: number
}

const stackWithUserInvocationStackSpliced = (err, userInvocationStack): StackAndCodeFrameIndex => {
  const stack = _.trim(err.stack, '\n') // trim newlines from end
  const [messageLines, stackLines] = splitStack(stack)
  const userInvocationStackWithoutMessage = stackWithoutMessage(userInvocationStack)

  let commandCallIndex = _.findIndex(stackLines, (line) => {
    return line.includes(STACK_REPLACEMENT_MARKER)
  })

  if (commandCallIndex < 0) {
    commandCallIndex = stackLines.length
  }

  stackLines.splice(commandCallIndex, stackLines.length, 'From Your Spec Code:')
  stackLines.push(userInvocationStackWithoutMessage)

  // the commandCallIndex is based off the stack without the message,
  // but the full stack includes the message + 'From Your Spec Code:',
  // so we adjust it
  return {
    stack: unsplitStack(messageLines, stackLines),
    index: commandCallIndex + messageLines.length + 1,
  }
}

export type InvocationDetails = {
  function?: string
  fileUrl?: string
  absoluteFile?: string
  column?: number
  line?: number
  originalFile?: string
  relativeFile?: string
  stack: string
}

// used to determine codeframes for hook/test/etc definitions rather than command invocations
const getInvocationDetails = (specWindow, sourceMapProjectRoot: string, type?: 'test'): InvocationDetails | undefined => {
  if (specWindow.Error) {
    let stack = (new specWindow.Error()).stack

    // note: specWindow.Cypress can be undefined or null
    // if the user quickly reloads the tests multiple times

    // firefox and chrome throw stacks that include lines from cypress
    // So we drop the lines until we get to the spec stackframe (includes __cypress)
    if (specWindow.Cypress) {
      // The stack includes frames internal to cypress, after the spec stackframe. In order
      // to determine the invocation details, the stack needs to be parsed and trimmed.

      // in Chrome and Firefox in E2E contexts, the spec stackframe includes the pattern, '__cypress/tests'.
      if (stack.includes('__cypress/tests')) {
        stack = stackWithLinesDroppedFromMarker(stack, '__cypress/tests', true)
      } else {
        // CT error contexts include the `__cypress` marker but not the `/tests` portion
        stack = stackWithLinesDroppedFromMarker(stack, '__cypress', true)
      }

      // if the hook is the test, and it's an e2e test, we will try to remove the lines that are not the actual invocation of the test
      if (type === 'test' && specWindow.Cypress.testingType === 'e2e') {
        stack = stackTrimmedToTestInvocation(stack, specWindow)
      }
    }

    const details: Omit<InvocationDetails, 'stack'> = getSourceDetailsForFirstLine(stack, sourceMapProjectRoot) || {}

    ;(details as any).stack = stack

    return details as (InvocationDetails & { stack: any })
  }

  return
}

const getLanguageFromExtension = (filePath) => {
  return (path.extname(filePath) || '').toLowerCase().replace('.', '') || null
}

const getCodeFrameFromSource = (sourceCode, { line, column: originalColumn, relativeFile, absoluteFile }) => {
  if (!sourceCode) return

  // stack columns are 0-based but code frames and IDEs start columns at 1.
  // add 1 so the code frame and "open in IDE" point to the right line
  const column = originalColumn + 1
  const frame = codeFrameColumns(sourceCode, { start: { line, column } })

  if (!frame) return

  return {
    line,
    column,
    originalFile: relativeFile,
    relativeFile: getRelativePathFromRoot(relativeFile, absoluteFile),
    absoluteFile,
    frame,
    language: getLanguageFromExtension(relativeFile),
  }
}

const getRelativePathFromRoot = (relativeFile: string, absoluteFile?: string) => {
  // at this point relativeFile is relative to the cypress config
  // we need it to be relative to the repo root, which is different for monorepos
  const repoRoot = Cypress.config('repoRoot')
  const posixAbsoluteFile = absoluteFile ? toPosix(absoluteFile) : ''

  if (posixAbsoluteFile?.startsWith(`${repoRoot}/`)) {
    return posixAbsoluteFile.replace(`${repoRoot}/`, '')
  }

  return relativeFile
}

const captureUserInvocationStack = (ErrorConstructor: SpecWindow['Error'], userInvocationStack?: string | false) => {
  if (!userInvocationStack) {
    const newErr = new ErrorConstructor('userInvocationStack')

    userInvocationStack = newErr.stack

    // if browser natively supports Error.captureStackTrace, use it (chrome) (must be bound)
    // otherwise use our polyfill on top.Error
    const captureStackTrace: ErrorConstructor['captureStackTrace'] = ErrorConstructor.captureStackTrace ? ErrorConstructor.captureStackTrace.bind(ErrorConstructor) : Error.captureStackTrace

    captureStackTrace(newErr, captureUserInvocationStack)

    // On Chrome 99+, captureStackTrace strips away the whole stack,
    // leaving nothing beyond the error message. If we get back a single line
    // (just the error message with no stack trace), then use the original value
    // instead of the trimmed one.
    if (newErr.stack!.match('\n')) {
      userInvocationStack = newErr.stack
    }
  }

  userInvocationStack = normalizedUserInvocationStack(userInvocationStack)

  return userInvocationStack
}

const getCodeFrameStackLine = (err, stackIndex) => {
  // if a specific index is not specified, use the first line with a file in it
  if (stackIndex == null) return _.find(err.parsedStack, (line) => !!line.fileUrl)

  return err.parsedStack[stackIndex]
}

const getCodeFrame = (err, stackIndex) => {
  if (err.codeFrame) return err.codeFrame

  const stackLine = getCodeFrameStackLine(err, stackIndex)

  if (!stackLine) return

  const { fileUrl, originalFile } = stackLine

  return getCodeFrameFromSource($sourceMapUtils.getSourceContents(fileUrl, originalFile), stackLine)
}

const getWhitespace = (line) => {
  if (!line) return ''

  const [, whitespace] = line.match(whitespaceRegex) || []

  return whitespace || ''
}

const decodeSpecialChars = (filePath) => {
  // the source map will encode certain characters like spaces and emojis
  // but characters like &%#^% are not encoded
  // because % is not encoded we must encode it manually before trying to decode
  // or else decodeURIComponent will throw an error
  //
  // however if a filename has something like %20 in it we have no way of telling
  // if that's the actual filename or an encoded space so we'll assume that its encoded
  // since that's far more likely and to fix this issue
  // we would have to patch the source-map library which likely isn't worth it

  if (filePath) {
    return decodeURIComponent(filePath.replace(percentNotEncodedRegex, '%25'))
  }

  return filePath
}

const getSourceDetails = (generatedDetails) => {
  const sourceDetails = $sourceMapUtils.getSourcePosition(generatedDetails.file, generatedDetails)

  if (!sourceDetails) return generatedDetails

  const { line, column, file } = sourceDetails
  let fn = generatedDetails.function

  return {
    line,
    column,
    file: decodeSpecialChars(file),
    function: fn,
  }
}

const functionExtrasRegex = /(\/<|<\/<)$/

const cleanFunctionName = (functionName) => {
  if (!_.isString(functionName)) return '<unknown>'

  return _.trim(functionName.replace(functionExtrasRegex, ''))
}

const parseLine = (line) => {
  if (!stackLineRegex.test(line)) return

  const parsed = errorStackParser.parse({ stack: line } as any)[0]

  if (!parsed) return

  return {
    line: parsed.lineNumber,
    column: parsed.columnNumber,
    file: parsed.fileName,
    function: cleanFunctionName(parsed.functionName),
  }
}

interface MessageLineDetail {
  message: any
  whitespace: any
}

interface StackLineDetail {
  function: any
  fileUrl: any
  originalFile: any
  relativeFile: any
  absoluteFile: any
  line: any
  column: number
  whitespace: any
}

const getSourceDetailsForLine = (projectRoot, line): MessageLineDetail | StackLineDetail => {
  const whitespace = getWhitespace(line)
  const generatedDetails = parseLine(line)

  // if it couldn't be parsed, it's a message line
  if (!generatedDetails) {
    return {
      message: line.replace(whitespace, ''), // strip leading whitespace
      whitespace,
    }
  }

  const sourceDetails = getSourceDetails(generatedDetails)

  const originalFile = sourceDetails.file

  let relativeFile = $utils.stripCustomProtocol(originalFile)

  if (relativeFile) {
    relativeFile = path.normalize(relativeFile)

    if (projectRoot && projectRoot !== '/' && relativeFile.includes(projectRoot)) {
      relativeFile = relativeFile.replace(projectRoot, '').substring(1)
    }
  }

  let absoluteFile

  // WebKit stacks may include an `<unknown>` or `[native code]` location that is not navigable.
  // We ensure that the absolute path is not set in this case.
  if (relativeFile &&
    projectRoot && (
    !Cypress.isBrowser('webkit') || (relativeFile !== '<unknown>' && relativeFile !== '[native code]')
  )) {
    absoluteFile = path.resolve(projectRoot, relativeFile)

    // rollup-plugin-node-builtins/src/es6/path.js only support POSIX, we have
    // to remove the / so the openFileInIDE can find the correct path
    if (Cypress.config('platform') === 'win32' && absoluteFile.startsWith('/')) {
      absoluteFile = absoluteFile.substring(1)
    }
  }

  return {
    function: sourceDetails.function,
    fileUrl: generatedDetails.file,
    originalFile,
    relativeFile,
    absoluteFile,
    line: sourceDetails.line,
    column: sourceDetails.column,
    whitespace,
  }
}

const getSourceDetailsForFirstLine = (stack, projectRoot) => {
  const line = getStackLines(stack)[0]

  if (!line) return

  return getSourceDetailsForLine(projectRoot, line) as StackLineDetail
}

const reconstructStack = (parsedStack) => {
  return _.map(parsedStack, (parsedLine) => {
    if (parsedLine.message != null) {
      return `${parsedLine.whitespace}${parsedLine.message}`
    }

    const { whitespace, originalFile, function: fn, line, column } = parsedLine

    const lineAndColumn = (Number.isInteger(line) || Number.isInteger(column)) ? `:${line}:${column}` : ''

    return `${whitespace}at ${fn} (${originalFile || '<unknown>'}${lineAndColumn})`
  }).join('\n').trimEnd()
}

const getSourceStack = (stack, projectRoot?) => {
  if (!_.isString(stack)) return {}

  const getSourceDetailsWithStackUtil = _.partial(getSourceDetailsForLine, projectRoot)
  const parsed = _.map(stack.split('\n'), getSourceDetailsWithStackUtil)

  return {
    parsed,
    sourceMapped: reconstructStack(parsed),
  }
}

const normalizeStackIndentation = (stack) => {
  const [messageLines, stackLines] = splitStack(stack)
  const normalizedStackLines = _.map(stackLines, (line) => {
    if (stackLineRegex.test(line)) {
      // stack lines get indented 4 spaces
      return line.replace(whitespaceRegex, '    ')
    }

    // message lines don't get indented at all
    return line.replace(whitespaceRegex, '')
  })

  return unsplitStack(messageLines, normalizedStackLines)
}

const normalizedStack = (err) => {
  // Firefox errors do not include the name/message in the stack, whereas
  // Chromium-based errors do, so we normalize them so that the stack
  // always includes the name/message
  const errString = err.toString()
  let errStack = err.stack || ''

  if (Cypress.isBrowser('webkit')) {
    // WebKit will not determine the proper stack trace for an error, with stack entries
    // missing function names, call locations, or both. This is due to a number of documented
    // issues with WebKit:
    // https://bugs.webkit.org/show_bug.cgi?id=86493
    // https://bugs.webkit.org/show_bug.cgi?id=243668
    // https://bugs.webkit.org/show_bug.cgi?id=174380
    //
    // We update these stack entries with placeholder names/locations to more closely align
    // the output with other browsers, minimizing the visual impact to the stack traces we render
    // within the command log and console and ensuring that the stacks can be identified within
    // and parsed out of test snapshots that include them.
    errStack = errStack.replaceAll(webkitStackLineRegex, (match, ...parts: string[]) => {
      // We patch WebKit's Error within the AUT as CyWebKitError, causing it to
      // be presented within the stack. If we detect it within the stack, we remove it.
      if (parts[0] === '__CyWebKitError') {
        return ''
      }

      return [
        parts[0] || '<unknown>',
        '@',
        parts[1] || '<unknown>',
        parts[2],
      ].join('')
    })
  }

  // the stack has already been normalized and normalizing the indentation
  // again could mess up the whitespace
  if (errStack.includes(errString)) return err.stack

  const firstErrLine = errString.slice(0, errString.indexOf('\n'))
  const firstStackLine = errStack.slice(0, errStack.indexOf('\n'))
  const stackIncludesMsg = firstStackLine.includes(firstErrLine)

  if (!stackIncludesMsg) {
    return `${errString}\n${errStack}`
  }

  return normalizeStackIndentation(errStack)
}

const normalizedUserInvocationStack = (userInvocationStack) => {
  // Firefox user invocation stack includes a line at the top that looks like
  // addCommand/cy[name]@cypress:///../driver/src/cypress/cy.js:936:77 or
  // add/$Chainer.prototype[key] (cypress:///../driver/src/cypress/chainer.js:30:128)
  // whereas Chromium browsers have the user's line first
  const stackLines = getStackLines(userInvocationStack)
  const nonCypressStackLines = _.reject(stackLines, (line) => {
    // WARNING: STACK TRACE WILL BE DIFFERENT IN DEVELOPMENT vs PRODUCTION
    // stacks in development builds look like:
    //     at cypressErr (cypress:///../driver/src/cypress/error_utils.js:259:17)
    // stacks in prod builds look like:
    //     at cypressErr (http://localhost:3500/isolated-runner/cypress_runner.js:173123:17)
    return line.includes('cy[name]')
    || line.includes('Chainer.prototype[key]')
    || line.includes('cy.<computed>')
    || line.includes('$Chainer.<computed>')
    // Note that these are hard coded for now, because they are happening in the prompt command
    // for some reason. Long term, we should make this more dynamic if necessary.
    || line.includes('$Cy.prompt')
    || line.includes('$Chainer.prompt')
    || line.includes('CyPrompt.prototype.prompt')
    // Remove cross origin stack lines
    || line.includes('SpecBridgeCommunicator')
    || (line.includes('at invokeOriginFn') && !line.includes('at eval'))
  }).join('\n')

  return normalizeStackIndentation(nonCypressStackLines)
}

const mergeCrossOriginUserInvocationStack = (userInvocationStack: string, originUserInvocationStack: string) => {
  // The method here is:
  // 1. Grab the line/column from the first line of the origin user invocation stack
  // 2. Add it to and replace the line/column of the first line of the user invocation stack
  //
  // Note: If any of our assumptions about the stack format are violated, this will just
  // return the original user invocation stack to avoid breaking the stack trace.
  const originStackLines = getStackLines(originUserInvocationStack)
  const userStackLines = getStackLines(userInvocationStack)

  if (userStackLines.length === 0 || originStackLines.length === 0) return userInvocationStack

  // Note: chrome adds a parenthesis to the end of the stack line and firefox does not
  const userStackMatch = userStackLines[0].match(/(\d+):(\d+)\)?$/)

  if (!userStackMatch) return userInvocationStack

  const userLine = Number(userStackMatch[1])
  const userColumn = Number(userStackMatch[2])

  // Note: chrome adds a parenthesis to the end of the stack line and firefox does not
  const originStackMatch = originStackLines[0].match(/(\d+):(\d+)\)?$/)

  if (!originStackMatch) return userInvocationStack

  const originLine = Number(originStackMatch[1])

  // Note: chrome adds a parenthesis to the end of the stack line and firefox does not so we need to keep whatever we find
  const newUserStackLine = `${originStackLines[0].replace(/(\d+:\d+)(\)?)$/, `${originLine + userLine - 1}:${userColumn}$2`)}`

  return userInvocationStack.replace(userStackLines[0], newUserStackLine)
}

export default {
  replacedStack,
  getCodeFrame,
  getCodeFrameFromSource,
  getRelativePathFromRoot,
  getSourceStack,
  getStackLines,
  getSourceDetailsForFirstLine,
  hasCrossFrameStacks,
  normalizedStack,
  normalizedUserInvocationStack,
  stackWithContentAppended,
  stackWithLinesDroppedFromMarker,
  stackWithoutMessage,
  stackWithReplacementMarkerLineRemoved,
  stackPriorToReplacementMarker,
  stackWithUserInvocationStackSpliced,
  captureUserInvocationStack,
  getInvocationDetails,
  mergeCrossOriginUserInvocationStack,
}
