/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const _ = require('lodash');
const { PROXY_TARGET } = require('../../constants');
const { isString } = require('../../util/is-string.js');

// instances of the following classes are unwrappable
const TYPE_EXOTIC = [
  ArrayBuffer,
  Error,
  RegExp,
  Int8Array,
  Uint8Array,
  Uint8ClampedArray,
  Int16Array,
  Uint16Array,
  Int32Array,
  Uint32Array,
  Float32Array,
  Float64Array,
  // not exactly exotic but not commonly used.
  Number,
  Boolean
];

/**
 * Formalizes the association between wrapped and unwrapped versions of objects.
 */
class Mapping {
  constructor(orig, wrapped) {
    /** original (fully unwrapped). equals target if this is the only membrane the orig belongs to. */
    this.orig = orig;
    /** proxy */
    this.wrapped = wrapped;
  }
}

// P --> O
// Global tracking of every wrapping to an unwrapped object.
// This is an unfortunate implementation detail which allows
// unwrap to be a static method.
const originals = new WeakMap();

class Membrane {
  /**
   * Membrane is an abstraction on Proxy which allows us to react
   * to gets of arbitrarily nested strings. Any Object retrieved from the Proxy
   * is wrapped in a Proxy with the same handler.
   *
   * We maintain the relationship between wrapped and unwrapped Objects via a WeakMap of Mappings.
   * This association is always made with fully unwrapped Objects.
   *
   * NOTE(ehden): My current biggest gripe with this design is that none of the methods in this class
   * are meant to be used by anybody outside of the class, except for wrap, unwrap, and the constructor.
   * This isn't obvious from the implementation.
   * It'd be great to move much of this out of the class definition, keeping in mind that things like wrapArray
   * and onString are meant to be extended and may have to remain on the class itself.
   */
  constructor(enablePropertyDescriptorTracking = true) {
    // map target (object) --> Mapping
    // map wrapped --> Mapping
    this.members = new Map();
    // map target --> Set(keys)
    this.safeKeys = new Map();
    // things which have been "removed" from the membrane
    this.disabled = new Set();
    this.trackingDataCache = new Map();
    this.enablePropertyDescriptorTracking = enablePropertyDescriptorTracking;
  }

  static unwrap(object) {
    return originals.get(object) || object;
  }

  /**
   * Things to override a get with when we know and may need to use the result.
   */
  postResultOverride(prop, tar, result) {
    if (
      typeof prop === 'symbol' ||
      ['prototype', '__proto__', 'constructor'].includes(prop)
    ) {
      return result;
    }

    if (typeof result === 'function') {
      return result.bind(tar);
    }

    if (this.isKeySafe(tar, prop)) {
      return result;
    }

    // When an object is disabled with respect to a membrane, the object
    // is still contained in the membrane, but its 'get' traps are are now inert.
    if (this.isDisabled(tar)) {
      return result;
    }
  }

  /**
   * We don't want to wrap things that weren't in the membrane before wrapping.
   */
  markKeySafe(target, prop) {
    if (!this.safeKeys.has(target)) {
      return;
    }

    const safeTargetKeys = this.safeKeys.get(target);
    safeTargetKeys.add(prop);
  }

  isKeySafe(target, prop) {
    const safeSet = this.safeKeys.get(target);
    return safeSet && safeSet.has(prop);
  }

  /**
   * Disable the value and its mapping from the membrane. We can't really 'remove'
   * the object from the membrane in multi-membrane scenarios, so instead, we turn its traps off.
   *
   * This doesn't return anything---that could be really confusing in scenarios such as
   * a wrapped object M2[M1[O]] where we disable the object in M1. M1 can't return
   * a version still wrapped by M2, as one might expect in using the API if it returned
   * anything.
   *
   * @param {*} value or wrapped value
   */
  disable(value) {
    if (this.includes(value)) {
      const mapping = this.getMapping(value);
      this.disabled.add(mapping);
    }
  }

  isDisabled(value) {
    return this.includes(value) && this.disabled.has(this.getMapping(value));
  }

