import type { File, Suite, Task, Test } from '../runtime/runner/types'
import type { TestError } from '../types/general'
import type { TestProject } from './project'
import { promises as fs } from 'node:fs'
import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping'
import { unique } from '@vitest/utils/helpers'
import { ancestor as walkAst } from 'acorn-walk'
import { relative } from 'pathe'
import { parseAst } from 'vite'
import { validateTags } from '../runtime/runner/utils/tags'
import { createIndexLocationsMap } from '../utils/base'
import { createDebugger } from '../utils/debugger'
import { calculateSuiteHash, createFileTask as createFileTaskOriginal, createTaskName } from '../utils/tasks'
import { detectCodeBlock } from '../utils/test-helpers'

interface ParsedFile extends File {
  start: number
  end: number
}

interface ParsedTest extends Test {
  start: number
  end: number
  dynamic: boolean
}

interface ParsedSuite extends Suite {
  start: number
  end: number
  dynamic: boolean
}

export interface LocalCallDefinition {
  start: number
  end: number
  name: string
  type: 'suite' | 'test'
  mode: 'run' | 'skip' | 'only' | 'todo' | 'queued'
  task: ParsedSuite | ParsedFile | ParsedTest
  dynamic: boolean
  concurrent: boolean | undefined
  tags: string[]
}

export interface FileInformation {
  file: File
  filepath: string
  parsed: string
  map: any
  definitions: LocalCallDefinition[]
}

export interface AstCollectOptions {
  /**
   * Override the pool stored on the resulting File task. Required when
   * collecting typecheck files because the project's `config.pool` is the
   * user's runtime pool (e.g. `forks`), not the `typescript` pool that the
   * typecheck spec uses to compute its task id.
   */
  pool?: string
}

const debug = createDebugger('vitest:ast-collect-info')
const verbose = createDebugger('vitest:ast-collect-verbose')

const INTERMEDIATE_CALL_PROPERTIES = new Set([
  'each',
  'for',
  'skipIf',
  'runIf',
  'extend',
  'scoped',
  'override',
])

function isTestFunctionName(name: string) {
  return name === 'it' || name === 'test' || name.startsWith('test') || name.endsWith('Test')
}

function isVitestFunctionName(name: string) {
  return name === 'describe' || name === 'suite' || isTestFunctionName(name)
}

