import type { MockedModule } from '@vitest/mocker'
import type { Awaitable, ParsedStack, TestError } from '@vitest/utils'
import type { StackTraceParserOptions } from '@vitest/utils/source-map'
import type { IndexHtmlTransformContext, IndexHtmlTransformResult, Plugin, ViteDevServer, UserConfig as ViteUserConfig } from 'vite'
import type { BrowserCommands, CDPSession, MarkOptions } from 'vitest/browser'
import type { BrowserTraceViewMode } from '../../runtime/config'
import type { CancelReason } from '../../runtime/runner/types'
import type { BrowserTesterOptions } from '../../types/browser'
import type { OTELCarrier } from '../../utils/traces'
import type { PluginHarness } from '../config/pluginHarness'
import type { Vitest } from '../core'
import type { TestProject } from '../project'
import type { ProjectConfig, ResolvedConfig } from './config'

export type { CDPSession }

export interface BrowserModuleMocker {
  register: (sessionId: string, module: MockedModule) => Promise<void>
  delete: (sessionId: string, url: string) => Promise<void>
  clear: (sessionId: string) => Promise<void>
}

export interface BrowserProviderOption<Options extends object = object> {
  name: string
  supportedBrowser?: ReadonlyArray<string>
  options: Options
  /**
   * Called once for every resolved browser project right before its shared
   * Vite server is created, so the provider can start preparing the browser
   * (e.g. launching it) concurrently. Optional, fire-and-forget: errors must
   * surface through the normal provider flow.
   */
  prewarm?: (ctx: {
    config: ResolvedConfig
    vitest: Vitest
  }) => void
  providerFactory: (project: TestProject) => BrowserProvider
  serverFactory: BrowserServerFactory
}

export interface BrowserServerFactory {
  (): Promise<BrowserServerContribution>
}

export interface BrowserProvider {
  name: string
  mocker?: BrowserModuleMocker
  readonly initScripts?: string[]
  /**
   * @experimental opt-in into file parallelisation
   */
  supportsParallelism: boolean
  getCommandsContext: (sessionId: string) => Record<string, unknown>
  openPage: (sessionId: string, url: string, options: { parallel: boolean }) => Promise<void>
  getCDPSession?: (sessionId: string) => Promise<CDPSession>
  close: () => Awaitable<void>
}

export type BrowserBuiltinProvider = 'webdriverio' | 'playwright' | 'preview'
export interface _BrowserNames {}

export interface BrowserTraceViewOptions {
  /**
   * Enable Vitest trace view artifacts collection.
   *
   * This option controls Vitest's own trace-view pipeline, independently from provider-specific trace retention.
   *
   * @default false
   * @experimental
   */
  enabled?: boolean

  /**
   * Capture canvas pixels in trace view snapshots.
   *
   * This uses rrweb's canvas snapshot support and can increase trace artifact size.
   * In the trace viewer, this enables a weaker replay iframe sandbox because rrweb needs scripts to redraw canvas data.
   *
   * @default false
   * @experimental
   */
  recordCanvas?: boolean

  /**
   * Inline loaded `<img>` element pixels in trace view snapshots.
   *
   * This uses rrweb's image inlining support. It improves offline replay for still images,
   * but can increase trace artifact size and does not preserve original image resource bytes.
   *
   * @default false
   * @experimental
   */
  inlineImages?: boolean
}

type UnsupportedProperties
  = | 'browser'
    | 'typecheck'
    | 'alias'
    | 'sequence'
    | 'root'
    | 'pool'
  // browser mode doesn't support a custom runner
    | 'runner'
  // non-browser options
    | 'api'
    | 'deps'
    | 'environment'
    | 'environmentOptions'
    | 'server'
    | 'benchmark'
    | 'name'

export interface BrowserInstanceOption extends
  Omit<ProjectConfig, UnsupportedProperties>,
  Pick<
    BrowserConfigOptions,
    | 'headless'
    | 'locators'
    | 'viewport'
    | 'testerHtmlPath'
    | 'screenshotDirectory'
    | 'screenshotFailures'
  > {
  /**
   * Name of the browser
   */
  browser: keyof _BrowserNames extends never
    ? string
    : _BrowserNames[keyof _BrowserNames]

  name?: string
  provider?: BrowserProviderOption
}

export interface BrowserConfigOptions {
  /**
   * if running tests in the browser should be the default
   *
   * @default false
   */
  enabled?: boolean

  /**
   * Name of the browser
   * @deprecated use `instances` instead. if both are defined, this will filter `instances` by name.
   * @internal
   */
  name?: string

  /**
   * Configurations for different browser setups
   */
  instances?: BrowserInstanceOption[]

