/**
 * A utility for creating a proxy that captures changes to an object
 * and provides a way to retrieve those changes.
 */

import { deepEqualsInternal, isTemporal } from './utils'

// Resolve draft handles before calling native Map/Set membership methods.
const draftCopies = new WeakMap<object, object>()
function defineDataProperty(
  object: object,
  key: PropertyKey,
  value: unknown,
): void {
  if (key !== `__proto__`) {
    ;(object as Record<PropertyKey, unknown>)[key] = value
    return
  }
  Object.defineProperty(object, key, {
    value,
    enumerable: true,
    configurable: true,
    writable: true,
  })
}

function unwrapDraft(value: unknown): unknown {
  return value !== null && typeof value === `object`
    ? (draftCopies.get(value) ?? value)
    : value
}

/**
 * Array and typed-array methods that modify the value in place.
 */
const ARRAY_MODIFYING_METHODS = new Set([
  `pop`,
  `push`,
  `shift`,
  `unshift`,
  `splice`,
  `sort`,
  `reverse`,
  `fill`,
  `copyWithin`,
  `set`,
])

/**
 * Set of Map/Set iterator methods.
 */
const MAP_SET_ITERATOR_METHODS = new Set([
  `entries`,
  `keys`,
  `values`,
  `forEach`,
])

/**
 * Check if a value is a proxiable object (not Date, RegExp, or Temporal)
 */
function isProxiableObject(
  value: unknown,
): value is Record<string | symbol, unknown> {
  return (
    value !== null &&
    typeof value === `object` &&
    !((value as any) instanceof Date) &&
    !((value as any) instanceof RegExp) &&
    !isTemporal(value)
  )
}

/**
 * Creates a wrapper for methods that modify a collection (array, Map, Set).
 * The wrapper calls the method and marks the change tracker as modified.
 */
function createModifyingMethodHandler<T extends object>(
  methodFn: (...args: Array<unknown>) => unknown,
  changeTracker: ChangeTracker<T>,
  markChanged: (tracker: ChangeTracker<T>) => void,
  receiver: unknown,
): (...args: Array<unknown>) => unknown {
  return function (...args: Array<unknown>) {
    const result = methodFn.apply(changeTracker.copy_, args)
    markChanged(changeTracker)
    // A method that returns the value itself returns the draft.
    return result === changeTracker.copy_ ? receiver : result
  }
}

/**
 * Use the native live iterator, but expose tracked values. Editing an entry
 * changes its owned draft copy in place; it must not delete/reinsert a Set slot.
 */
function createMapSetIteratorHandler<T extends object>(
  methodName: string,
  prop: string | symbol,
  changeTracker: ChangeTracker<T>,
  collectionProxy: unknown,
  memoizedCreateChangeProxy: (
    obj: Record<string | symbol, unknown>,
    parent?: ChangeParent,
  ) => { proxy: Record<string | symbol, unknown> },
): ((...args: Array<unknown>) => unknown) | undefined {
  if (!MAP_SET_ITERATOR_METHODS.has(methodName) && prop !== Symbol.iterator) {
    return undefined
  }

  return (...args) => {
    const copy = changeTracker.copy_ as Map<unknown, unknown> | Set<unknown>
    const isMap = copy instanceof Map
    if (isMap && methodName === `keys`) return copy.keys()

    const track = (value: unknown) =>
      isProxiableObject(value)
        ? memoizedCreateChangeProxy(value, {
            tracker: changeTracker as unknown as ChangeTracker<
              Record<string | symbol, unknown>
            >,
            prop: ``,
            retainIdentity: true,
          }).proxy
        : value

    if (methodName === `forEach`) {
      const callback = args[0]
      if (typeof callback !== `function`)
        throw new TypeError(`forEach callback must be a function`)
      return copy.forEach((value, key) => {
        const tracked = track(value)
        callback.call(args[1], tracked, isMap ? key : tracked, collectionProxy)
      })
    }

    const entries = copy.entries()
    const pairs =
      methodName === `entries` || (isMap && prop === Symbol.iterator)
    return {
      next() {
        const result = entries.next()
        if (result.done) return result
        const [key, value] = result.value
        const tracked = track(value)
        return {
          done: false,
          value: pairs ? [isMap ? key : tracked, tracked] : tracked,
        }
      },
      [Symbol.iterator]() {
        return this
      },
    }
  }
}

