const True = true;
const False = false;
const None = undefined;

let app = undefined;
let mounted_app = undefined;

function initUnoCss() {
  if (window.__unocss_runtime === undefined) return;

  const renderedClasses = new Set();
  let queue = Promise.resolve();
  let isInitialized = false;

  new MutationObserver((mutations) => {
    let newClassesString = "";
    function appendClass(className) {
      if (renderedClasses.has(className)) return;
      renderedClasses.add(className);
      newClassesString += className + " ";
    }
    for (const mutation of mutations) {
      if (mutation.type === "attributes") {
        for (const className of mutation.target.classList) appendClass(className);
      } else if (mutation.type === "childList") {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;
          for (const el of [node, ...node.querySelectorAll("*")]) {
            for (const className of el.classList) appendClass(className);
          }
        }
      }
    }
    if (newClassesString.length === 0) return;
    queue = queue.then(async () => {
      await window.__unocss_runtime.extract(newClassesString);
      if (isInitialized) return;
      for (const style of document.querySelectorAll("style[data-unocss-runtime-layer]"))
        document.head.appendChild(style);
      document.getElementById("app").classList.remove("nicegui-unocss-loading");
      isInitialized = true;
    });
  }).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["class"] });
}

function applyColors(colors) {
  const quasarColors = [
    "primary",
    "secondary",
    "accent",
    "dark",
    "dark-page",
    "positive",
    "negative",
    "info",
    "warning",
  ];
  let customCSS = "";
  for (let color in colors) {
    if (quasarColors.includes(color)) continue;
    const colorName = color.replaceAll("_", "-");
    const colorVar = "--q-" + colorName;
    document.body.style.setProperty(colorVar, colors[color]);
    customCSS += `.text-${colorName} { color: var(${colorVar}) !important; }\n`;
    customCSS += `.bg-${colorName} { background-color: var(${colorVar}) !important; }\n`;
  }
  if (!customCSS) return;
  const style = document.createElement("style");
  style.innerHTML = customCSS;
  style.dataset.niceguiCustomColors = "";
  document.head.querySelectorAll("[data-nicegui-custom-colors]").forEach((el) => el.remove());
  document.getElementsByTagName("head")[0].appendChild(style);
}

let darkSetter = undefined;

function setDark(dark) {
  if (dark === null) dark = None;
  darkSetter?.(dark);
  document
    .getElementById("nicegui-color-scheme")
    .setAttribute("content", dark === None ? "normal" : dark ? "dark" : "light");
}

