import type { OriginalMapping } from '@jridgewell/trace-mapping'
import type { ParsedStack, TestError } from './types'
import { originalPositionFor } from '@jridgewell/trace-mapping'
import { resolve } from 'pathe'
import { isPrimitive, notNullish } from './helpers'

export interface StackTraceParserOptions {
  ignoreStackEntries?: (RegExp | string)[]
  getSourceMap?: (file: string) => unknown
  getUrlId?: (id: string) => string
  frameFilter?: (error: TestError, frame: ParsedStack) => boolean | void
}

const CHROME_IE_STACK_REGEXP = /^\s*at .*(?:\S:\d+|\(native\))/m
const SAFARI_NATIVE_CODE_REGEXP = /^(?:eval@)?(?:\[native code\])?$/

const stackIgnorePatterns: (string | RegExp)[] = [
  'node:internal',
  /\/packages\/\w+\/dist\//,
  /\/@vitest\/\w+\/dist\//,
  '/vitest/dist/',
  '/vitest/src/',
  '/packages/expect/src/',
  '/packages/snapshot/src/',
  '/node_modules/chai/',
  '/node_modules/tinyspy/',
  '/vite/dist/node/module-runner',
  '/rolldown-vite/dist/node/module-runner',
  // browser related deps
  '/deps/chunk-',
  '/deps/@vitest',
  '/deps/loupe',
  '/deps/chai',
  '/browser-playwright/dist/locators.js',
  '/browser-webdriverio/dist/locators.js',
  '/browser-preview/dist/locators.js',
  /node:\w+/,
  /__vitest_test__/,
  /__vitest_browser__/,
  '/@id/__x00__vitest/browser',
  /\/deps\/vitest_/,
  '/deps/vitest.js',
]

export { stackIgnorePatterns as defaultStackIgnorePatterns }

const NOW_LENGTH = Date.now().toString().length
const REGEXP_VITEST = new RegExp(`vitest=\\d{${NOW_LENGTH}}`)

function extractLocation(urlLike: string) {
  // Fail-fast but return locations like "(native)"
  if (!urlLike.includes(':')) {
    return [urlLike]
  }

  const regExp = /(.+?)(?::(\d+))?(?::(\d+))?$/
  const parts = regExp.exec(urlLike.replace(/^\(|\)$/g, ''))
  if (!parts) {
    return [urlLike]
  }
  let url = parts[1]
  if (url.startsWith('async ')) {
    url = url.slice(6)
  }
  if (url.startsWith('http:') || url.startsWith('https:')) {
    const urlObj = new URL(url)
    urlObj.searchParams.delete('import')
    urlObj.searchParams.delete('browserv')
    url = urlObj.pathname + urlObj.hash + urlObj.search
  }
  if (url.startsWith('/@fs/')) {
    const isWindows = /^\/@fs\/[a-zA-Z]:\//.test(url)
    url = url.slice(isWindows ? 5 : 4)
  }
  if (url.includes('vitest=')) {
    url = url.replace(REGEXP_VITEST, '').replace(/[?&]$/, '')
  }
  return [url, parts[2] || undefined, parts[3] || undefined]
}

export function parseSingleFFOrSafariStack(raw: string): ParsedStack | null {
  let line = raw.trim()

  if (SAFARI_NATIVE_CODE_REGEXP.test(line)) {
    return null
  }

  if (line.includes(' > eval')) {
    line = line.replace(
      / line (\d+)(?: > eval line \d+)* > eval:\d+:\d+/g,
      ':$1',
    )
  }

  // Early return for lines that don't look like Firefox/Safari stack traces
  // Firefox/Safari stack traces must contain '@' and should have location info after it
  if (!line.includes('@')) {
    return null
  }

  // Find the correct @ that separates function name from location
  // For cases like '@https://@fs/path' or 'functionName@https://@fs/path'
  // we need to find the first @ that precedes a valid location (containing :)
  let atIndex = -1
  let locationPart = ''
  let functionName: string | undefined

  // Try each @ from left to right to find the one that gives us a valid location
  for (let i = 0; i < line.length; i++) {
    if (line[i] === '@') {
      const candidateLocation = line.slice(i + 1)
      // Minimum length 3 for valid location: 1 for filename + 1 for colon + 1 for line number (e.g., "a:1")
      if (candidateLocation.includes(':') && candidateLocation.length >= 3) {
        atIndex = i
        locationPart = candidateLocation
        functionName = i > 0 ? line.slice(0, i) : undefined
        break
      }
    }
  }

  // Validate we found a valid location with minimum length (filename:line format)
  if (atIndex === -1 || !locationPart.includes(':') || locationPart.length < 3) {
    return null
  }
  const [url, lineNumber, columnNumber] = extractLocation(locationPart)

  if (!url || !lineNumber || !columnNumber) {
    return null
  }

  return {
    file: url,
    method: functionName || '',
    line: Number.parseInt(lineNumber),
    column: Number.parseInt(columnNumber),
  }
}