function astParseFile(filepath: string, code: string) {
  const ast = parseAst(code)

  if (verbose) {
    verbose(
      'Collecting',
      filepath,
      code,
    )
  }
  else {
    debug?.('Collecting', filepath)
  }
  const definitions: LocalCallDefinition[] = []
  const getName = (callee: any): string | null => {
    if (!callee) {
      return null
    }
    if (callee.type === 'Identifier') {
      return callee.name
    }
    if (callee.type === 'CallExpression') {
      return getName(callee.callee)
    }
    if (callee.type === 'TaggedTemplateExpression') {
      return getName(callee.tag)
    }
    if (callee.type === 'MemberExpression') {
      if (
        callee.object?.type === 'Identifier'
        && isVitestFunctionName(callee.object.name)
      ) {
        return callee.object?.name
      }
      if (
        // direct call as `__vite_ssr_exports_0__.test()`
        callee.object?.name?.startsWith('__vite_ssr_')
        // Vitest's module mocker uses `__vi_import_N__` for mocked/dynamic imports
        // e.g. `__vi_import_0__.it()` when vi.mock is present in the file
        || callee.object?.name?.startsWith('__vi_import_')
        // call as `__vite_ssr_exports_0__.Vitest.test`,
        // this is a special case for using Vitest namespaces popular in Effect
        || (callee.object?.object?.name?.startsWith('__vite_ssr_') && callee.object?.property?.name === 'Vitest')
      ) {
        return getName(callee.property)
      }
      // call as `__vite_ssr__.test.skip()` or `describe.concurrent.each()`
      return getName(callee.object)
    }
    // unwrap (0, ...)
    if (callee.type === 'SequenceExpression' && callee.expressions.length === 2) {
      const [e0, e1] = callee.expressions
      if (e0.type === 'Literal' && e0.value === 0) {
        return getName(e1)
      }
    }
    return null
  }

  const getProperties = (callee: any): string[] => {
    if (!callee) {
      return []
    }
    if (callee.type === 'Identifier') {
      return []
    }
    if (callee.type === 'CallExpression') {
      return getProperties(callee.callee)
    }
    if (callee.type === 'TaggedTemplateExpression') {
      return getProperties(callee.tag)
    }
    if (callee.type === 'MemberExpression') {
      const props = getProperties(callee.object)
      if (callee.property?.name) {
        props.push(callee.property.name)
      }
      return props
    }
    return []
  }

  walkAst(ast as any, {
    CallExpression(node) {
      const { callee } = node as any
      const name = getName(callee)
      if (!name) {
        return
      }
      if (!isVitestFunctionName(name)) {
        verbose?.(`Skipping ${name} (unknown call)`)
        return
      }
      const properties = getProperties(callee)
      const property = callee?.property?.name
      // intermediate calls like .each(), .for() will be picked up in the next iteration
      if (property && INTERMEDIATE_CALL_PROPERTIES.has(property)) {
        return
      }
      // skip properties on return values of calls - e.g., test('name', fn).skip()
      if (callee.type === 'MemberExpression' && callee.object?.type === 'CallExpression') {
        return
      }
      // derive mode from the full chain (handles any order like .skip.concurrent or .concurrent.skip)
      let mode: 'run' | 'skip' | 'only' | 'todo' = 'run'
      for (const prop of properties) {
        if (prop === 'skip' || prop === 'only' || prop === 'todo') {
          mode = prop
        }
        else if (prop === 'skipIf' || prop === 'runIf') {
          mode = 'skip'
        }
      }
      let concurrent = properties.includes('concurrent') || undefined

      let start: number
      const end = node.end
      // .each or (0, __vite_ssr_exports_0__.test)()
      if (
        callee.type === 'CallExpression'
        || callee.type === 'SequenceExpression'
        || callee.type === 'TaggedTemplateExpression'
      ) {
        start = callee.end
      }
      else {
        start = node.start
      }

      const messageNode = node.arguments?.[0]

      if (messageNode == null) {
        verbose?.(`Skipping node at ${node.start} because it doesn't have a name`)
        return
      }

      let message: string
      if (messageNode?.type === 'Literal' || messageNode?.type === 'TemplateLiteral') {
        message = code.slice(messageNode.start + 1, messageNode.end - 1)
      }
      else {
        message = code.slice(messageNode.start, messageNode.end)

        if (message.endsWith('.name')) {
          message = message.slice(0, -5)
        }
      }

      if (message.startsWith('0,')) {
        message = message.slice(2)
      }

      message = message
        // vite 7+
        .replace(/\(0\s?,\s?__vite_ssr_import_\d+__.(\w+)\)/g, '$1')
        // vite <7
        .replace(/__(vite_ssr_import|vi_import)_\d+__\./g, '')
        // Vitest module mocker injects these
        .replace(/__vi_import_\d+__\./g, '')

      const parentCalleeName = typeof callee?.callee === 'object' && callee?.callee.type === 'MemberExpression' && callee?.callee.property?.name
      let isDynamicEach = parentCalleeName === 'each' || parentCalleeName === 'for'
      if (!isDynamicEach && callee.type === 'TaggedTemplateExpression') {
        const property = callee.tag?.property?.name
        isDynamicEach = property === 'each' || property === 'for'
      }

      // Extract options from the second argument if it's an options object
      const tags: string[] = []
      const secondArg = node.arguments?.[1]
      if (secondArg?.type === 'ObjectExpression') {
        for (const prop of (secondArg.properties || []) as any[]) {
          if (prop.type !== 'Property' || prop.key?.type !== 'Identifier') {
            continue
          }
          const keyName = prop.key.name
          if (keyName === 'tags') {
            const tagsValue = prop.value
            if (tagsValue?.type === 'Literal' && typeof tagsValue.value === 'string') {
              tags.push(tagsValue.value)
            }
            else if (tagsValue?.type === 'ArrayExpression') {
              for (const element of tagsValue.elements || []) {
                if (element?.type === 'Literal' && typeof element.value === 'string') {
                  tags.push(element.value)
                }
              }
            }
          }
          else if (prop.value?.type === 'Literal') {
            if ((keyName === 'skip' || keyName === 'only' || keyName === 'todo') && prop.value.value === true) {
              mode = keyName
            }
            else if (keyName === 'concurrent' && typeof prop.value.value === 'boolean') {
              concurrent = prop.value.value
            }
          }
        }
      }

      debug?.('Found', name, message, `(${mode})`, tags.length ? `[${tags.join(', ')}]` : '')
      definitions.push({
        start,
        end,
        name: message,
        type: isTestFunctionName(name) ? 'test' : 'suite',
        mode,
        task: null as any,
        dynamic: isDynamicEach,
        concurrent,
        tags,
      } satisfies LocalCallDefinition)
    },
  })
  return {
    ast,
    definitions,
  }
}