  includes(object) {
    return this.members.has(object) || this.members.has(Membrane.unwrap(object));
  }

  getMapping(object) {
    return this.members.get(object) || this.members.get(Membrane.unwrap(object));
  }

  /**
   * Wrap the given value in a proxy.
   */
  wrapObject(object, metadata) {
    const handler = makeHandler(this, metadata);
    const wrapped = new Proxy(object, handler);

    let orig = object;
    const proxied = object[PROXY_TARGET];
    if (proxied) {
      orig = proxied.target;
    }
    originals.set(wrapped, orig);

    this.safeKeys.set(object, new Set());

    const mapping = new Mapping(object, wrapped);
    this.members.set(object, mapping);
    this.members.set(wrapped, mapping);

    return wrapped;
  }

  /**
   * Called when isArray(value) === true.
   * The default implementation of this treats it like any other object, but
   * exists so that subclasses can treat Arrays however they want to. for
   * example, the source membrane implements array sampling in wrapArray().
   */
  wrapArray(value, metadata) {
    metadata.isArray = true;
    return this.wrapObject(value, metadata);
  }

  /**
   * Called when isString(value) === true.
   * Most subclasses are expected to overload this method.
   */
  onString(str, metadata = {}) {
    return str;
  }

  /**
   * Caches tracking data for values accessed from membrane.
   * @param {object} trackingData
   * @param {object} metadata
   */
  cacheTrackingData(trackingData, metadata) {
    const { path, target } = metadata;

    if (!target || typeof target !== 'object') {
      return;
    }

    const cached = this.trackingDataCache.get(target);
    if (!cached) {
      this.trackingDataCache.set(target, { [path]: trackingData });
    } else {
      cached[path] = trackingData;
    }
  }

  /**
   * Given the metadata describing proxy property access, will return cached
   * tracking data for the tracked value.
   * @param {object} metadata metadata from onString
   * @returns {ContrastProperties|undefined}
   */
  getCachedTrackingData(metadata) {
    const targetData = this.trackingDataCache.get(metadata.target);
    if (targetData) {
      const cached = targetData[metadata.path];
      if (cached && cached.tracked) {
        return cached;
      }
    }
  }

  /**
   * Called when instanceof Buffer === true.
   * Most subclasses are expected to overload this method.
   */
  wrapBuffer(buf, metadata = {}) {
    return buf;
  }

  /**
   * Dispatches wraps based on type of the target.
   *
   * @param {*} value
   */
  wrap(target, metadata = { target }) {
    if (target instanceof Buffer) {
      return this.wrapBuffer(target, metadata);
    }

    // verify the value is compatible with membranes
    if (!isWrappable(target)) {
      return target;
    }

    // no double wraps
    if (this.includes(target)) {
      return this.getMapping(target).wrapped;
    }

    if (isString(target)) {
      return this.onString(target, metadata);
    }

    if (Object(target) !== target) {
      return target;
    }

    if (Array.isArray(target)) {
      metadata.isArray = true;
      return this.wrapArray(target, metadata);
    }

    return this.wrapObject(target, metadata);
  }
}

/**
 * Produces the Proxy handler for use in wrapObject
 *
 * @param {Membrane} membrane The membrane instance to create a handler for
 * @param {Object} metadata The metadata to pass along the object graph for gets
 */
