/**
 * @vitest-environment jsdom
 *
 * Closure evaluation account: the symbol surface a browser half receives, the
 * teaching traps shadowing ambient globals, the parse/return diagnostics, and
 * the style bookkeeping whose disposal the runner owns.
 */
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import type { CordisDynamicPluginId } from '@deepseek-ai/dsh-api-remotes/client'
import {
  DynamicCordisStyles,
  DYNAMIC_CLIENT_REDIRECTS,
  evaluateClientHalf,
  isDynamicCordisPlugin,
} from '../src/client/evaluator.ts'
import type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from '../src/client/evaluator.ts'

const ID = 'dyn-1' as CordisDynamicPluginId

function env(overrides: Partial<DynamicCordisClosureEnv> = {}): DynamicCordisClosureEnv {
  return {
    invoke: () => Promise.resolve(null),
    noteError: () => {},
    ...overrides,
  }
}

/** Evaluate one source with fresh style bookkeeping. */
async function run(source: string, closure: DynamicCordisClosureEnv = env()): Promise<{
  plugin: DynamicCordisEvaluatedPlugin | ((ctx: unknown) => unknown)
  styles: DynamicCordisStyles
}> {
  const styles = new DynamicCordisStyles(ID)
  const plugin = await evaluateClientHalf(ID, source, closure, styles)
  return { plugin, styles }
}

describe('evaluateClientHalf', () => {
  it('returns the object-form plugin and hands the page React instance to the closure', async () => {
    const { plugin } = await run(`
      if (React.createElement === undefined) throw new Error('React symbol missing')
      return { name: 'ignored', inject: ['slots'], apply(ctx) { return React } }
    `)
    expect(typeof plugin).toBe('object')
    const object = plugin as DynamicCordisEvaluatedPlugin
    expect(object.inject).toEqual(['slots'])
    // Same instance as the page's React: a second copy would break hooks.
    expect(object.apply({})).toBe(React)
  })

  it('accepts the function form', async () => {
    const { plugin } = await run('return (ctx) => "applied"')
    expect(typeof plugin).toBe('function')
    expect((plugin as (ctx: unknown) => unknown)({})).toBe('applied')
  })

  it('redirects browser timers to the ctx facade', async () => {
    for (const timer of ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] as const) {
      const { plugin } = await run(`return () => ${timer}(() => {}, 1)`)
      expect(() => (plugin as (ctx: unknown) => unknown)({}))
        .toThrow(DYNAMIC_CLIENT_REDIRECTS[timer])
    }
  })

  it('redirects fetch to the host half and require to the closure symbols', async () => {
    const { plugin: fetcher } = await run('return () => fetch("/x")')
    expect(() => (fetcher as (ctx: unknown) => unknown)({})).toThrow(/network belongs to the HOST half/)
    const { plugin: importer } = await run('return () => require("react")')
    expect(() => (importer as (ctx: unknown) => unknown)({})).toThrow(/React arrives as the `React` closure symbol/)
  })

  it('teaches the half split on any harness access', async () => {
    const { plugin } = await run('return () => harness.handle("m", () => {})')
    expect(() => (plugin as (ctx: unknown) => unknown)({}))
      .toThrow(/harness\.handle belongs to the HOST half/)
  })

  it('routes host.call to the runner invoke seam', async () => {
    const invoke = vi.fn(() => Promise.resolve({ ok: 1 }))
    const { plugin } = await run('return { apply: (ctx) => host.call("ping", { a: 1 }) }', env({ invoke }))
    await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual({ ok: 1 })
    expect(invoke).toHaveBeenCalledWith('ping', { a: 1 })
  })

  it('sends null for a host.call written without arguments', async () => {
    const invoke = vi.fn(() => Promise.resolve(['fs', 'web']))
    // A handler that takes nothing is the natural case ("list the services"), and
    // `undefined` is not JSON — so the omission travels as null rather than
    // making the wire refuse the call.
    const { plugin } = await run('return { apply: (ctx) => host.call("listServices") }', env({ invoke }))
    await expect((plugin as DynamicCordisEvaluatedPlugin).apply({})).resolves.toEqual(['fs', 'web'])
    expect(invoke).toHaveBeenCalledWith('listServices', null)
  })

  it('reports a parse failure as a plain-JavaScript teaching error', async () => {
    await expect(run('return (')).rejects.toThrow(/client half failed to parse in this browser/)
    await expect(run('return (')).rejects.toThrow(/no JSX, no TypeScript/)
  })

  it('names the missing return, and rejects a non-plugin value', async () => {
    await expect(run('const x = 1')).rejects.toThrow(/did you forget `return`/)
    await expect(run('return 42')).rejects.toThrow(/must `return` a plugin/)
  })

  it('propagates a non-syntax construction failure untouched', async () => {
    const boom = new TypeError('engine refused')
    // The constructor is the only failure seam before evaluation; a
    // non-SyntaxError must not be reinterpreted as a source problem.
    vi.stubGlobal('Function', function stub(): never { throw boom })
    try {
      await expect(run('return () => {}')).rejects.toBe(boom)
    } finally {
      vi.unstubAllGlobals()
    }
    expect(typeof Function).toBe('function')
  })
})