export function createFailedFileTask(project: TestProject, filepath: string, error: Error, options?: AstCollectOptions): File {
  const config = project.serializedConfig
  const pool = options?.pool ?? config.pool
  const baseFile = createFileTaskOriginal(
    filepath,
    config.root,
    config.name,
    pool,
    undefined,
    { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel },
  )
  const file: ParsedFile = {
    ...baseFile,
    mode: 'run',
    start: 0,
    end: 0,
    result: {
      state: 'fail',
      errors: serializeError(project, error),
    },
  }
  file.file = file
  return file
}

function serializeError(ctx: TestProject, error: any): TestError[] {
  if ('errors' in error && 'pluginCode' in error) {
    const errors = error.errors.map((e: any) => {
      return {
        name: error.name,
        message: e.text,
        stack: e.location
          ? `${error.name}: ${e.text}\n  at ${relative(ctx.config.root, e.location.file)}:${e.location.line}:${e.location.column}`
          : '',
      }
    })
    return errors
  }
  return [
    {
      name: error.name,
      stack: error.stack,
      message: error.message,
    },
  ]
}

function createFileTask(
  project: TestProject,
  testFilepath: string,
  code: string,
  requestMap: any,
  filepath: string,
  fileTags: string[] | undefined,
  options?: AstCollectOptions,
) {
  const { definitions, ast } = astParseFile(testFilepath, code)
  const config = project.serializedConfig
  const pool = options?.pool ?? config.pool
  const baseFile = createFileTaskOriginal(
    filepath,
    config.root,
    config.name,
    pool,
    undefined,
    { typecheck: pool === 'typescript', __vitest_label__: config.mergeReportsLabel },
  )
  const file: ParsedFile = {
    ...baseFile,
    mode: 'run',
    start: ast.start,
    end: ast.end,
    tags: fileTags || [],
  }
  file.file = file
  const indexMap = createIndexLocationsMap(code)
  const map = requestMap && new TraceMap(requestMap)
  let lastSuite: ParsedSuite = file as any
  const updateLatestSuite = (index: number) => {
    while (lastSuite.suite && lastSuite.end < index) {
      lastSuite = lastSuite.suite as ParsedSuite
    }
    return lastSuite
  }
  definitions
    .sort((a, b) => a.start - b.start)
    .forEach((definition) => {
      const latestSuite = updateLatestSuite(definition.start)
      let mode = definition.mode
      if (latestSuite.mode !== 'run') {
        // inherit suite mode, if it's set
        mode = latestSuite.mode
      }
      const processedLocation = indexMap.get(definition.start)
      let location: { line: number; column: number } | undefined
      if (map && processedLocation) {
        const originalLocation = originalPositionFor(map, {
          line: processedLocation.line,
          column: processedLocation.column,
        })
        if (originalLocation.column != null) {
          verbose?.(
            `Found location for`,
            definition.type,
            definition.name,
            `${processedLocation.line}:${processedLocation.column + 1}`,
            '->',
            `${originalLocation.line}:${originalLocation.column + 1}`,
          )
          location = {
            line: originalLocation.line,
            column: originalLocation.column + 1,
          }
        }
        else {
          debug?.(
            'Cannot find original location for',
            definition.type,
            definition.name,
            `${processedLocation.column}:${processedLocation.line}`,
          )
        }
      }
      else {
        debug?.(
          'Cannot find original location for',
          definition.type,
          definition.name,
          `${definition.start}`,
        )
      }
      // Inherit tags from parent suite and merge with own tags
      const parentTags = latestSuite.tags || []
      const taskTags = unique([...parentTags, ...definition.tags])
      const concurrent = definition.concurrent ?? latestSuite.concurrent

      if (definition.type === 'suite') {
        const task: ParsedSuite = {
          type: definition.type,
          id: '',
          suite: latestSuite,
          file,
          tasks: [],
          mode,
          each: definition.dynamic,
          concurrent,
          name: definition.name,
          fullName: createTaskName([latestSuite.fullName, definition.name]),
          fullTestName: createTaskName([latestSuite.fullTestName, definition.name]),
          end: definition.end,
          start: definition.start,
          location,
          dynamic: definition.dynamic,
          meta: {},
          tags: taskTags,
        }
        definition.task = task
        latestSuite.tasks.push(task)
        lastSuite = task
        return
      }
      validateTags(config, taskTags)
      const task: ParsedTest = {
        type: definition.type,
        id: '',
        suite: latestSuite,
        file,
        each: definition.dynamic,
        concurrent,
        mode,
        context: {} as any, // not used on the server
        name: definition.name,
        fullName: createTaskName([latestSuite.fullName, definition.name]),
        fullTestName: createTaskName([latestSuite.fullTestName, definition.name]),
        end: definition.end,
        start: definition.start,
        location,
        dynamic: definition.dynamic,
        meta: {},
        timeout: 0,
        annotations: [],
        artifacts: [],
        benchmarks: [],
        tags: taskTags,
      }
      definition.task = task
      latestSuite.tasks.push(task)
    })
  calculateSuiteHash(file)
  markDynamicTests(file.tasks)
  if (!file.tasks.length) {
    file.result = {
      state: 'fail',
      errors: [
        {
          name: 'Error',
          message: `No test suite found in file ${filepath}`,
        },
      ],
    }
  }
  return { file, definitions }
}