  /**
   * Browser provider
   * @example
   * ```ts
   * import { playwright } from '@vitest/browser-playwright'
   * export default defineConfig({
   *   test: {
   *     browser: {
   *       provider: playwright(),
   *     },
   *   },
   * })
   * ```
   */
  provider?: BrowserProviderOption

  /**
   * enable headless mode
   *
   * @default process.env.CI
   */
  headless?: boolean

  /**
   * Show Vitest UI
   *
   * @default !process.env.CI
   */
  ui?: boolean

  /**
   * Default position for the details panel in browser mode
   * 'right' shows the details panel on the right side (horizontal split)
   * 'bottom' shows the details panel at the bottom (vertical split)
   * @default 'right'
   */
  detailsPanelPosition?: 'right' | 'bottom'

  /**
   * Default viewport size
   */
  viewport?: {
    /**
     * Width of the viewport
     * @default 414
     */
    width: number
    /**
     * Height of the viewport
     * @default 896
     */
    height: number
  }

  /**
   * Locator options
   */
  locators?: {
    /**
     * Attribute used to locate elements by test id
     * @default 'data-testid'
     */
    testIdAttribute?: string
    /**
     * Should locators match the text exactly by default
     * @default false
     */
    exact?: boolean
    /**
     * Format used for locator "Cannot find element" error details.
     *
     * @default 'all'
     */
    errorFormat?: 'html' | 'aria' | 'all'
  }

  /**
   * Generate traces that can be viewed on https://trace.playwright.dev/
   *
   * This option is supported only by **playwright** provider.
   */
  trace?: BrowserTraceViewMode | {
    mode: BrowserTraceViewMode
    /**
     * The directory where all traces will be stored. By default, Vitest
     * stores all traces in `__traces__` folder close to the test file.
     */
    tracesDir?: string
    /**
     * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview.
     * @default true
     */
    screenshots?: boolean
    /**
     * If this option is true tracing will
     * - capture DOM snapshot on every action
     * - record network activity
     * @default true
     */
    snapshots?: boolean
  }

  /**
   *
   * Enable Vitest trace view artifacts collection.
   *
   * This option controls Vitest's own trace-view pipeline, independently from provider-specific trace retention.
   *
   * @default false
   * @experimental
   */
  traceView?: boolean | BrowserTraceViewOptions

  /**
   * Directory where screenshots will be saved when page.screenshot() is called
   * If not set, all screenshots are saved to __screenshots__ directory in the same folder as the test file.
   * If this is set, it will be resolved relative to the project root.
   * @default __screenshots__
   */
  screenshotDirectory?: string

  /**
   * Should Vitest take screenshots if the test fails
   * @default !browser.ui
   */
  screenshotFailures?: boolean

  /**
   * Serve sourcemaps of your dependencies (files in `node_modules`) to the
   * browser during headless test runs.
   *
   * These sourcemaps are used by browser devtools: when disabled, pausing
   * inside dependency code shows the compiled code the browser actually
   * runs instead of the dependency's original sources. If you don't debug
   * into your dependencies this way, disabling them makes test runs faster:
   * the server doesn't generate and inline the maps, and every browser tab
   * downloads several times fewer bytes.
   *
   * Reported test errors are not affected: stack frames pointing into a
   * pre-bundled dependency are mapped using the sourcemaps stored on disk
   * even when this option is disabled.
   *
   * Vitest never serves sourcemaps of its own pre-built modules in headless
   * runs (unless `--inspect` is used) — their frames are hidden from stack
   * traces anyway. Sourcemaps of your own source files are always served.
   * @default true
   */
  dependencySourcemaps?: boolean

  /**
   * Path to the index.html file that will be used to run tests.
   */
  testerHtmlPath?: string

  /**
   * Scripts injected into the main window.
   */
  orchestratorScripts?: BrowserScript[]

  /**
   * Commands that will be executed on the server
   * via the browser `import("vitest/browser").commands` API.
   * @see {@link https://vitest.dev/api/browser/commands}
   */
  commands?: Record<string, BrowserCommand<any>>

  /**
   * Timeout for connecting to the browser
   * @default 30000
   */
  connectTimeout?: number

  expect?: {
    toMatchScreenshot?: {
      [ComparatorName in keyof ToMatchScreenshotComparators]:
      {
        /**
         * The name of the comparator to use for visual diffing.
         *
         * @defaultValue `'pixelmatch'`
         */
        comparatorName?: ComparatorName
        comparatorOptions?: ToMatchScreenshotComparators[ComparatorName]
      }
    }[keyof ToMatchScreenshotComparators] & ToMatchScreenshotOptions
  }

  /**
   * Enables tracking uncaught errors and exceptions so they can be reported by Vitest.
   *
   * If you need to hide certain errors, it is recommended to use [`onUnhandledError`](https://vitest.dev/config/onunhandlederror) option instead.
   *
   * Disabling this will completely remove all Vitest error handlers, which can help debugging with the "Pause on exceptions" checkbox turned on.
   * @default true
   */
  trackUnhandledErrors?: boolean
}

