/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

"use strict";

type ROViolationType =
  | "FORGET_MUTATE_IMMUT"
  | "FORGET_DELETE_PROP_IMMUT"
  | "FORGET_CHANGE_PROP_IMMUT"
  | "FORGET_ADD_PROP_IMMUT";
type ROViolationLogger = (
  violation: ROViolationType,
  source: string,
  key: string,
  value?: any
) => void;

/**
 * Represents a "proxy" of a read-only object property
 *   savedVal: underlying "source of truth" for a property value
 *   getter: hack, this lets us check whether we have already saved this property
 * */
type SavedEntry = {
  savedVal: unknown;
  getter: () => unknown;
};
type SavedROObject = Map<string, SavedEntry>;
type SavedROObjects = WeakMap<Object, SavedROObject>;

// Utility functions
function isWriteable(desc: PropertyDescriptor) {
  return (desc.writable || desc.set) && desc.configurable;
}

function getOrInsertDefault(
  m: SavedROObjects,
  k: object
): { existed: boolean; entry: SavedROObject } {
  const entry = m.get(k);
  if (entry) {
    return { existed: true, entry };
  } else {
    const newEntry: SavedROObject = new Map();
    m.set(k, newEntry);
    return { existed: false, entry: newEntry };
  }
}

function buildMakeReadOnly(
  logger: ROViolationLogger,
  skippedClasses: string[]
): <T>(val: T, source: string) => T {
  // All saved proxys
  const savedROObjects: SavedROObjects = new WeakMap();

  // Overwrites an object property with its proxy and saves its original value
  function addProperty(
    obj: Object,
    source: string,
    key: string,
    prop: PropertyDescriptor,
    savedEntries: Map<string, SavedEntry>
  ) {
    const proxy: PropertyDescriptor & { get(): unknown } = {
      get() {
        // read from backing cache entry
        return makeReadOnly(savedEntries.get(key)!.savedVal, source);
      },
      set(newVal: unknown) {
        logger("FORGET_MUTATE_IMMUT", source, key, newVal);
        // update backing cache entry
        savedEntries.get(key)!.savedVal = newVal;
      },
    };
    if (prop.configurable != null) {
      proxy.configurable = prop.configurable;
    }
    if (prop.enumerable != null) {
      proxy.enumerable = prop.enumerable;
    }

    savedEntries.set(key, { savedVal: (obj as any)[key], getter: proxy.get });
    Object.defineProperty(obj, key, proxy);
  }

  // Changes an object to be read-only, returns its input
  function makeReadOnly<T>(o: T, source: string): T {
    if (typeof o !== "object" || o == null) {
      return o;
    } else if (
      o.constructor?.name != null &&
      skippedClasses.includes(o.constructor.name)
    ) {
      return o;
    }

    const { existed, entry: cache } = getOrInsertDefault(savedROObjects, o);

    for (const [k, entry] of cache.entries()) {
      const currentProp = Object.getOwnPropertyDescriptor(o, k);
      if (currentProp && !isWriteable(currentProp)) {
        continue;
      }
      const currentPropGetter = currentProp?.get;
      const cachedGetter = entry.getter;

      if (currentPropGetter !== cachedGetter) {
        // cache is currently holding an old property
        //  - it may have been deleted
        //  - it may have been deleted + re-set
        //    (meaning that new value is not proxied,
        //     and the current proxied value is stale)
        cache.delete(k);
        if (!currentProp) {
          logger("FORGET_DELETE_PROP_IMMUT", source, k);
        } else if (currentProp) {
          logger("FORGET_CHANGE_PROP_IMMUT", source, k);
          addProperty(o, source, k, currentProp, cache);
        }
      }
    }
    for (const [k, prop] of Object.entries(
      Object.getOwnPropertyDescriptors(o)
    )) {
      if (!cache.has(k) && isWriteable(prop)) {
        if (
          prop.hasOwnProperty("set") ||
          prop.hasOwnProperty("get") ||
          k === "current"
        ) {
          // - we currently don't handle accessor properties
          // - we currently have no other way of checking whether an object
          // is a `ref` (i.e. returned by useRef).
          continue;
        }

        if (existed) {
          logger("FORGET_ADD_PROP_IMMUT", source, k);
        }
        addProperty(o, source, k, prop, cache);
      }
    }
    return o;
  }

  return makeReadOnly;
}

export default buildMakeReadOnly;