/**
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 url = require('url');

const patcher = require('../../../hooks/patcher');
const tracker = require('../../../tracker.js');
const tagRangeUtil = require('../../models/tag-range/util');
const stackFactory = require('../../../core/stacktrace').singleton;
const { createEvent } = require('./utils');

const URL_PROPS = {
  PROTOCOL: 'protocol',
  ORIGIN: 'origin',
  USERNAME: 'username',
  PASSWORD: 'password',
  HOST: 'host',
  PATHNAME: 'pathname',
  SEARCH: 'search',
  HASH: 'hash',
  HREF: 'href'
};
const SET_HREF_PROPS = [
  URL_PROPS.HOST,
  URL_PROPS.PATHNAME,
  URL_PROPS.SEARCH,
  URL_PROPS.HASH
];
const SET_ORIGIN_TAGS = [URL_PROPS.PROTOCOL, URL_PROPS.HOST];
const SINGLE_SOURCE_KEYS = [
  URL_PROPS.PROTOCOL,
  URL_PROPS.USERNAME,
  URL_PROPS.PASSWORD,
  URL_PROPS.HOST,
  URL_PROPS.PATHNAME,
  URL_PROPS.SEARCH,
  URL_PROPS.HASH
];

/**
 * Tag hostname and port the host property is tagged
 *
 * @param {Object} urlObj the URL object being tagged
 * @param {Object} stack
 * @param {string[]} args the arguments the url.URL constructor was invoked with
 * @param {Object} sourceEvent
 */
function setHostnamePortTags(urlObj, stack, args, sourceEvent) {
  if (!urlObj._contrast_host) {
    return;
  }

  const splitIndex = urlObj._contrast_host.indexOf(':');
  const [hostname, port] = urlObj._contrast_host.split(':');
  if (!port) {
    urlObj._contrast_hostname = urlObj._contrast_host;
    return;
  }

  const hostData = tracker.getData(urlObj._contrast_host);
  if (!hostData) {
    return;
  }
  const hostnameTags = tagRangeUtil.trim(hostData.tagRanges, 0, splitIndex - 1);
  const portTags = tagRangeUtil.trim(
    hostData.tagRanges,
    splitIndex + 1,
    urlObj._contrast_host.length - 1
  );
  let event;

  if (hostnameTags.length > 0) {
    const hostnameTracked = tracker.track(hostname);
    if (hostnameTracked) {
      hostnameTracked.props.tagRanges = hostnameTags;
      urlObj._contrast_hostname = hostnameTracked.str;
      event = createEvent('url.URL', stack, hostnameTags, hostname, args, urlObj);
      event.parents.push(sourceEvent);
      event.tagRanges = hostnameTags;
      hostnameTracked.props.event = event;  
    }
  }
  if (portTags.length > 0) {
    const portTracked = tracker.track(port);
    if (portTracked) {
      portTracked.props.tagRanges = portTags;
      urlObj._contrast_port = portTracked.str;
      event = createEvent('url.URL', stack, portTags, port, args, urlObj);
      event.parents.push(sourceEvent);
      event.tagRanges = portTags;
      portTracked.props.event = event;  
    }
  }
}

/**
 * Takes an array of tag ranges, and returns a new array of tag ranges that are shifted by an offset
 *
 * @param {Object[]} tagRanges the array of input tags
 * @param {number} offset the offset to shift by
 * @returns {Object[]} the shifted tagRanges
 */
function offsetTagRanges(tagRanges, offset) {
  const tags = tagRanges.map((r) => {
    const tag = r.clone();
    tag.start += offset;
    tag.stop += offset;
    return tag;
  });
  return tags;
}

/**
 * Join a list of String properties into a single String, copying any tags from the sources to the result
 *
 * @param {Object} urlObj the url.URL object being constructed
 * @param {string[]} the list of source properties
 * @param {string[]} the list of separators to use between fields
 *
 * @returns {String} the joined string
 */