// Update type for ChangeTracker
interface ChangeParent {
  tracker: ChangeTracker<Record<string | symbol, unknown>>
  prop: string | symbol
  // Map/Set entries already belong to the parent's private copy.
  retainIdentity?: boolean
}

interface ChangeTracker<T extends object> {
  valueCopies: WeakMap<object, unknown>
  originalObject: T
  modified: boolean
  copy_: T
  assigned_: Record<string | symbol, boolean>
  parent?: ChangeParent
  target: T
}

/**
 * Deep clones an object while preserving special types like Date and RegExp
 */

interface TypedArray {
  length: number
  set: (source: TypedArray) => void
}

function deepClone<T extends unknown>(
  obj: T,
  visited = new WeakMap<object, unknown>(),
  detach = false,
): T {
  // A draft handle and its underlying copy must share one cycle identity.
  obj = unwrapDraft(obj) as T
  // Handle null and undefined
  if (obj === null || obj === undefined) {
    return obj
  }

  // Handle primitive types
  if (typeof obj !== `object`) {
    return obj
  }

  // If we've already cloned this object, return the cached clone
  if (visited.has(obj as object)) {
    return visited.get(obj as object) as T
  }

  if (obj instanceof Date) {
    const clone = new Date(obj.getTime())
    visited.set(obj, clone)
    return clone as T
  }

  if (obj instanceof RegExp) {
    const clone = new RegExp(obj.source, obj.flags)
    clone.lastIndex = obj.lastIndex
    visited.set(obj, clone)
    return clone as T
  }

  if (obj instanceof URL) {
    const clone = new URL(obj.href)
    visited.set(obj, clone)
    return clone as T
  }

  if (Array.isArray(obj)) {
    const arrayClone = new Array<unknown>(obj.length)
    visited.set(obj as object, arrayClone)
    obj.forEach((item, index) => {
      arrayClone[index] = deepClone(item, visited, detach)
    })
    return arrayClone as unknown as T
  }

  // Handle TypedArrays
  if (ArrayBuffer.isView(obj) && !(obj instanceof DataView)) {
    // Create an instance of the same type by length, then copy the values.
    // A subclass constructor need not forward a source array to super.
    const TypedArrayConstructor = Object.getPrototypeOf(obj).constructor
    const clone = new TypedArrayConstructor(
      (obj as unknown as TypedArray).length,
    ) as TypedArray
    visited.set(obj as object, clone)
    clone.set(obj as unknown as TypedArray)
    return clone as unknown as T
  }

  if (obj instanceof Map) {
    const clone = new Map() as Map<unknown, unknown>
    visited.set(obj as object, clone)
    obj.forEach((value, key) => {
      clone.set(key, deepClone(value, visited, detach))
    })
    return clone as unknown as T
  }

  if (obj instanceof Set) {
    const clone = new Set()
    visited.set(obj as object, clone)
    obj.forEach((value) => {
      clone.add(deepClone(value, visited, detach))
    })
    return clone as unknown as T
  }

  // Handle Temporal objects
  if (isTemporal(obj)) {
    // Temporal objects are immutable, so we can return them directly
    // This preserves all their internal state correctly
    return obj
  }

  // Arbitrary instances may carry private/native state we cannot reconstruct.
  // Keep them by reference at publication, rather than silently flattening them.
  if (detach) {
    const prototype = Object.getPrototypeOf(obj)
    if (prototype !== Object.prototype && prototype !== null) return obj
  }

  const clone = {} as Record<string | symbol, unknown>
  visited.set(obj as object, clone)

  // Own enumerable string keys and every own symbol key, in native order.
  for (const key of Reflect.ownKeys(obj)) {
    if (
      typeof key === `symbol` ||
      Object.prototype.propertyIsEnumerable.call(obj, key)
    ) {
      // Copy data properties without invoking Object.prototype.__proto__.
      defineDataProperty(
        clone,
        key,
        deepClone(
          (obj as Record<string | symbol, unknown>)[key],
          visited,
          detach,
        ),
      )
    }
  }

  return clone as T
}