function parseElements(raw_elements) {
  return JSON.parse(
    raw_elements
      .replace(/&#36;/g, "$")
      .replace(/&#96;/g, "`")
      .replace(/&gt;/g, ">")
      .replace(/&lt;/g, "<")
      .replace(/&amp;/g, "&"),
  );
}

function replaceUndefinedAttributes(element) {
  element.class ??= [];
  element.style ??= {};
  element.props ??= {};
  element.text ??= null;
  element.events ??= [];
  element.update_method ??= null;
  element.slots = {
    default: { ids: element.children || [] },
    ...(element.slots ?? {}),
  };
}

function getElement(id) {
  const _id = id instanceof Element ? id.id.slice(1) : id;
  return mounted_app.$refs["r" + _id];
}

function getHtmlElement(id) {
  let id_as_a_string = id.toString();
  if (!id_as_a_string.startsWith("c")) {
    id_as_a_string = "c" + id_as_a_string;
  }
  return document.getElementById(id_as_a_string);
}

function runMethod(target, method_name, args) {
  if (typeof target === "object") {
    if (method_name in target) {
      return target[method_name](...args);
    }
  } else {
    const element = getElement(target);
    if (element === null || element === undefined) return;
    if (method_name in element) {
      return element[method_name](...args);
    } else if (method_name in (element.$refs.qRef || [])) {
      return element.$refs.qRef[method_name](...args);
    }
  }
  let msg = `Method "${method_name}" not found.`;
  if (method_name.includes("=>") || method_name.startsWith("(")) {
    msg += " To run arbitrary JavaScript, use ui.run_javascript() instead.";
  }
  logAndEmit("error", msg);
}

function getComputedProp(target, prop_name) {
  if (typeof target === "object" && prop_name in target) {
    return target[prop_name];
  }
  const element = getElement(target);
  if (element === null || element === undefined) return;
  if (prop_name in element) {
    return element[prop_name];
  } else if (prop_name in (element.$refs.qRef || [])) {
    return element.$refs.qRef[prop_name];
  }
}

function emitEvent(event_name, ...args) {
  getElement(0).$emit(event_name, ...args);
}

function logAndEmit(level, message) {
  if (level === "error") {
    console.error(message);
  } else if (level === "warning") {
    console.warn(message);
  } else {
    console.log(message);
  }
  window.socket.emit("log", { client_id: window.clientId, level, message });
}

function stringifyEventArgs(args, event_args) {
  const result = [];
  args.forEach((arg, i) => {
    if (event_args !== null && i >= event_args.length) return;
    let filtered = {};
    if (typeof arg !== "object" || arg === null || Array.isArray(arg)) {
      filtered = arg;
    } else {
      for (let k in arg) {
        // ignore "Restricted" fields in Firefox (see #2469)
        if (k == "originalTarget") {
          try {
            arg[k].toString();
          } catch (e) {
            continue;
          }
        }
        if (event_args === null || event_args[i] === null || event_args[i].includes(k)) {
          filtered[k] = arg[k];
        }
      }
    }
    result.push(JSON.stringify(filtered, (k, v) => (v instanceof Node || v instanceof Window ? undefined : v)));
  });
  return result;
}

const waitingCallbacks = new Map();
function throttle(callback, time, leading, trailing, id) {
  if (time <= 0) {
    // execute callback immediately and return
    callback();
    return;
  }
  if (waitingCallbacks.has(id)) {
    if (trailing) {
      // update trailing callback
      waitingCallbacks.set(id, callback);
    }
  } else {
    if (leading) {
      // execute leading callback and set timeout to block more leading callbacks
      callback();
      waitingCallbacks.set(id, null);
    } else if (trailing) {
      // set trailing callback and set timeout to execute it
      waitingCallbacks.set(id, callback);
    }
    if (leading || trailing) {
      // set timeout to remove block and to execute trailing callback
      setTimeout(() => {
        const trailingCallback = waitingCallbacks.get(id);
        if (trailingCallback) trailingCallback();
        waitingCallbacks.delete(id);
      }, 1000 * time);
    }
  }
}

function renderRecursively(elements, id, propsContext) {
  const element = elements[id];
  if (element === undefined) {
    return;
  }

  const props = {
    id: "c" + id,
    ref: "r" + id,
    key: id, // HACK: workaround for #600 and #898
    class: element.class.join(" ") || undefined,
    style: Object.entries(element.style).reduce((str, [p, val]) => `${str}${p}:${val};`, "") || undefined,
    ...element.props,
  };
  Object.entries(props).forEach(([key, value]) => {
    if (key.startsWith(":")) {
      try {
        try {
          props[key.substring(1)] = new Function("props", `return (${value})`)(propsContext);
        } catch (e) {
          props[key.substring(1)] = eval(value);
        }
        delete props[key];
      } catch (e) {
        console.error(`Error while converting ${key} attribute to function:`, e);
      }
    }
  });
  element.events.forEach((event) => {
    let event_name = "on" + event.type[0].toLocaleUpperCase() + event.type.substring(1);
    event.specials.forEach((s) => (event_name += s[0].toLocaleUpperCase() + s.substring(1)));

    const emit = (...args) => {
      const emitter = () =>
        window.socket?.emit("event", {
          id: id,
          client_id: window.clientId,
          listener_id: event.listener_id,
          args: stringifyEventArgs(args, event.args),
        });
      const delayed_emitter = () => {
        if (window.did_handshake) emitter();
        else setTimeout(delayed_emitter, 10);
      };
      throttle(delayed_emitter, event.throttle, event.leading_events, event.trailing_events, event.listener_id);
      if (element.props["loopback"] === False && event.type == "update:modelValue") {
        element.props["model-value"] = args;
      }
    };

    let handler;
    if (event.js_handler) {
      const props = propsContext; // make `props` accessible from inside the event handler
      handler = eval(event.js_handler);
    } else {
      handler = emit;
    }

    handler = Vue.withModifiers(handler, event.modifiers);
    handler = event.keys.length ? Vue.withKeys(handler, event.keys) : handler;
    if (props[event_name]) {
      props[event_name].push(handler);
    } else {
      props[event_name] = [handler];
    }
  });
  const slots = {};
  const element_slots = {
    default: { ids: element.children || [] },
    ...element.slots,
  };
  Object.entries(element_slots).forEach(([name, data]) => {
    slots[name] = (props) => {
      const rendered = [];
      if (data.template) {
        rendered.push(
          Vue.h(
            {
              props: { props: { type: Object, default: {} } },
              template: data.template,
            },
            {
              props: props,
            },
          ),
        );
      }
      const children = data.ids.map((id) => renderRecursively(elements, id, props || propsContext));
      if (name === "default" && element.text !== null) {
        children.unshift(element.text);
      }
      return [...rendered, ...children];
    };
  });
  return Vue.h(app.config.isNativeTag(element.tag) ? element.tag : Vue.resolveComponent(element.tag), props, slots);
}

function runJavascript(code, request_id) {
  new Promise((resolve) => resolve(eval(code)))
    .catch((reason) => {
      if (reason instanceof SyntaxError) return eval(`(async() => {${code}})()`);
      else throw reason;
    })
    .then((result) => {
      if (request_id) {
        window.socket.emit("javascript_response", { request_id, client_id: window.clientId, result });
      }
    });
}

function download(src, filename, mediaType, prefix) {
  const anchor = document.createElement("a");
  if (typeof src === "string") {
    anchor.href = src.startsWith("/") ? prefix + src : src;
  } else {
    anchor.href = URL.createObjectURL(new Blob([src], { type: mediaType }));
  }
  anchor.target = "_blank";
  anchor.download = filename || "";
  document.body.appendChild(anchor);
  anchor.click();
  document.body.removeChild(anchor);
  if (typeof src !== "string") {
    URL.revokeObjectURL(anchor.href);
  }
}

function ack() {
  if (!window.socket || !window.did_handshake) return;
  if (window.ackedMessageId >= window.nextMessageId) return;
  window.socket.emit("ack", {
    client_id: window.clientId,
    next_message_id: window.nextMessageId,
  });
  window.ackedMessageId = window.nextMessageId;
}

function createRandomUUID() {
  try {
    return crypto.randomUUID();
  } catch (e) {
    // https://stackoverflow.com/a/2117523/3419103
    return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) =>
      (+c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4)))).toString(16),
    );
  }
}