function joinProperties(urlObj, sourceProps, separators) {
  let value = '';
  let sepIdx = 0;

  // build up the actual value
  for (const prop of sourceProps) {
    value += urlObj[prop];
    value += separators[sepIdx++];
  }

  // copy tag ranges
  let valIdx = 0;
  sepIdx = 0;
  let tags = [];

  for (const prop of sourceProps) {
    const trackedPropData = tracker.getData(urlObj[`_contrast_${prop}`]);
    if (trackedPropData) {
      tags = tagRangeUtil.addAll(
        tags,
        offsetTagRanges(
          trackedPropData.tagRanges,
          valIdx
        )
      );
    }
    valIdx += urlObj[prop].length + separators[sepIdx++].length;
  }
  const tracked = tracker.track(value);
  if (tracked) {
    tracked.props.tagRanges = tags;
    return tracked.str;  
  }
}

/**
 * Sets the origin property on url.URL object from the protocol and host fields, copying over any tags from the sources
 *
 * @param {Object} urlObj the url.URL object being constructed
 * @param {Object} stack
 * @param {string[]} args the arguments to the url.URL constructor
 * @param {Object} sourceEvent
 */
function setOriginTags(urlObj, stack, args, sourceEvent) {
  //
  // origin is "protocol" // "host"
  //

  const trackedOrigin = joinProperties(urlObj, SET_ORIGIN_TAGS, ['//', '']);
  const trackedOriginData = tracker.getData(trackedOrigin);
  if (!trackedOriginData || trackedOriginData.tagRanges.length === 0) {
    return;
  }
  urlObj._contrast_origin = trackedOrigin;

  const event = createEvent(
    'url.URL',
    stack,
    trackedOriginData.tagRanges,
    trackedOrigin,
    args,
    urlObj
  );
  event.parents.push(sourceEvent);
  event.tagRanges = trackedOriginData.tagRanges;
  trackedOriginData.event = event;
}

/**
 * Set the href property on a new url.URL object from source properties, copying any tags from source properties
 *
 * @param {Object} urlObj the url.URL object being constructed
 * @param {Object} stack
 * @param {string[]} args the arguments to the url.URL constructor
 * @param {Object} sourceEvent
 */
function setHrefTags(urlObj, stack, args, sourceEvent) {
  //
  // href = "protocol" "//" "username" ":" "password" "@" "host" "pathname" "search" "hash"
  //
  const properties = [URL_PROPS.PROTOCOL];
  const separators = ['//'];
  if (urlObj.username && urlObj.password) {
    properties.push(URL_PROPS.USERNAME);
    properties.push(URL_PROPS.PASSWORD);
    separators.push(':');
    separators.push('@');
  } else if (urlObj.username) {
    properties.push(URL_PROPS.USERNAME);
    separators.push('@');
  }

  for (const prop of SET_HREF_PROPS) {
    properties.push(prop);
    separators.push('');
  }

  const joinedHref = joinProperties(urlObj, properties, separators);
  const joinedHrefData = tracker.getData(joinedHref);
  if (!joinedHrefData || joinedHrefData.tagRanges.length === 0) {
    return;
  }
  urlObj._contrast_href = joinedHref;
  const event = createEvent(
    'url.URL',
    stack,
    joinedHrefData.tagRanges,
    joinedHref,
    args,
    urlObj
  );
  event.parents.push(sourceEvent);
  event.tagRanges = joinedHrefData.tagRanges;
  joinedHrefData.event = event;
}

/**
 * Check if host is immediately followed by a query parameter or hash
 *
 */
function implicitPath(urlObj, input) {
  const hostIndex = input.indexOf(urlObj.host);
  const nextIndex = hostIndex + urlObj.host.length;
  if (input[nextIndex] === '?' || input[nextIndex] === '#') {
    return true;
  }
  return false;
}

/**
 * Copy the tags from the input of new url.URL to the appropriate properties of the new object
 *
 * @param {Object} urlObj the url.URL object being constructed
 * @param {String} input the string
 * @param {Object} sourceTagRanges the tags of the input string
 * @param {Object} sourceEvent
 * @param {string[]} args the arguments to url.URL constructor
 *
 * @returns {Boolean} true if tags were copied, false otherwise
 */