describe('tagged console', () => {
  it('mirrors only error lines, and stringifies every argument shape', async () => {
    const seen: string[] = []
    const closure = env({ noteError: message => seen.push(message) })
    const circular: Record<string, unknown> = {}
    circular.self = circular
    const { plugin } = await run(`
      return { apply: (ctx) => {
        console.log('quiet')
        console.warn('also quiet')
        console.error('text', new Error('boom'), { a: 1 }, undefined, ctx.circular)
        console.debug('quiet too')
      } }
    `, closure)
    vi.spyOn(console, 'error').mockImplementation(() => {})
    vi.spyOn(console, 'log').mockImplementation(() => {})
    vi.spyOn(console, 'warn').mockImplementation(() => {})
    vi.spyOn(console, 'debug').mockImplementation(() => {})
    ;(plugin as DynamicCordisEvaluatedPlugin).apply({ circular })
    vi.restoreAllMocks()
    expect(seen).toHaveLength(1)
    expect(seen[0]).toBe('text boom {"a":1} undefined [unserializable console argument]')
  })

  it('truncates a long mirrored error', async () => {
    const seen: string[] = []
    const { plugin } = await run(
      'return { apply: () => console.error("x".repeat(900)) }',
      env({ noteError: message => seen.push(message) }),
    )
    vi.spyOn(console, 'error').mockImplementation(() => {})
    ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
    vi.restoreAllMocks()
    expect(seen[0]).toHaveLength(500)
  })
})

describe('DynamicCordisStyles', () => {
  it('stamps ownership, counts live tags, and disposes one tag or all of them', () => {
    const styles = new DynamicCordisStyles(ID)
    const first = styles.insert('.a { color: red }')
    styles.insert('.b { color: blue }')
    expect(styles.count).toBe(2)
    const tags = [...document.querySelectorAll('style[data-dyn="dyn-1"]')]
    expect(tags).toHaveLength(2)
    expect(tags[0]?.textContent).toBe('.a { color: red }')
    first()
    expect(styles.count).toBe(1)
    expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(1)
    styles.dispose()
    expect(styles.count).toBe(0)
    expect(document.querySelectorAll('style[data-dyn="dyn-1"]')).toHaveLength(0)
  })

  it('rejects a non-string stylesheet', () => {
    const styles = new DynamicCordisStyles(ID)
    expect(() => styles.insert(42 as unknown as string)).toThrow(/needs a CSS string/)
  })

  it('exposes styles.insert to the closure', async () => {
    const { plugin, styles } = await run('return { apply: () => styles.insert(".c {}") }')
    ;(plugin as DynamicCordisEvaluatedPlugin).apply({})
    expect(styles.count).toBe(1)
    styles.dispose()
  })
})

describe('isDynamicCordisPlugin', () => {
  it('accepts both mountable forms and rejects everything else', () => {
    expect(isDynamicCordisPlugin(() => {})).toBe(true)
    expect(isDynamicCordisPlugin({ apply: () => {} })).toBe(true)
    expect(isDynamicCordisPlugin({})).toBe(false)
    expect(isDynamicCordisPlugin(null)).toBe(false)
    expect(isDynamicCordisPlugin(42)).toBe(false)
  })
})
