export type JsonValue =
  | null
  | boolean
  | number
  | string
  | JsonValue[]
  | { [key: string]: JsonValue }

const CIRCULAR = '[Circular]'
const ACCESSOR = '[Accessor]'
const UNSERIALIZABLE = '[Unserializable]'

function dataProperty(object: object, key: PropertyKey): unknown {
  let current: object | null = object
  for (let depth = 0; current !== null && depth < 32; depth += 1) {
    const descriptor = Object.getOwnPropertyDescriptor(current, key)
    if (descriptor) return 'value' in descriptor ? descriptor.value : undefined
    current = Object.getPrototypeOf(current)
  }
  return undefined
}

function normalizeError(error: Error, seen: WeakSet<object>): JsonValue {
  const name = dataProperty(error, 'name')
  const message = dataProperty(error, 'message')
  const stack = dataProperty(error, 'stack')
  const cause = dataProperty(error, 'cause')
  const output: Record<string, JsonValue> = {
    name: typeof name === 'string' ? name : 'Error',
    message: typeof message === 'string' ? message : '',
  }
  if (typeof stack === 'string') output.stack = stack
  if (cause !== undefined) {
    const normalizedCause = toJsonValue(cause, seen)
    if (normalizedCause !== undefined) output.cause = normalizedCause
  }
  return output
}

function normalizeArray(value: unknown[], seen: WeakSet<object>): JsonValue[] {
  const descriptors = Object.getOwnPropertyDescriptors(value)
  const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length')
  const lengthValue = lengthDescriptor && 'value' in lengthDescriptor
    ? lengthDescriptor.value
    : undefined
  const length = typeof lengthValue === 'number'
    && Number.isSafeInteger(lengthValue)
    && lengthValue >= 0
    ? lengthValue
    : 0
  const output: JsonValue[] = Array.from({ length }, () => null)

  for (let index = 0; index < length; index += 1) {
    const descriptor = descriptors[String(index)]
    if (!descriptor) continue
    if (!('value' in descriptor)) {
      output[index] = ACCESSOR
      continue
    }
    output[index] = toJsonValue(descriptor.value, seen) ?? null
  }
  return output
}

function normalizeObject(value: object, seen: WeakSet<object>): Record<string, JsonValue> {
  const descriptors = Object.getOwnPropertyDescriptors(value)
  const output: Record<string, JsonValue> = {}

  for (const [key, descriptor] of Object.entries(descriptors)) {
    if (!descriptor.enumerable) continue
    if (!('value' in descriptor)) {
      output[key] = ACCESSOR
      continue
    }
    const safe = toJsonValue(descriptor.value, seen)
    if (safe !== undefined) output[key] = safe
  }
  return output
}

export function toJsonValue(value: unknown, seen = new WeakSet<object>()): JsonValue | undefined {
  if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
  if (typeof value === 'number') return Number.isFinite(value) ? value : String(value)
  if (typeof value === 'undefined') return undefined
  if (typeof value === 'bigint') return value.toString()
  if (typeof value === 'symbol') return String(value)
  if (typeof value === 'function') return '[Function]'
  if (typeof value !== 'object') return String(value)

  if (seen.has(value)) return CIRCULAR
  seen.add(value)

  try {
    if (value instanceof Error) return normalizeError(value, seen)
    if (Array.isArray(value)) return normalizeArray(value, seen)
    return normalizeObject(value, seen)
  } catch {
    return UNSERIALIZABLE
  } finally {
    seen.delete(value)
  }
}

export function toJsonObject(value: Readonly<Record<string, unknown>> | undefined): Record<string, JsonValue> {
  const normalized = toJsonValue(value ?? {})
  return normalized && !Array.isArray(normalized) && typeof normalized === 'object'
    ? normalized as Record<string, JsonValue>
    : {}
}