function makeHandler(membrane, metadata) {
  const handler = {
    get(tar, prop, recv) {
      if (prop === PROXY_TARGET) {
        // provide access to real target.
        return { target: tar, membrane };
      }

      let r = Reflect.get(tar, prop, recv);

      const postOverride = membrane.postResultOverride(prop, tar, r);
      if (postOverride) return postOverride;

      // https://www.ecma-international.org/ecma-262/7.0/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver
      // satisfy invariant
      const desc = Object.getOwnPropertyDescriptor(tar, prop);
      if (desc && desc.configurable) {
        r = membrane.wrap(r, copyMetadata(tar, prop, metadata));
      } // else: invariant case; can't wrap

      return r;
    },

    // KNOWN LIMITATION: if the descriptor is non-configurable,
    // we cannot modify it to wrap its data
    getOwnPropertyDescriptor(tar, prop) {
      let desc = Reflect.getOwnPropertyDescriptor(tar, prop);
      if (!desc) {
        return;
      }

      if (!membrane.enablePropertyDescriptorTracking) {
        return desc;
      }

      if (!desc.configurable) {
        return desc;
      }

      // XXX should we clone desc? or is modifying ok?
      if (desc.value) {
        const val = desc.value;

        const override = membrane.postResultOverride(prop, tar, val);
        if (override) {
          desc.value = override;
          return desc;
        }

        desc.value = membrane.wrap(
          desc.value,
          copyMetadata(tar, prop, metadata)
        );
      } else if (desc.get) {
        const { get, set, enumerable, configurable } = desc;

        desc = {
          set,
          enumerable,
          configurable,
          get(...args) {
            const val = get.apply(this, args);

            const override = membrane.postResultOverride(prop, tar, val);
            if (override) return override;

            return membrane.wrap(val, copyMetadata(tar, prop, metadata));
          }
        };
      }

      return desc;
    },

    /**
     * Trap set to record which properties should not be wrapped when accessed.
     *
     * XXX(ehden): We're intentionally avoiding the use of the receiver here.
     * We encountered an issue when using the given receiver with a call to
     * Reflect.set.  If we call `Reflect.set(...arguments)` and the value
     * we're setting is a tracked string which exists untracked on the target,
     * the set won't work.  Another way around this is to `delete tar[prop]`
     * before calling Reflect.set.
     *
     * The simplest way to reproduce the issue is:
     *
     *  const str = 'some string';
     *  const obj = { a:  str };
     *  const proxy = new Proxy(obj, {});
     *  const tracked = tracker.track(str);
     *  proxy.a = tracked;
     *  expect(tracker.getData(proxy.a)).to.have.property('tracked', true);
     *
     * I'm not sure why this happens. It feels like a bug in v8.
     */
    set(tar, prop, val, recv) {
      // ? seems like this key could be removed from the trackingDataCache map
      // when it's marked safe. the code will never get to looking in the map.
      membrane.markKeySafe(tar, prop);

      return Reflect.set(tar, prop, val);
    },
    defineProperty(tar, prop, desc) {
      membrane.markKeySafe(tar, prop);

      return Reflect.defineProperty(tar, prop, desc);
    },
    deleteProperty(tar, prop) {
      membrane.markKeySafe(tar, prop);
      return Reflect.deleteProperty(tar, prop);
    }
  };

  return handler;
}

/**
 * Copies a metadata object and updates its path
 *
 * @param {*} tar proxy target
 * @param {string} prop name of the property being pulled out of the membrane
 * @param {object} metadata sourceType, last target, dot-delimitted path of value being accessed
 */
function copyMetadata(tar, prop, metadata) {
  const nm = _.cloneDeepWith(metadata, (val, key) => {
    if (key === 'target') {
      // null the target that will get set below
      return null;
    }
  });

  if (!nm.path) {
    nm.path = prop || "''";
  } else if (nm.isArray) {
    nm.path += `[${prop}]`;
  } else if (prop) {
    nm.path += `.${prop}`;
  }
  nm.target = tar;

  return nm;
}

// lodash says this is the fast way to check
const { isDate } = require('util').types;
/**
 * Is the given object an `exotic' that we do not want to wrap in a membrane
 * See: NODE-171
 *
 * Added type checking for date objects
 * See: NODE-1179
 * @param {*} val
 */
function isWrappable(val) {
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Typed_arrays
  for (const type of TYPE_EXOTIC) {
    if (val instanceof type) return false;
  }
  const type = typeof val;

  if (type === 'number' || type === 'boolean') {
    return false;
  }
  if (isDate(val)) {
    return false;
  }

  return val !== null && val !== undefined;
}

module.exports = Membrane;
module.exports.isWrappable = isWrappable;
module.exports.TYPE_EXOTIC = TYPE_EXOTIC;
