/* eslint-disable @typescript-eslint/no-explicit-any */
const reactiveSymbol = Symbol('reactive')
const proxySymbol = Symbol('proxy')

interface DomainInstance {
  domain?: { notifyChange(): void }
  notifyChange?: () => void
}

export interface PropertyDecorator {
  // accessor decorator signature
  <C extends DomainInstance, V>(
    target: ClassAccessorDecoratorTarget<C, V>,
    context: ClassAccessorDecoratorContext<C, V>
  ): ClassAccessorDecoratorResult<C, V>
  // setter decorator signature
  <C extends DomainInstance, V>(
    target: (value: V) => void,
    context: ClassSetterDecoratorContext<C, V>
  ): (this: C, value: V) => void
}

type StandardPropertyContext<C, V> = ClassAccessorDecoratorContext<C, V> | ClassSetterDecoratorContext<C, V>
type StandardPropertyTarget<C, V> = ClassAccessorDecoratorTarget<C, V> | ((value: V) => void)

export interface ObserveConfig {
  /**
   * To be used when observing an object and not a primitive.
   * It creates a proxy that observes changes in the object.
   */
  deep?: boolean
}

/**
 * Reads the raw value of the object, that is, not proxied object.
 * @param source The source object that contains the object
 * @param target The target object set on the source object
 * @returns The not proxied object or undefined
 */
export function toRaw<T extends object = object>(source: object, target: T): T | undefined {
  const proxies = Reflect.get(source, proxySymbol) as Map<object, object>
  if (!proxies) {
    return undefined
  }
  for (const [key, value] of proxies.entries()) {
    if (value === target) {
      return key as T
    }
  }
}

/**
 * Turns a class property or a setter into an observed property
 * that notifies the root domain when changed.
 *
 * A property has to be declared with the `accessor` type to be
 * observed.
 *
 * ```typescript
 * @observed() accessor name: string | undefined
 *
 * @observed()
 * set height(value: number) {
 *   this.#height = value
 * }
 *
 * get height(): number {
 *   return this.#height
 * }
 * ```
 *
 * The decorator can be used in the following ways:
 * - As a class property decorator
 * - As a class setter decorator
 *
 * The property class either has to have the `domain` property
 * or a `notifyChange` method. The decorator will call the
 * `notifyChange` method if it exists. Otherwise, it will
 * call the `notifyChange` method of the root domain (the `domain` property).
 */
export function observed(config: ObserveConfig = {}): PropertyDecorator {
  return <C extends DomainInstance, V>(
    target: StandardPropertyTarget<C, V>,
    context: StandardPropertyContext<C, V>
  ): any => {
    const { kind } = context
    const { deep } = config

    function createDeepProxy(this: DomainInstance, obj: unknown, notifyChange: () => void): any {
      if (typeof obj !== 'object' || obj === null) {
        return obj
      }
      // instead of using the target object to store the proxy info, we
      // reflect the proxy info to the object itself. This way we can
      // keep the stored values clean, which helps with testing (deep equal would include symbols)
      // Keys are original objects, values are proxies
      let proxies = Reflect.get(this, proxySymbol) as Map<object, object>
      if (!proxies) {
        proxies = new Map()
        Reflect.set(this, proxySymbol, proxies)
      }

      if (proxies.has(obj)) {
        return obj
      }

      // eslint-disable-next-line @typescript-eslint/no-this-alias
      const targetObject = this

      const proxy = new Proxy(obj, {
        get(target, prop) {
          const value = Reflect.get(target, prop)
          return createDeepProxy.bind(targetObject)(value, notifyChange)
        },
        set(target, prop, value) {
          const oldValue = Reflect.get(target, prop)
          if (oldValue !== value) {
            Reflect.set(target, prop, value)
            notifyChange()
          }
          return true
        },
        deleteProperty(target, prop) {
          Reflect.deleteProperty(target, prop)
          notifyChange()
          return true
        },
      })
      proxies.set(obj, proxy)
      return proxy
    }

    if (kind === 'accessor') {
      return {
        set(this: DomainInstance, value: V): void {
          let map = Reflect.get(this, reactiveSymbol)
          if (!map) {
            map = {}
            Reflect.set(this, reactiveSymbol, map)
          }
          if (map[context.name] === value) {
            return
          }
          const notify = () => {
            if (this.domain) {
              this.domain.notifyChange()
            } else if (this.notifyChange) {
              this.notifyChange()
            }
          }
          if (deep) {
            value = createDeepProxy.bind(this)(value, notify)
          }
          if (value === undefined) {
            // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
            delete map[context.name]
          } else {
            map[context.name] = value
          }
          notify()
        },

        get(): V {
          const map = Reflect.get(this, reactiveSymbol) || {}
          return map[context.name]
        },
      }
    } else if (kind === 'setter') {
      const { name } = context
      return function (this: DomainInstance, value: V): void {
        const oldValue = this[name as keyof DomainInstance]
        if (value === oldValue) {
          return
        }
        const notify = () => {
          if (this.domain) {
            this.domain.notifyChange()
          } else if (this.notifyChange) {
            this.notifyChange()
          }
        }
        ;(target as (value: V) => void).call(this, value)
        notify()
      }
    }
    throw new Error(`Unsupported decorator location: ${kind}`)
  }
}

/**
 * A decorator that can be used to retarget the change event
 * of a property to the root domain.
 *
 * This decorator should not be used with the `observed` decorator.
 * It does the same thing. It will notify about a change to the
 * property itself.
 */
export function retargetChange() {
  return <C extends DomainInstance, V extends EventTarget | undefined>(
    target: StandardPropertyTarget<C, V>,
    context: StandardPropertyContext<C, V>
  ): any => {
    const { kind } = context
    function observer(this: DomainInstance) {
      if (this.domain) {
        this.domain.notifyChange()
      }
    }
    let binding: ((this: DomainInstance) => void) | undefined
    if (kind === 'accessor') {
      return {
        set(this: DomainInstance, value: V): void {
          let map = Reflect.get(this, reactiveSymbol)
          if (!map) {
            map = {}
            Reflect.set(this, reactiveSymbol, map)
          }
          if (map[context.name] === value) {
            return
          }
          const oldValue = map[context.name]
          if (oldValue === value) {
            return
          }
          if (value === undefined) {
            // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
            delete map[context.name]
          } else {
            map[context.name] = value
          }
          if (oldValue && binding) {
            oldValue.removeEventListener('change', binding)
          }
          if (value) {
            binding = observer.bind(this)
            value.addEventListener('change', binding)
          }
          if (this.domain) {
            this.domain.notifyChange()
          }
        },

        get(): V {
          const map = Reflect.get(this, reactiveSymbol) || {}
          return map[context.name]
        },
      }
    } else if (kind === 'setter') {
      const { name } = context
      return function (this: DomainInstance, value: V): void {
        const oldValue = this[name as keyof DomainInstance] as unknown as V
        if (value === oldValue) {
          return
        }
        ;(target as (value: V) => void).call(this, value)
        if (oldValue && binding) {
          oldValue.removeEventListener('change', binding)
        }
        if (value) {
          binding = observer.bind(this)
          value.addEventListener('change', binding)
        }
      }
    }
    throw new Error(`Unsupported decorator location: ${kind}`)
  }
}