export interface BrowserCommandContext {
  testPath: string | undefined
  provider: BrowserProvider
  project: TestProject
  sessionId: string
  mark: (name: string, options?: MarkOptions) => Promise<void>
  triggerCommand: <K extends keyof BrowserCommands>(
    name: K,
    ...args: Parameters<BrowserCommands[K]>
  ) => ReturnType<BrowserCommands[K]>
  /**
   * Returns Vitest's cached CDP handler for the current tester RPC connection.
   * This works similar to client `cdp()` API.
   *
   * Unlike `provider.getCDPSession`, this preserves CDP session state across
   * multiple command calls from the same browser tester. This matters for
   * stateful CDP domains such as `Profiler`, where `startPreciseCoverage` and
   * `takePreciseCoverage` must run on the same CDP session.
   *
   * @internal
   */
  __ensureCDPHandler: () => Promise<any> // use `any` since type is messy
}

export interface BrowserServerStateSession {
  project: TestProject
  otelCarrier?: OTELCarrier
  concurrencyId: number
  connected: () => void
  ready: () => void
  fail: (v: Error) => void
}

export interface BrowserOrchestrator {
  cleanupTesters: () => Promise<void>
  createTesters: (options: BrowserTesterOptions) => Promise<void>
  onCancel: (reason: CancelReason) => Promise<void>
  $close: () => void
}

export interface BrowserServerState {
  orchestrators: Map<string, BrowserOrchestrator>
}

export interface ParentProjectBrowser {
  spawn: (project: TestProject) => ProjectBrowser
  vite: ViteDevServer
  vitest: Vitest
  config: ResolvedConfig
}

export interface BrowserServerContribution {
  transformIndexHtml: (ctx: IndexHtmlTransformContext) => Awaitable<IndexHtmlTransformResult | undefined>
  configureServer: (server: ViteDevServer) => Awaitable<void>
  /**
   * Browser-specific Vite config (`resolve.alias`, `define`, esbuild). Applied
   * by the core loader plugin's `config` hook during the single project
   * resolution, so other plugins observe it (e.g. alias must be baked at
   * resolution time). The loader always forces `server.middlewareMode = false`
   * on top. `harness` provides the package installer's `isPackageExists` (no
   * `Vitest` instance is available during resolution).
   */
  config: (config: ViteUserConfig, harness: PluginHarness) => Awaitable<ViteUserConfig>
  /**
   * Browser `optimizeDeps`, aggregated across every project that shares the
   * single browser Vite server (instance and benchmark variants). Called by core
   * after all projects are resolved and before the server is created; the result
   * is merged into the resolved Vite config's `client` environment
   * `optimizeDeps`. `testFiles` is the aggregated, already-globbed set of test
   * files for the server (globbing lives in the core package).
   */
  resolveOptimizeDeps: (projectConfigs: ResolvedConfig[], testFiles: string[], harness: PluginHarness) => Awaitable<NonNullable<ViteUserConfig['optimizeDeps']>>
  /**
   * Runtime plugins. Injected into the browser (`client`) environment by the
   * loader's `applyToEnvironment`; their `configureServer`/`transformIndexHtml`
   * are run by the loader. MUST NOT define `config`/`configResolved` hooks.
   */
  plugins: Plugin[]
  /**
   * Constructs the `ParentBrowserProject`. Called by core at server creation,
   * when the `Vitest` instance exists.
   */
  createParent: (ctx: { config: ResolvedConfig; vitest: Vitest }) => ParentProjectBrowser
  /** Called by core after `server.listen()` to wire up the browser RPC. */
  setupRpc: (parent: ParentProjectBrowser) => void
  /**
   * Mutable. Filled by core at server creation; the pushed `BrowserPlugin`
   * closes over this same object and reads `.parent` in `configureServer`.
   */
  parent?: ParentProjectBrowser
}

export interface ProjectBrowser {
  vite: ViteDevServer
  state: BrowserServerState
  provider: BrowserProvider
  close: () => Promise<void>
  initBrowserProvider: (project: TestProject) => Promise<void>
  parseStacktrace: (stack: string) => ParsedStack[]
  parseErrorStacktrace: (error: TestError, options?: StackTraceParserOptions) => ParsedStack[]
  registerCommand: <K extends keyof BrowserCommands>(
    name: K,
    cb: BrowserCommand<
      Parameters<BrowserCommands[K]>,
      ReturnType<BrowserCommands[K]>
    >,
  ) => void
  triggerCommand: <K extends keyof BrowserCommands>(
    name: K,
    context: BrowserCommandContext,
    ...args: Parameters<BrowserCommands[K]>
  ) => ReturnType<BrowserCommands[K]>
}

