import assert from "node:assert/strict"
import { createRequire } from "node:module"
import { isDeepStrictEqual } from "node:util"

const require = createRequire(import.meta.url)
const { JSDOM } = require("jsdom")

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
  url: "https://example.invalid/",
})

globalThis.window = dom.window
globalThis.document = dom.window.document
Object.defineProperty(globalThis, "navigator", {
  configurable: true,
  value: dom.window.navigator,
})
globalThis.HTMLElement = dom.window.HTMLElement
globalThis.Event = dom.window.Event
globalThis.CompositionEvent = dom.window.CompositionEvent

const modifierFields = {
  Alt: "altKey",
  AltGraph: "modifierAltGraph",
  Control: "ctrlKey",
  Meta: "metaKey",
  Shift: "shiftKey",
}

globalThis.KeyboardEvent = class KeyboardEvent extends dom.window.KeyboardEvent {
  #modifiers

  constructor(type, init = {}) {
    super(type, init)
    this.#modifiers = { ...init }
  }

  getModifierState(modifier) {
    let field = modifierFields[modifier]
    return field ? Boolean(this.#modifiers[field]) : false
  }
}

const tests = []
const describeStack = []
let currentCleanups = null
let spies = []

function fullName(name) {
  return [...describeStack, name].join(" > ")
}

export function test(name, fn) {
  tests.push({ name: fullName(name), fn })
}

test.describe = (name, fn) => {
  describeStack.push(name)
  try {
    fn()
  } finally {
    describeStack.pop()
  }
}

export function onTestFinished(fn) {
  if (!currentCleanups) {
    throw new Error("onTestFinished() called outside a test")
  }
  currentCleanups.push(fn)
}

function makeMock(implementation = () => undefined) {
  function mock(...args) {
    mock.mock.calls.push(args)
    return implementation.apply(this, args)
  }

  mock.mock = { calls: [] }
  mock.mockImplementation = fn => {
    implementation = fn
    return mock
  }

  return mock
}

export const vi = {
  fn: makeMock,
  spyOn(object, property) {
    const original = object[property]
    const mock = makeMock(function (...args) {
      return original.apply(this, args)
    })
    object[property] = mock
    spies.push(() => {
      object[property] = original
    })
    return mock
  },
}

function callsOf(actual) {
  assert.ok(actual?.mock?.calls, "expected a mock function")
  return actual.mock.calls
}

function formatCalls(calls) {
  return JSON.stringify(calls)
}

function makeExpectation(actual, negated = false) {
  function check(condition, message) {
    if (negated ? condition : !condition) {
      throw new assert.AssertionError({ message })
    }
  }

  return {
    get not() {
      return makeExpectation(actual, !negated)
    },
    toHaveBeenCalled() {
      check(callsOf(actual).length > 0, "expected mock to have been called")
    },
    toHaveBeenCalledOnce() {
      let calls = callsOf(actual)
      check(
        calls.length === 1,
        `expected mock to have been called once, got ${calls.length}`,
      )
    },
    toHaveBeenCalledExactlyOnceWith(...expected) {
      let calls = callsOf(actual)
      check(
        calls.length === 1 && isDeepStrictEqual(calls[0], expected),
        `expected one call with ${JSON.stringify(expected)}, got ${formatCalls(calls)}`,
      )
    },
  }
}

export function expect(actual) {
  return makeExpectation(actual)
}

export async function run() {
  let failures = 0

  for (let { name, fn } of tests) {
    currentCleanups = []
    spies = []
    try {
      await fn()
      console.log(`ok - ${name}`)
    } catch (error) {
      failures += 1
      console.error(`not ok - ${name}`)
      console.error(error?.stack || error)
    } finally {
      for (let cleanup of currentCleanups.reverse()) {
        await cleanup()
      }
      for (let restore of spies.reverse()) {
        restore()
      }
      currentCleanups = null
      document.body.replaceChildren()
    }
  }

  console.log(`${tests.length - failures}/${tests.length} tests passed`)
  if (failures) {
    process.exitCode = 1
  }
}