const OLD_TAB_ID = sessionStorage.__nicegui_tab_closed === "false" ? sessionStorage.__nicegui_tab_id : null;
const TAB_ID =
  !sessionStorage.__nicegui_tab_id || sessionStorage.__nicegui_tab_closed === "false"
    ? (sessionStorage.__nicegui_tab_id = createRandomUUID())
    : sessionStorage.__nicegui_tab_id;
sessionStorage.__nicegui_tab_closed = "false";
window.onbeforeunload = function () {
  sessionStorage.__nicegui_tab_closed = "true";
};

function createApp(elements, options) {
  Object.entries(elements).forEach(([_, element]) => replaceUndefinedAttributes(element));
  setInterval(() => ack(), 3000);
  initUnoCss();
  return (app = Vue.createApp({
    data() {
      return {
        elements,
      };
    },
    render() {
      return renderRecursively(this.elements, 0);
    },
    mounted() {
      mounted_app = this;
      window.documentId = createRandomUUID();
      window.clientId = options.query.client_id;
      const url = (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host;
      window.path_prefix = options.prefix;
      window.nextMessageId = options.query.next_message_id;
      window.ackedMessageId = -1;
      options.query.document_id = window.documentId;
      options.query.tab_id = TAB_ID;
      options.query.old_tab_id = OLD_TAB_ID;
      window.socket = io(url, {
        path: `${options.prefix}/_nicegui_ws/socket.io`,
        query: options.query,
        extraHeaders: options.extraHeaders,
        transports:
          "prerendering" in document && document.prerendering === true
            ? ["polling", ...options.transports]
            : options.transports,
      });
      window.did_handshake = false;
      const messageHandlers = {
        connect: () => {
          function wrapFunction(originalFunction) {
            const MAX_WEBSOCKET_MESSAGE_SIZE = 1000000 - 100; // 1MB without 100 bytes of slack for the message header
            return function (...args) {
              const msg = args[0];
              if (typeof msg === "string" && msg.length > MAX_WEBSOCKET_MESSAGE_SIZE) {
                const errorMessage = `Payload size ${msg.length} exceeds the maximum allowed limit.`;
                console.error(errorMessage);
                args[0] = `42["log",{"client_id":"${window.clientId}","level":"error","message":"${errorMessage}"}]`;
                if (window.tooLongMessageTimerId) clearTimeout(window.tooLongMessageTimerId);
                const popup = document.getElementById("too_long_message_popup");
                popup.ariaHidden = false;
                window.tooLongMessageTimerId = setTimeout(() => (popup.ariaHidden = true), 5000);
              }
              return originalFunction.call(this, ...args);
            };
          }
          const transport = window.socket.io.engine.transport;
          if (transport?.ws?.send) transport.ws.send = wrapFunction(transport.ws.send);
          if (transport?.doWrite) transport.doWrite = wrapFunction(transport.doWrite);

          function finishHandshake(ok) {
            if (!ok) {
              console.log("reloading because handshake failed for clientId " + window.clientId);
              window.location.reload();
            }
            window.did_handshake = true;
            document.getElementById("popup").ariaHidden = true;
          }

          if (options.query.implicit_handshake) {
            finishHandshake(true);
          } else {
            window.socket.emit("handshake", options.query, finishHandshake);
          }
        },
        connect_error: (err) => {
          if (err.message == "timeout") {
            console.log("reloading because connection timed out");
            window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
          } else if (err.message == "Implicit handshake failed") {
            console.log("reloading because implicit handshake failed for clientId " + window.clientId);
            window.location.reload();
          }
        },
        try_reconnect: async () => {
          document.getElementById("popup").ariaHidden = false;
          await fetch(window.location.href, { headers: { "NiceGUI-Check": "try_reconnect" } });
          console.log("reloading because reconnect was requested");
          window.location.reload();
        },
        disconnect: () => {
          document.getElementById("popup").ariaHidden = false;
        },
        load_js_components: async (msg) => {
          const urls = msg.components.map((c) => `${options.prefix}/_nicegui/${options.version}/components/${c.key}`);
          const imports = await Promise.all(urls.map((url) => import(url)));
          msg.components.forEach((c, i) => app.component(c.tag, imports[i].default));
        },
        update: async (msg) => {
          let eventListenersChanged = false;
          for (const [id, element] of Object.entries(msg)) {
            if (element === null) continue;
            if (!(id in this.elements)) continue;
            const oldListenerIds = new Set((this.elements[id]?.events || []).map((ev) => ev.listener_id));
            if (element.events?.some((e) => !oldListenerIds.has(e.listener_id))) {
              delete this.elements[id];
              eventListenersChanged = true;
            }
          }
          if (eventListenersChanged) {
            logAndEmit("warning", "Event listeners changed after initial definition. Re-rendering affected elements.");
            await this.$nextTick();
          }

          for (const [id, element] of Object.entries(msg)) {
            if (element === null) {
              delete this.elements[id];
              continue;
            }
            replaceUndefinedAttributes(element);
            this.elements[id] = element;
          }

          await this.$nextTick();
          for (const [id, element] of Object.entries(msg)) {
            if (element?.update_method) {
              getElement(id)?.[element.update_method]();
            }
          }
        },
        run_javascript: (msg) => runJavascript(msg.code, msg.request_id),
        open: (msg) => {
          const url = msg.path.startsWith("/") ? options.prefix + msg.path : msg.path;
          const target = msg.new_tab ? "_blank" : "_self";
          window.open(url, target);
        },
        download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix),
        notify: (msg) => Quasar.Notify.create(msg),
      };
      const socketMessageQueue = [];
      let isProcessingSocketMessage = false;
      for (const [event, handler] of Object.entries(messageHandlers)) {
        window.socket.on(event, async (...args) => {
          if (args.length > 0 && args[0]._id !== undefined) {
            const message_id = args[0]._id;
            if (message_id < window.nextMessageId) return;
            window.nextMessageId = message_id + 1;
            delete args[0]._id;
          }
          socketMessageQueue.push(() => handler(...args));
          if (!isProcessingSocketMessage) {
            while (socketMessageQueue.length > 0) {
              const handler = socketMessageQueue.shift();
              isProcessingSocketMessage = true;
              try {
                await handler();
              } catch (e) {
                console.error(e);
              }
              isProcessingSocketMessage = false;
            }
          }
        });
      }
    },
  }));
}

// HACK: remove Quasar's rules for divs in QCard (#2265, #2301)
for (const importRule of document.styleSheets[0].cssRules) {
  if (importRule instanceof CSSImportRule && /quasar/.test(importRule.styleSheet?.href)) {
    for (const rule of Array.from(importRule.styleSheet.cssRules)) {
      if (rule instanceof CSSStyleRule && /\.q-card > div/.test(rule.selectorText)) {
        if (/\.q-card > div/.test(rule.selectorText)) rule.selectorText = ".nicegui-card-tight" + rule.selectorText;
      }
    }
  }
}
