// Overrides the global fetch and XMLHttpRequest open methods to modify the request URLs.
// Also overrides the attribute setter prototype to modify the request URLs
// fetch("/relative_script.js") -> fetch("http://localhost:8080/relative_script.js")
(() => {
  // ============== PARAMS ===========================
  // if the original request was: http://localhost:8080/http://proxiedsite.com/foo/bar
  // proxyOrigin is http://localhost:8080
  const proxyOrigin = "{{PROXY_ORIGIN}}";
  //const proxyOrigin = globalThis.window.location.origin;

  // if the original request was: http://localhost:8080/http://proxiedsite.com/foo/bar
  // origin is http://proxiedsite.com
  const origin = "{{ORIGIN}}";
  //const origin = (new URL(decodeURIComponent(globalThis.window.location.pathname.substring(1)))).origin
  // ============== END PARAMS ======================

  const blacklistedSchemes = [
    "ftp:",
    "mailto:",
    "tel:",
    "file:",
    "blob:",
    "javascript:",
    "about:",
    "magnet:",
    "ws:",
    "wss:",
  ];

  function rewriteURL(url) {
    if (!url) return url;

    // fetch url might be string, url, or request object
    // handle all three by downcasting to string
    const isStr = typeof url === "string";
    if (!isStr) {
      x = String(url);
      if (x == "[object Request]") {
        url = url.url;
      } else {
        url = String(url);
      }
    }

    const oldUrl = url;

    // don't rewrite special URIs
    if (blacklistedSchemes.includes(url)) return url;

    // don't rewrite invalid URIs
    try {
      new URL(url, origin);
    } catch {
      return url;
    }

    // don't double rewrite
    if (url.startsWith(`${proxyOrigin}/http://`)) return url;
    if (url.startsWith(`${proxyOrigin}/https://`)) return url;
    if (url.startsWith(`/${proxyOrigin}`)) return url;
    if (url.startsWith(`/${origin}`)) return url;
    if (url.startsWith(`/http://`)) return url;
    if (url.startsWith(`/https://`)) return url;
    if (url.startsWith(`/http%3A%2F%2F`)) return url;
    if (url.startsWith(`/https%3A%2F%2F`)) return url;
    if (url.startsWith(`/%2Fhttp`)) return url;

    //console.log(`proxychain: origin: ${origin} // proxyOrigin: ${proxyOrigin} // original: ${oldUrl}`)

    //originDomain = origin.replace("https://", "");
    let scheme = origin.split(":")[0];

    if (url.startsWith("//")) {
      url = `/${scheme}://${encodeURIComponent(url.substring(2))}`;
    } else if (url.startsWith("/")) {
      url = `/${origin}/${encodeURIComponent(url.substring(1))}`;
    } else if (
      url.startsWith(proxyOrigin) && !url.startsWith(`${proxyOrigin}/http`)
    ) {
      // edge case where client js uses current url host to write an absolute path
      url = "".replace(proxyOrigin, `${proxyOrigin}/${origin}`);
    } else if (url.startsWith(origin)) {
      url = `/${encodeURIComponent(url)}`;
    } else if (url.startsWith("http://") || url.startsWith("https://")) {
      url = `/${proxyOrigin}/${encodeURIComponent(url)}`;
    }
    console.log(`proxychain: rewrite JS URL: ${oldUrl} -> ${url}`);
    return url;
  }

  /*
                // sometimes anti-bot protections like cloudflare or akamai bot manager check if JS is hooked
                function hideMonkeyPatch(objectOrName, method, originalToString) {
                    let obj;
                    let isGlobalFunction = false;

                    if (typeof objectOrName === "string") {
                        obj = globalThis[objectOrName];
                        isGlobalFunction = (typeof obj === "function") &&
                            (method === objectOrName);
                    } else {
                        obj = objectOrName;
                    }

                    if (isGlobalFunction) {
                        const originalFunction = obj;
                        globalThis[objectOrName] = function(...args) {
                            return originalFunction.apply(this, args);
                        };
                        globalThis[objectOrName].toString = () => originalToString;
                    } else if (obj && typeof obj[method] === "function") {
                        const originalMethod = obj[method];
                        obj[method] = function(...args) {
                            return originalMethod.apply(this, args);
                        };
                        obj[method].toString = () => originalToString;
                    } else {
                        console.warn(
                            `proxychain: cannot hide monkey patch: ${method} is not a function on the provided object.`,
                        );
                    }
                }
                */
  function hideMonkeyPatch(objectOrName, method, originalToString) {
    return;
  }

  // monkey patch fetch
  const oldFetch = fetch;
  fetch = async (url, init) => {
    return oldFetch(rewriteURL(url), init);
  };
  hideMonkeyPatch("fetch", "fetch", "function fetch() { [native code] }");

  // monkey patch xmlhttprequest
  const oldOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function(
    method,
    url,
    async = true,
    user = null,
    password = null,
  ) {
    return oldOpen.call(this, method, rewriteURL(url), async, user, password);
  };
  hideMonkeyPatch(
    XMLHttpRequest.prototype,
    "open",
    'function(){if("function"==typeof eo)return eo.apply(this,arguments)}',
  );

  const oldSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function(method, url) {
    return oldSend.call(this, method, rewriteURL(url));
  };
  hideMonkeyPatch(
    XMLHttpRequest.prototype,
    "send",
    'function(){if("function"==typeof eo)return eo.apply(this,arguments)}',
  );

  // monkey patch service worker registration
  /*
  const oldRegister = ServiceWorkerContainer.prototype.register;
  ServiceWorkerContainer.prototype.register = function (scriptURL, options) {
    return oldRegister.call(this, rewriteURL(scriptURL), options);
  };
  hideMonkeyPatch(
    ServiceWorkerContainer.prototype,
    "register",
    "function register() { [native code] }",
  );
  */

  // monkey patch URL.toString() method
  const oldToString = URL.prototype.toString;
  URL.prototype.toString = function() {
    let originalURL = oldToString.call(this);
    return rewriteURL(originalURL);
  };
  hideMonkeyPatch(
    URL.prototype,
    "toString",
    "function toString() { [native code] }",
  );

  // monkey patch URL.toJSON() method
  const oldToJson = URL.prototype.toString;
  URL.prototype.toString = function() {
    let originalURL = oldToJson.call(this);
    return rewriteURL(originalURL);
  };
  hideMonkeyPatch(
    URL.prototype,
    "toString",
    "function toJSON() { [native code] }",
  );

  // Monkey patch URL.href getter and setter
  const originalHrefDescriptor = Object.getOwnPropertyDescriptor(
    URL.prototype,
    "href",
  );
  Object.defineProperty(URL.prototype, "href", {
    get: function() {
      let originalHref = originalHrefDescriptor.get.call(this);
      return rewriteURL(originalHref);
    },
    set: function(newValue) {
      originalHrefDescriptor.set.call(this, rewriteURL(newValue));
    },
  });

  // TODO: do one more pass of this by manually traversing the DOM
  // AFTER all the JS and page has loaded just in case

  // Monkey patch setter
  const elements = [
    { tag: "a", attribute: "href" },
    { tag: "img", attribute: "src" },
    // { tag: 'img', attribute: 'srcset' }, // TODO: handle srcset
    { tag: "script", attribute: "src" },
    { tag: "link", attribute: "href" },
    { tag: "link", attribute: "icon" },
    { tag: "iframe", attribute: "src" },
    { tag: "audio", attribute: "src" },
    { tag: "video", attribute: "src" },
    { tag: "source", attribute: "src" },
    // { tag: 'source', attribute: 'srcset' }, // TODO: handle srcset
    { tag: "embed", attribute: "src" },
    { tag: "embed", attribute: "pluginspage" },
    { tag: "html", attribute: "manifest" },
    { tag: "object", attribute: "src" },
    { tag: "input", attribute: "src" },
    { tag: "track", attribute: "src" },
    { tag: "form", attribute: "action" },
    { tag: "area", attribute: "href" },
    { tag: "base", attribute: "href" },
    { tag: "blockquote", attribute: "cite" },
    { tag: "del", attribute: "cite" },
    { tag: "ins", attribute: "cite" },
    { tag: "q", attribute: "cite" },
    { tag: "button", attribute: "formaction" },
    { tag: "input", attribute: "formaction" },
    { tag: "meta", attribute: "content" },
    { tag: "object", attribute: "data" },
  ];

  elements.forEach(({ tag, attribute }) => {
    const proto = document.createElement(tag).constructor.prototype;
    const descriptor = Object.getOwnPropertyDescriptor(proto, attribute);
    if (descriptor && descriptor.set) {
      Object.defineProperty(proto, attribute, {
        ...descriptor,
        set(value) {
          // calling rewriteURL will end up calling a setter for href,
          // leading to a recusive loop and a Maximum call stack size exceeded
          // error, so we guard against this with a local semaphore flag
          const isRewritingSetKey = Symbol.for("isRewritingSet");
          if (!this[isRewritingSetKey]) {
            this[isRewritingSetKey] = true;
            descriptor.set.call(this, rewriteURL(value));
            //descriptor.set.call(this, value);
            this[isRewritingSetKey] = false;
          } else {
            // Directly set the value without rewriting
            descriptor.set.call(this, value);
          }
        },
        get() {
          const isRewritingGetKey = Symbol.for("isRewritingGet");
          if (!this[isRewritingGetKey]) {
            this[isRewritingGetKey] = true;
            let oldURL = descriptor.get.call(this);
            let newURL = rewriteURL(oldURL);
            this[isRewritingGetKey] = false;
            return newURL;
          } else {
            return descriptor.get.call(this);
          }
        },
      });
    }
  });

  // monkey-patching Element.setAttribute
  const originalSetAttribute = Element.prototype.setAttribute;
  Element.prototype.setAttribute = function(name, value) {
    const isMatchingElement = elements.some((element) => {
      return this.tagName.toLowerCase() === element.tag &&
        name.toLowerCase() === element.attribute;
    });
    if (isMatchingElement) {
      value = rewriteURL(value);
    }
    originalSetAttribute.call(this, name, value);
  };

  // sometimes, libraries will set the Element.innerHTML or Element.outerHTML directly with a string instead of setters.
  // in this case, we intercept it, create a fake DOM, parse it and then rewrite all attributes that could
  // contain a URL. Then we return the replacement innerHTML/outerHTML with redirected links.
  function rewriteInnerHTML(html, elements) {
    const isRewritingHTMLKey = Symbol.for("isRewritingHTML");

    // Check if already processing
    if (document[isRewritingHTMLKey]) {
      return html;
    }

    const tempContainer = document.createElement("div");
    document[isRewritingHTMLKey] = true;

    try {
      tempContainer.innerHTML = html;

      // Create a map for quick lookup
      const elementsMap = new Map(elements.map((e) => [e.tag, e.attribute]));

      // Loop-based DOM traversal
      const nodes = [...tempContainer.querySelectorAll("*")];
      for (const node of nodes) {
        const attribute = elementsMap.get(node.tagName.toLowerCase());
        if (attribute && node.hasAttribute(attribute)) {
          const originalUrl = node.getAttribute(attribute);
          const rewrittenUrl = rewriteURL(originalUrl);
          node.setAttribute(attribute, rewrittenUrl);
        }
      }

      return tempContainer.innerHTML;
    } finally {
      // Clear the flag
      document[isRewritingHTMLKey] = false;
    }
  }

  // Store original setters
  const originalSetters = {};

  ["innerHTML", "outerHTML"].forEach((property) => {
    const descriptor = Object.getOwnPropertyDescriptor(
      Element.prototype,
      property,
    );
    if (descriptor && descriptor.set) {
      originalSetters[property] = descriptor.set;

      Object.defineProperty(Element.prototype, property, {
        ...descriptor,
        set(value) {
          const isRewritingHTMLKey = Symbol.for("isRewritingHTML");
          if (!this[isRewritingHTMLKey]) {
            this[isRewritingHTMLKey] = true;
            try {
              // Use custom logic
              descriptor.set.call(this, rewriteInnerHTML(value, elements));
            } finally {
              this[isRewritingHTMLKey] = false;
            }
          } else {
            // Use original setter in recursive call
            originalSetters[property].call(this, value);
          }
        },
      });
    }
  });
})();