// Generic value equality intentionally ignores some native state. Draft
// assignments must also preserve Set contents/order and RegExp match position.
function draftValuesEqual(
  left: unknown,
  right: unknown,
  paired = new Map<object, object>(),
): boolean {
  return deepEqualsInternal(left, right, paired, true)
}

/**
 * Creates a proxy that tracks changes to the target object
 *
 * @param target The object to proxy
 * @param parent Optional parent information
 * @returns An object containing the proxy and a function to get the changes
 */
export function createChangeProxy<
  T extends Record<string | symbol, any | undefined>,
>(
  target: T,
  parent?: ChangeParent,
): {
  proxy: T

  getChanges: () => Record<string | symbol, any>
} {
  const changeProxyCache = new Map<object, object>()

  function memoizedCreateChangeProxy<
    TInner extends Record<string | symbol, any | undefined>,
  >(
    innerTarget: TInner,
    innerParent?: ChangeParent,
  ): {
    proxy: TInner
    getChanges: () => Record<string | symbol, any>
  } {
    if (changeProxyCache.has(innerTarget)) {
      return changeProxyCache.get(innerTarget) as {
        proxy: TInner
        getChanges: () => Record<string | symbol, any>
      }
    } else {
      const changeProxy = createChangeProxy(innerTarget, innerParent)
      changeProxyCache.set(innerTarget, changeProxy)
      return changeProxy
    }
  }
  // Existing values share one private copy per row. Newly inserted objects
  // retain normal references during the callback; the result is detached below.
  const valueCopies =
    parent?.tracker.valueCopies ?? new WeakMap<object, unknown>()
  const changeTracker: ChangeTracker<T> = {
    valueCopies,
    copy_: parent
      ? ((valueCopies.get(target) ?? target) as T)
      : deepClone(target, valueCopies),
    originalObject: deepClone(target),
    modified: false,
    assigned_: Object.create(null),
    parent,
    target, // Store reference to the target object
  }

  // Mark this object and all its ancestors as modified
  // Also propagate the actual changes up the chain
  function markChanged(state: ChangeTracker<object>) {
    if (!state.modified) {
      state.modified = true
    }

    // Propagate the change up the parent chain
    if (state.parent) {
      if (
        !state.parent.retainIdentity &&
        state.parent.tracker.copy_[state.parent.prop] === state.copy_
      ) {
        // Only mark an edge that still points to this child. A retained handle
        // must not reinstall itself after the callback replaces or deletes it.
        state.parent.tracker.assigned_[state.parent.prop] = true
      }

      // Mark parent as changed
      markChanged(state.parent.tracker)
    }
  }

  // Check if all properties in the current state have reverted to original values.
  // assigned_ keys are always strings: traps record `prop.toString()`.
  function checkIfReverted(
    state: ChangeTracker<Record<string | symbol, unknown>>,
  ): boolean {
    if (state.copy_ instanceof Map || state.copy_ instanceof Set) {
      // Compare entry contents: these containers have no assigned properties.
      return draftValuesEqual(
        Array.from(state.copy_),
        Array.from(
          state.originalObject as unknown as
            | Map<unknown, unknown>
            | Set<unknown>,
        ),
      )
    }
    for (const prop in state.assigned_) {
      // false marks a deletion, which always differs from the original. A key
      // added with the value undefined differs from an absent key.
      if (
        !state.assigned_[prop] ||
        Object.hasOwn(state.copy_, prop) !==
          Object.hasOwn(state.originalObject, prop) ||
        !draftValuesEqual(
          state.copy_[prop],
          (state.originalObject as any)[prop],
        )
      ) {
        return false
      }
    }
    return true
  }

  // Update parent status based on child changes
  function checkParentStatus(
    parentState: ChangeTracker<Record<string | symbol, unknown>>,
  ) {
    // Check if all properties of the parent are reverted
    const isReverted = checkIfReverted(parentState)

    if (isReverted) {
      // If everything is reverted, clear the tracking
      parentState.modified = false
      parentState.assigned_ = Object.create(null)

      // Continue up the chain. The parent's edge to this object no longer
      // counts as a change when the parent's value equals its original; a
      // replaced object can revert to its own snapshot and still differ.
      const edge = parentState.parent
      if (edge) {
        if (
          draftValuesEqual(
            edge.tracker.copy_[edge.prop],
            edge.tracker.originalObject[edge.prop],
          )
        )
          delete edge.tracker.assigned_[edge.prop]
        checkParentStatus(edge.tracker)
      }
    }
  }

  // Create a proxy for the target object.
  // Use the unfrozen copy_ as the proxy target to avoid Proxy invariant violations
  // when the original target is frozen (e.g., from Immer)
  const proxy = new Proxy(changeTracker.copy_, {
    get(ptarget, prop, receiver) {
      const value = changeTracker.copy_[prop as keyof T]

      // If it's a getter, return the value directly
      const desc = Object.getOwnPropertyDescriptor(ptarget, prop)
      if (desc?.get) {
        return value
      }

      // The Proxy invariants require a read-only non-configurable value as
      // stored, as after Object.freeze. A raw object can still be written
      // through, so its key counts as changed.
      if (desc?.configurable === false && !desc.writable) {
        if (isProxiableObject(value)) {
          changeTracker.assigned_[String(prop)] = true
          markChanged(changeTracker)
        }
        return value
      }

      // If the value is a function, bind it to the ptarget
      if (typeof value === `function`) {
        // A function stored as data is returned as stored, like any value. A
        // call then sees the draft as `this`, so its writes are tracked. A
        // constructor is not a method. Only inherited methods (Array, Map,
        // Set) need the handling below.
        if (Object.hasOwn(ptarget, prop) || prop === `constructor`) return value

        const methodName = prop.toString()

        // A subarray shares the buffer, so it is a draft whose writes mark
        // this value changed, like a Map value.
        if (methodName === `subarray` && ArrayBuffer.isView(ptarget)) {
          return (...args: Array<unknown>) =>
            memoizedCreateChangeProxy(value.apply(ptarget, args), {
              tracker: changeTracker,
              prop: ``,
              retainIdentity: true,
            }).proxy
        }

        // Array and typed-array methods that modify the value in place
        if (
          ARRAY_MODIFYING_METHODS.has(methodName) &&
          (Array.isArray(ptarget) || ArrayBuffer.isView(ptarget))
        ) {
          return createModifyingMethodHandler(
            value,
            changeTracker,
            markChanged,
            receiver,
          )
        }

        if (Array.isArray(ptarget)) {
          // Other methods, iterators included, read through the draft
          // itself, so returned and callback elements are drafts and searches
          // find them. This also tracks the callback array and implicit
          // reduce seed.
          return value.bind(receiver)
        }

        // For Map and Set methods that modify the collection
        if (ptarget instanceof Map || ptarget instanceof Set) {
          const resolveValue = (entry: unknown) => {
            const raw = unwrapDraft(entry)
            return raw !== null && typeof raw === `object`
              ? (valueCopies.get(raw) ?? raw)
              : raw
          }

          if (
            methodName === `has` ||
            methodName === `delete` ||
            methodName === `add` ||
            methodName === `set`
          ) {
            return (...args: Array<unknown>) => {
              if (ptarget instanceof Set) args[0] = resolveValue(args[0])
              else if (methodName === `set`) args[1] = resolveValue(args[1])
              const result = value.apply(ptarget, args)
              if (methodName !== `has`) markChanged(changeTracker)
              return result === ptarget ? receiver : result
            }
          }

          if (ptarget instanceof Map && methodName === `get`) {
            return (key: unknown) => {
              const entry = ptarget.get(key)
              return isProxiableObject(entry)
                ? memoizedCreateChangeProxy(entry, {
                    tracker: changeTracker,
                    prop: ``,
                    retainIdentity: true,
                  }).proxy
                : entry
            }
          }

          if (methodName === `clear`) {
            return createModifyingMethodHandler(
              value,
              changeTracker,
              markChanged,
              receiver,
            )
          }

          // Handle iterator methods for Map and Set
          const iteratorHandler = createMapSetIteratorHandler(
            methodName,
            prop,
            changeTracker,
            receiver,
            memoizedCreateChangeProxy,
          )
          if (iteratorHandler) {
            return iteratorHandler
          }
        }
        return value.bind(ptarget)
      }

      // If the value is an object (but not Date, RegExp, or Temporal), create a proxy for it
      if (isProxiableObject(value)) {
        // Create a parent reference for the nested object
        const nestedParent = {
          tracker: changeTracker,
          prop: String(prop),
        }

        // Create (or reuse) the proxy for the nested object
        return memoizedCreateChangeProxy(value, nestedParent).proxy
      }

      return value
    },

    set(_sobj, prop, value) {
      const currentValue = changeTracker.copy_[prop as keyof T]

      // Only track the change if the value is actually different
      if (
        !Object.hasOwn(changeTracker.copy_, prop) ||
        !draftValuesEqual(currentValue, value)
      ) {
        // Check if the new value is equal to the original value
        // Important: Use the originalObject to get the true original value
        const originalValue = changeTracker.originalObject[prop as keyof T]
        const isRevertToOriginal =
          Object.hasOwn(changeTracker.originalObject, prop) &&
          draftValuesEqual(value, originalValue)

        if (isRevertToOriginal) {
          // If the value is reverted to its original state, remove it from changes
          delete changeTracker.assigned_[prop.toString()]

          // Make sure the copy is updated with the original value
          changeTracker.copy_[prop as keyof T] = deepClone(originalValue)

          // Some properties may still be changed; checkParentStatus clears
          // tracking here and up the chain once everything is reverted.
          changeTracker.modified = true
          checkParentStatus(changeTracker)
        } else {
          // Set the value on the copy
          changeTracker.copy_[prop as keyof T] = value

          // Track that this property was assigned - store using the actual property (symbol or string)
          changeTracker.assigned_[prop.toString()] = true

          // Mark this object and its ancestors as modified
          markChanged(changeTracker)
        }
      }

      return true
    },

    defineProperty(ptarget, prop, descriptor) {
      // Forward the defineProperty to the target to maintain Proxy invariants
      // This allows Object.seal() and Object.freeze() to work on the proxy
      const result = Reflect.defineProperty(ptarget, prop, descriptor)
      // A value or an accessor changes what the key reads. Sealing does not.
      if (
        result &&
        (`value` in descriptor || descriptor.get || descriptor.set)
      ) {
        changeTracker.assigned_[prop.toString()] = true
        markChanged(changeTracker)
      }
      return result
    },

    getOwnPropertyDescriptor(ptarget, prop) {
      // Forward to target to maintain Proxy invariants for seal/freeze
      return Reflect.getOwnPropertyDescriptor(ptarget, prop)
    },

    preventExtensions(ptarget) {
      // Forward to target to allow Object.seal() and Object.preventExtensions()
      return Reflect.preventExtensions(ptarget)
    },

    isExtensible(ptarget) {
      // Forward to target to maintain consistency
      return Reflect.isExtensible(ptarget)
    },

    deleteProperty(dobj, prop) {
      const stringProp = typeof prop === `symbol` ? prop.toString() : prop

      if (Object.hasOwn(dobj, prop)) {
        // Check if the property exists in the original object
        const hadPropertyInOriginal = Object.hasOwn(
          changeTracker.originalObject,
          prop,
        )

        // Forward the delete to the target using Reflect
        // This respects Object.seal/preventExtensions constraints
        const result = Reflect.deleteProperty(dobj, prop)

        if (result) {
          // If the property didn't exist in the original object, removing it
          // should revert to the original state
          if (!hadPropertyInOriginal) {
            delete changeTracker.assigned_[stringProp]

            // Deleting an added key is a revert. Like the set trap, clear
            // tracking here and up the chain once everything is reverted.
            changeTracker.modified = true
            checkParentStatus(changeTracker)
          } else {
            // Mark this property as deleted
            changeTracker.assigned_[stringProp] = false
            markChanged(changeTracker)
          }
        }

        return result
      }

      return true
    },
  })
  draftCopies.set(proxy, changeTracker.copy_)

  // Return the proxy and a function to get the changes
  return {
    proxy,
    getChanges: () => {
      // First, check if the object is still considered modified
      if (!changeTracker.modified) {
        return {}
      }

      // If we have a copy, return it directly
      // Check if valueObj is actually an object
      if (
        typeof changeTracker.copy_ !== `object` ||
        Array.isArray(changeTracker.copy_)
      ) {
        return changeTracker.copy_
      }

      if (Object.keys(changeTracker.assigned_).length === 0) {
        return changeTracker.copy_
      }

      const result: Record<string, any | undefined> = {}
      const mayHaveChangedAliases = Object.keys(changeTracker.assigned_).some(
        (key) =>
          typeof changeTracker.copy_[key] === `object` ||
          typeof changeTracker.originalObject[key] === `object`,
      )
      const pairedRoots = new Map<object, object>([
        [changeTracker.copy_, changeTracker.originalObject],
      ])

      // Iterate through keys in keyObj
      for (const key in changeTracker.copy_) {
        const value: unknown = changeTracker.copy_[key]
        const original: unknown = changeTracker.originalObject[key]
        // Compare child contents, stopping only at paired root backedges. A
        // child's own changes still count even when it also points to this row.
        if (
          changeTracker.assigned_[key] === true ||
          (mayHaveChangedAliases &&
            !draftValuesEqual(
              value instanceof Set ? Array.from(value) : value,
              original instanceof Set ? Array.from(original) : original,
              pairedRoots,
            ))
        ) {
          defineDataProperty(result, key, changeTracker.copy_[key])
        }
      }

      for (const key of Object.keys(changeTracker.assigned_)) {
        if (changeTracker.assigned_[key] === false) {
          defineDataProperty(result, key, undefined)
        }
      }

      return result as unknown as Record<string | symbol, unknown>
    },
  }
}