export async function astCollectTests(
  project: TestProject,
  filepath: string,
): Promise<File> {
  const information = await astCollectFileInformation(project, filepath)
  return information.file
}

export async function astCollectFileInformation(
  project: TestProject,
  filepath: string,
  options?: AstCollectOptions,
): Promise<FileInformation> {
  const request = await transformSSR(project, filepath)
  const testFilepath = relative(project.config.root, filepath)
  if (!request) {
    debug?.('Cannot parse', testFilepath, '(vite didn\'t return anything)')
    return {
      file: createFailedFileTask(
        project,
        filepath,
        new Error(`Failed to parse ${testFilepath}. Vite didn't return anything.`),
        options,
      ),
      filepath,
      parsed: '',
      map: null,
      definitions: [],
    }
  }
  const { file, definitions } = createFileTask(
    project,
    testFilepath,
    request.code,
    request.map,
    filepath,
    request.fileTags,
    options,
  )
  return {
    file,
    filepath,
    parsed: request.code,
    map: request.map,
    definitions,
  }
}

async function transformSSR(project: TestProject, filepath: string) {
  // Read original file content to extract pragmas (environment, tags)
  const originalCode = await fs.readFile(filepath, 'utf-8').catch(() => '')
  const { env: pragmaEnv, tags: fileTags } = detectCodeBlock(originalCode)

  // Use environment from pragma if defined, otherwise fall back to config
  const environment = pragmaEnv || project.config.environment
  const env = environment === 'jsdom' || environment === 'happy-dom'
    ? project.vite.environments.client
    : project.vite.environments.ssr

  const transformResult = await env.transformRequest(filepath)

  return transformResult ? { ...transformResult, fileTags } : null
}

function markDynamicTests(tasks: Task[]) {
  for (const task of tasks) {
    if (task.dynamic) {
      task.id += '-dynamic'
    }
    if ('tasks' in task) {
      markDynamicTests(task.tasks)
    }
  }
}

function escapeRegex(str: string) {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

const kReplacers = new Map<string, string>([
  ['%i', '\\d+?'],
  ['%#', '\\d+?'],
  ['%d', '[\\d.eE+-]+?'],
  ['%f', '[\\d.eE+-]+?'],
  ['%s', '.+?'],
  ['%j', '.+?'],
  ['%o', '.+?'],
  ['%%', '%'],
])

export function escapeTestName(label: string, dynamic: boolean): string {
  if (!dynamic) {
    return escapeRegex(label)
  }

  // Replace object access patterns ($value, $obj.a) with %s first
  let pattern = label.replace(/\$[a-z_.]+/gi, '%s')
  pattern = escapeRegex(pattern)
  // Replace percent placeholders with their respective regex
  pattern = pattern.replace(/%[i#dfsjo%]/g, m => kReplacers.get(m) || m)
  return pattern
}