function copyTagsSingleSource(sourceTagRanges, sourceEvent, data) {
  let pos = 0;
  let stack;
  let event;
  let copied = false;
  const {
    args,
    args: [input],
    result: urlObj
  } = data;
  //order matters here
  for (const key of SINGLE_SOURCE_KEYS) {
    if (key === URL_PROPS.PATHNAME && implicitPath(urlObj, input)) {
      continue;
    }

    const val = urlObj[key];
    const valStart = input.indexOf(val, pos);

    if (valStart == -1 || val === '') {
      continue;
    }

    const valEnd = valStart + val.length;
    const trimmed = tagRangeUtil.trim(
      sourceTagRanges,
      pos + (valStart - pos),
      valEnd - 1
    );
    pos = valEnd;

    if (trimmed.length === 0) {
      continue;
    }

    copied = true;
    const trackedKey = `_contrast_${key}`;
    const tracked = tracker.track(val);
    if (tracked) {
      tracked.props.tagRanges = trimmed;
      urlObj[trackedKey] = tracked.str;
  
      // only create the stack once because stack creation is expensive
      stack =
        stack ||
        stackFactory.createSnapshot({
          constructorOpt: data.hooked
        })();
  
      event = createEvent('url.URL', stack, trimmed, val, args, urlObj);
      event.parents.push(sourceEvent);
      event.tagRanges = trimmed;
      tracked.props.event = event;  
    }
  }

  if (!copied) {
    return copied;
  }

  // derived props
  setHostnamePortTags(urlObj, stack, args, sourceEvent);
  setOriginTags(urlObj, stack, args, sourceEvent);
  setHrefTags(urlObj, stack, args, sourceEvent);

  return true;
}

/**
 * Call the unhooked url.URL constructor
 *
 * @param {String} the input string
 *
 * @returns {Object} a url.URL object
 */
function callUnwrapped(input) {
  const unwrapped = patcher.unwrap(url.URL);
  return new unwrapped(input);
}

/**
 * Check if the inputs to the url.URL constructor aren't tracked
 *
 * @param {Object} inputData tracker data for the input parameter
 * @param {Object} baseData tracker data for the base parameter
 *
 * @returns {Boolean} true if tracking can be skipped, false otherwise
 */
function skipTracking(inputData, baseData) {
  // if neither input or base are tracked we can just skip tracking
  if (!inputData && !baseData) {
    return true;
  }
  return false;
}

/**
 * handle function for the propatagor
 *
 * @param {Object} data
 */
function handle(data) {
  const inputData = tracker.getData(data.args[0]);
  let baseData = null;
  let copiedTags = false;

  if (data.args.length > 1) {
    baseData = tracker.getData(data.args[1]);
  }

  if (skipTracking(inputData, baseData)) {
    return;
  }

  if (data.args.length === 1) {
    copiedTags = copyTagsSingleSource(
      inputData.tagRanges,
      inputData.event,
      data
    );
  } else {
    let single = false;
    // "If input is relative, then base is required. If input is absolute, the base is ignored."
    //  if we parse the input and get the same result we know base was ignored - and so we can ignore it too
    try {
      const result = callUnwrapped(data.args[0]);
      if (result.href === data.result.href) {
        single = true;
      }
    } catch (err) {
      // if it failed we must need both
    }
    if (single) {
      copiedTags = copyTagsSingleSource(
        inputData.tagRanges,
        inputData.event,
        data
      );
    } else {
      //TODO - we'll add support for propagating data from both input and data in NODE-435
      return;
    }
  }

  if (!copiedTags) {
    return;
  }

  const p = new Proxy(data.result, {
    set(obj, prop, value) {
      // this is a quick hack to not break user code
      // TODO implement updating tags - NODE-436
      const properties = Object.values(URL_PROPS);
      for (let i = 0; i < properties.length; i++) {
        if (obj[`_contrast_${properties[i]}`]) {
          delete obj[`_contrast_${properties[i]}`];
        }
      }
      return Reflect.set(obj, prop, value);
    },
    get(obj, prop) {
      // if we're getting something other than a string fallback
      if (typeof prop !== 'string') {
        return Reflect.get(obj, prop);
      }
      if (prop.startsWith('_contrast')) {
        return undefined;
      }
      if (prop === 'toString' || prop === 'toJSON') {
        //toString() and toJSON() are equivalent to href.  If we have a proxy
        //object something must have been tracked so return the tracked href
        return function() {
          return Reflect.get(obj, '_contrast_href');
        };
      }
      const trackedProp = Reflect.get(obj, `_contrast_${prop}`);
      if (trackedProp) {
        return trackedProp;
      } else {
        return Reflect.get(obj, prop);
      }
    },
    ownKeys(target) {
      return [];
    }
  });
  data.result = p;
}

module.exports.handle = handle;