export function parseSingleStack(raw: string): ParsedStack | null {
  const line = raw.trim()
  if (!CHROME_IE_STACK_REGEXP.test(line)) {
    return parseSingleFFOrSafariStack(line)
  }
  return parseSingleV8Stack(line)
}

// Based on https://github.com/stacktracejs/error-stack-parser
// Credit to stacktracejs
export function parseSingleV8Stack(raw: string): ParsedStack | null {
  let line = raw.trim()

  if (!CHROME_IE_STACK_REGEXP.test(line)) {
    return null
  }

  if (line.includes('(eval ')) {
    line = line
      .replace(/eval code/g, 'eval')
      .replace(/(\(eval at [^()]*)|(,.*$)/g, '')
  }

  let sanitizedLine = line
    .replace(/^\s+/, '')
    .replace(/\(eval code/g, '(')
    .replace(/^.*?\s+/, '')

  // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in
  // case it has spaces in it, as the string is split on \s+ later on
  const location = sanitizedLine.match(/ (\(.+\)$)/)

  // remove the parenthesized location from the line, if it was matched
  sanitizedLine = location
    ? sanitizedLine.replace(location[0], '')
    : sanitizedLine

  // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine
  // because this line doesn't have function name
  const [url, lineNumber, columnNumber] = extractLocation(
    location ? location[1] : sanitizedLine,
  )
  let method = (location && sanitizedLine) || ''
  let file = url && ['eval', '<anonymous>'].includes(url) ? undefined : url

  if (!file || !lineNumber || !columnNumber) {
    return null
  }

  if (method.startsWith('async ')) {
    method = method.slice(6)
  }

  if (file.startsWith('file://')) {
    file = file.slice(7)
  }

  // normalize Windows path (\ -> /)
  file = file.startsWith('node:') || file.startsWith('internal:')
    ? file
    : resolve(file)

  if (method) {
    method = method
    // vite 7+
      .replace(/\(0\s?,\s?__vite_ssr_import_\d+__.(\w+)\)/g, '$1')
    // vite <7
      .replace(/__(vite_ssr_import|vi_import)_\d+__\./g, '')
      .replace(/(Object\.)?__vite_ssr_export_default__\s?/g, '')
  }

  return {
    method,
    file,
    line: Number.parseInt(lineNumber),
    column: Number.parseInt(columnNumber),
  }
}

export function createStackString(stacks: ParsedStack[]): string {
  return stacks.map((stack) => {
    const line = `${stack.file}:${stack.line}:${stack.column}`
    if (stack.method) {
      return `    at ${stack.method}(${line})`
    }
    return `    at ${line}`
  }).join('\n')
}

export function parseStacktrace(
  stack: string,
  options: StackTraceParserOptions = {},
): ParsedStack[] {
  const { ignoreStackEntries = stackIgnorePatterns } = options
  let stacks = !CHROME_IE_STACK_REGEXP.test(stack)
    ? parseFFOrSafariStackTrace(stack)
    : parseV8Stacktrace(stack)

  // remove vi.defineHelper's internal stacks
  const helperIndex = stacks.findLastIndex(s =>
    // this covers cases such as
    //   "__VITEST_HELPER__"
    //   "__VITEST_HELPER__ [as <object method name>]"
    //   "async*__VITEST_HELPER__" (firefox)
    //   "async __VITEST_HELPER__" (webkit)
    s.method.includes('__VITEST_HELPER__'),
  )
  if (helperIndex >= 0) {
    stacks = stacks.slice(helperIndex + 1)
  }

  return stacks.map((stack) => {
    if (options.getUrlId) {
      stack.file = options.getUrlId(stack.file)
    }

    const map = options.getSourceMap?.(stack.file) as
      | SourceMapLike
      | null
      | undefined
    if (!map || typeof map !== 'object' || !map.version) {
      return shouldFilter(ignoreStackEntries, stack.file) ? null : stack
    }

    const traceMap = new DecodedMap(map, stack.file)
    if (stack.line <= 0 || stack.column <= 0) {
      return stack
    }
    const position = getOriginalPosition(traceMap, {
      line: stack.line,
      // stacktrace's column is 1-indexed, but sourcemap's one is 0-indexed
      column: stack.column - 1,
    })
    if (!position) {
      return stack
    }

    const { line, column, source, name } = position
    let file = source || stack.file
    if (/\/\w:\//.test(file)) {
      file = file.slice(1)
    }

    if (shouldFilter(ignoreStackEntries, file)) {
      return null
    }

    if (line != null && column != null) {
      return {
        line,
        column: column + 1,
        file,
        method: name || stack.method,
      }
    }
    return stack
  }).filter(s => s != null)
}

function shouldFilter(ignoreStackEntries: (string | RegExp)[], file: string): boolean {
  return ignoreStackEntries.some(p => file.match(p))
}

function parseFFOrSafariStackTrace(stack: string): ParsedStack[] {
  return stack
    .split('\n')
    .map(line => parseSingleFFOrSafariStack(line))
    .filter(notNullish)
}

function parseV8Stacktrace(stack: string): ParsedStack[] {
  return stack
    .split('\n')
    .map(line => parseSingleV8Stack(line))
    .filter(notNullish)
}

export function parseErrorStacktrace(
  e: TestError | Error,
  options: StackTraceParserOptions = {},
): ParsedStack[] {
  if (!e || isPrimitive(e)) {
    return []
  }

  if ('stacks' in e && e.stacks) {
    return e.stacks
  }

  const stackStr = e.stack || ''
  // if "stack" property was overwritten at runtime to be something else,
  // ignore the value because we don't know how to process it
  let stackFrames = typeof stackStr === 'string'
    ? parseStacktrace(stackStr, options)
    : []

  if (!stackFrames.length) {
    const e_ = e as any
    if (e_.fileName != null && e_.lineNumber != null && e_.columnNumber != null) {
      stackFrames = parseStacktrace(`${e_.fileName}:${e_.lineNumber}:${e_.columnNumber}`, options)
    }
    if (e_.sourceURL != null && e_.line != null && e_._column != null) {
      stackFrames = parseStacktrace(`${e_.sourceURL}:${e_.line}:${e_.column}`, options)
    }
  }

  if (options.frameFilter) {
    stackFrames = stackFrames.filter(
      f => options.frameFilter!(e as TestError, f) !== false,
    )
  }

  ;(e as TestError).stacks = stackFrames
  return stackFrames
}

interface SourceMapLike {
  version: number
  mappings?: string
  names?: string[]
  sources?: string[]
  sourcesContent?: string[]
  sourceRoot?: string
}

interface Needle {
  line: number
  column: number
}

export class DecodedMap {
  _encoded: string
  _decoded: undefined | number[][][]
  _decodedMemo: Stats
  url: string
  version: number
  names: string[] = []
  resolvedSources: string[]

  constructor(
    public map: SourceMapLike,
    from: string,
  ) {
    const { mappings, names, sources } = map
    this.version = map.version
    this.names = names || []
    this._encoded = mappings || ''
    this._decodedMemo = memoizedState()
    this.url = from
    this.resolvedSources = (sources || []).map(s =>
      resolve(from, '..', s || ''),
    )
  }
}

interface Stats {
  lastKey: number
  lastNeedle: number
  lastIndex: number
}

function memoizedState(): Stats {
  return {
    lastKey: -1,
    lastNeedle: -1,
    lastIndex: -1,
  }
}

export function getOriginalPosition(
  map: DecodedMap,
  needle: Needle,
): OriginalMapping | null {
  const result = originalPositionFor(map as any, needle)
  if (result.column == null) {
    return null
  }
  return result
}