export interface BrowserCommand<Payload extends unknown[] = [], ReturnValue = any> {
  (context: BrowserCommandContext, ...payload: Payload): Awaitable<ReturnValue>
}

export interface BrowserScript {
  /**
   * If "content" is provided and type is "module", this will be its identifier.
   *
   * If you are using TypeScript, you can add `.ts` extension here for example.
   * @default `injected-${index}.js`
   */
  id?: string
  /**
   * JavaScript content to be injected. This string is processed by Vite plugins if type is "module".
   *
   * You can use `id` to give Vite a hint about the file extension.
   */
  content?: string
  /**
   * Path to the script. This value is resolved by Vite so it can be a node module or a file path.
   */
  src?: string
  /**
   * If the script should be loaded asynchronously.
   */
  async?: boolean
  /**
   * Script type.
   * @default 'module'
   */
  type?: string
}

export interface ResolvedBrowserOptions extends BrowserConfigOptions {
  name: string
  enabled: boolean
  headless: boolean
  ui: boolean
  viewport: {
    width: number
    height: number
  }
  screenshotFailures: boolean
  locators: {
    testIdAttribute: string
    exact: boolean
    errorFormat: 'html' | 'aria' | 'all'
  }
  trace: {
    mode: BrowserTraceViewMode
    tracesDir?: string
    screenshots?: boolean
    snapshots?: boolean
    sources?: boolean
  }
  traceView: Required<BrowserTraceViewOptions>
}

type ToMatchScreenshotResolvePath = (data: {
  /**
   * Path **without** extension, sanitized and relative to the test file.
   *
   * This comes from the arguments passed to `toMatchScreenshot`; if called
   * without arguments this will be the auto-generated name.
   *
   * @example
   * test('calls `onClick`', () => {
   *   expect(locator).toMatchScreenshot()
   *   // arg = "calls-onclick-1"
   * })
   *
   * @example
   * expect(locator).toMatchScreenshot('foo/bar/baz.png')
   * // arg = "foo/bar/baz"
   *
   * @example
   * expect(locator).toMatchScreenshot('../foo/bar/baz.png')
   * // arg = "foo/bar/baz"
   */
  arg: string
  /**
   * Screenshot extension, with leading dot.
   *
   * This can be set through the arguments passed to `toMatchScreenshot`, but
   * the value will fall back to `'.png'` if an unsupported extension is used.
   */
  ext: string
  /**
   * The instance's browser name.
   */
  browserName: string
  /**
   * The value of {@linkcode process.platform}.
   */
  platform: NodeJS.Platform
  /**
   * The value provided to {@linkcode ToMatchScreenshotOptions.screenshotDirectory|browser.expect.toMatchScreenshot.screenshotDirectory}, if none is provided, its default value (`__screenshots__`).
   */
  screenshotDirectory: string
  /**
   * Absolute path to the project's
   * {@linkcode https://vitest.dev/config/root|root}.
   */
  root: string
  /**
   * Path to the test file, relative to the project's
   * {@linkcode https://vitest.dev/config/root|root}.
   */
  testFileDirectory: string
  /**
   * The test's filename.
   */
  testFileName: string
  /**
   * The {@linkcode https://vitest.dev/api/#test|test}'s name, including
   * parent {@linkcode https://vitest.dev/api/#describe|describe}, sanitized.
   */
  testName: string
  /**
   * The value provided to
   * {@linkcode https://vitest.dev/config/attachmentsdir|attachmentsDir},
   * if none is provided, its default value.
   */
  attachmentsDir: string
  /**
   * The {@linkcode https://vitest.dev/api/advanced/test-project|TestProject} the test belongs to.
   *
   * @experimental
   */
  project: TestProject
}) => string

export interface ToMatchScreenshotOptions {
  /**
   * The directory name used for storing reference screenshots.
   *
   * This value is passed as `screenshotDirectory` to {@linkcode resolveScreenshotPath|browser.expect.toMatchScreenshot.resolveScreenshotPath} and {@linkcode resolveDiffPath|browser.expect.toMatchScreenshot.resolveDiffPath}, and used in the default path resolution of `resolveScreenshotPath`.
   *
   * @default `__screenshots__`.
   */
  screenshotDirectory?: string
  /**
   * Overrides default reference screenshot path.
   *
   * @default path.resolve(root, testFileDirectory, screenshotDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`)
   */
  resolveScreenshotPath?: ToMatchScreenshotResolvePath
  /**
   * Overrides default screenshot path used for diffs.
   *
   * @default path.resolve(root, attachmentsDir, testFileDirectory, testFileName, `${arg}-${browserName}-${platform}${ext}`)
   */
  resolveDiffPath?: ToMatchScreenshotResolvePath
}

export interface ToMatchScreenshotComparators {}