/**
 * Creates proxies for an array of objects and tracks changes to each
 *
 * @param targets Array of objects to proxy
 * @returns An object containing the array of proxies and a function to get all changes
 */
export function createArrayChangeProxy<T extends object>(
  targets: Array<T>,
): {
  proxies: Array<T>
  getChanges: () => Array<Record<string | symbol, unknown>>
} {
  const proxiesWithChanges = targets.map((target) => createChangeProxy(target))

  return {
    proxies: proxiesWithChanges.map((p) => p.proxy),
    getChanges: () => proxiesWithChanges.map((p) => p.getChanges()),
  }
}

/**
 * Creates a proxy for an object, passes it to a callback function,
 * and returns the changes made by the callback
 *
 * @param target The object to proxy
 * @param callback Function that receives the proxy and can make changes to it
 * @returns The changes made to the object
 */
export function withChangeTracking<T extends object>(
  target: T,
  callback: (proxy: T) => void,
): Record<string | symbol, unknown> {
  const { proxy, getChanges } = createChangeProxy(target)

  callback(proxy)

  return deepClone(getChanges(), undefined, true)
}

/**
 * Creates proxies for an array of objects, passes them to a callback function,
 * and returns the changes made by the callback for each object
 *
 * @param targets Array of objects to proxy
 * @param callback Function that receives the proxies and can make changes to them
 * @returns Array of changes made to each object
 */
export function withArrayChangeTracking<T extends object>(
  targets: Array<T>,
  callback: (proxies: Array<T>) => void,
): Array<Record<string | symbol, unknown>> {
  const { proxies, getChanges } = createArrayChangeProxy(targets)

  callback(proxies)

  return deepClone(getChanges(), undefined, true)
}
