// This file provides an extended variant of the interpreter used for desktop and liveview interaction
//
// This file lives on the renderer, not the host. It's basically a polyfill over functionality that the host can't
// provide since it doesn't have access to the dom.

import { BaseInterpreter, NodeId } from "./core";
import { SerializedEvent, serializeEvent } from "./serialize";

// okay so, we've got this JSChannel thing from sledgehammer, implicitly imported into our scope
// we want to extend it, and it technically extends base interpreter. To make typescript happy,
// we're going to bind the JSChannel_ object to the JSChannel object, and then extend it
var JSChannel_: typeof BaseInterpreter;

// @ts-ignore - this is coming from the host
if (RawInterpreter !== undefined && RawInterpreter !== null) {
  // @ts-ignore - this is coming from the host
  JSChannel_ = RawInterpreter;
}

export class NativeInterpreter extends JSChannel_ {
  intercept_link_redirects: boolean;
  ipc: any;
  editsPath: string;
  eventsPath: string;
  kickStylesheets: boolean;
  queuedBytes: ArrayBuffer[] = [];

  // eventually we want to remove liveview and build it into the server-side-events of fullstack
  // however, for now we need to support it since WebSockets in fullstack doesn't exist yet
  liveview: boolean;

  constructor(editsPath: string, eventsPath: string) {
    super();
    this.editsPath = editsPath;
    this.eventsPath = eventsPath;
    this.kickStylesheets = false;
  }

  initialize(root: HTMLElement): void {
    this.intercept_link_redirects = true;
    this.liveview = false;

    // attach an event listener on the body that prevents file drops from navigating
    // this is because the browser will try to navigate to the file if it's dropped on the window
    window.addEventListener(
      "dragover",
      function (e) {
        // // check which element is our target
        if (e.target instanceof Element && e.target.tagName != "INPUT") {
          e.preventDefault();
        }
      },
      false
    );

    window.addEventListener(
      "drop",
      function (e) {
        let target = e.target;

        if (!(target instanceof Element)) {
          return;
        }

        // Dropping a file on the window will navigate to the file, which we don't want
        e.preventDefault();
      },
      false
    );

    // attach a listener to the route that listens for clicks and prevents the default file dialog
    window.addEventListener("click", (event) => {
      const target = event.target;
      if (
        target instanceof HTMLInputElement &&
        target.getAttribute("type") === "file"
      ) {
        // Send a message to the host to open the file dialog if the target is a file input and has a dioxus id attached to it
        let target_id = getTargetId(target);
        if (target_id !== null) {
          const message = this.serializeIpcMessage("file_dialog", {
            event: "change&input",
            accept: target.getAttribute("accept"),
            directory: target.getAttribute("webkitdirectory") === "true",
            multiple: target.hasAttribute("multiple"),
            target: target_id,
            bubbles: event.bubbles,
          });
          this.ipc.postMessage(message);
          event.preventDefault();
        }
      }
    });

    // @ts-ignore - wry gives us this
    this.ipc = window.ipc;

    // make sure we pass the handler to the base interpreter
    const handler: EventListener = (event) =>
      this.handleEvent(event, event.type, true);

    super.initialize(root, handler);
  }

  serializeIpcMessage(method: string, params = {}) {
    return JSON.stringify({ method, params });
  }

  scrollTo(id: NodeId, options: ScrollIntoViewOptions): boolean {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      node.scrollIntoView(options);
      return true;
    }
    return false;
  }

  scroll(id: NodeId, x: number, y: number, behavior: ScrollBehavior): boolean {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      node.scroll({ top: y, left: x, behavior });
      return true;
    }
    return false;
  }

  getScrollHeight(id: NodeId): number | undefined {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      return node.scrollHeight;
    }
  }

  getScrollLeft(id: NodeId): number | undefined {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      return node.scrollLeft;
    }
  }

  getScrollTop(id: NodeId): number | undefined {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      return node.scrollTop;
    }
  }

  getScrollWidth(id: NodeId): number | undefined {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      return node.scrollWidth;
    }
  }

  getClientRect(
    id: NodeId
  ): { type: string; origin: number[]; size: number[] } | undefined {
    const node = this.nodes[id];
    if (node instanceof HTMLElement) {
      const rect = node.getBoundingClientRect();
      return {
        type: "GetClientRect",
        origin: [rect.x, rect.y],
        size: [rect.width, rect.height],
      };
    }
  }

  setFocus(id: NodeId, focus: boolean) {
    const node = this.nodes[id];

    if (node instanceof HTMLElement) {
      if (focus) {
        node.focus();
      } else {
        node.blur();
      }
    }
  }

  // Windows drag-n-drop fix code. Called by wry drag-n-drop handler over the event loop.
  handleWindowsDragDrop() {
    if (window.dxDragLastElement) {
      const dragLeaveEvent = new DragEvent("dragleave", {
        bubbles: true,
        cancelable: true,
      });
      window.dxDragLastElement.dispatchEvent(dragLeaveEvent);

      let data = new DataTransfer();

      // We need to mimic that there are actually files in this event for our native file engine to pick it up.
      const file = new File(["content"], "file.txt", { type: "text/plain" });
      data.items.add(file);

      const dragDropEvent = new DragEvent("drop", {
        bubbles: true,
        cancelable: true,
        dataTransfer: data,
      });
      window.dxDragLastElement.dispatchEvent(dragDropEvent);
      window.dxDragLastElement = null;
    }
  }

  handleWindowsDragOver(xPos, yPos) {
    const element = document.elementFromPoint(xPos, yPos);

    if (element != window.dxDragLastElement) {
      if (window.dxDragLastElement) {
        const dragLeaveEvent = new DragEvent("dragleave", {
          bubbles: true,
          cancelable: true,
        });
        window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
      }

      const dragOverEvent = new DragEvent("dragover", {
        bubbles: true,
        cancelable: true,
      });
      element.dispatchEvent(dragOverEvent);
      window.dxDragLastElement = element;
    }
  }

  handleWindowsDragLeave() {
    if (window.dxDragLastElement) {
      const dragLeaveEvent = new DragEvent("dragleave", {
        bubbles: true,
        cancelable: true,
      });
      window.dxDragLastElement.dispatchEvent(dragLeaveEvent);
      window.dxDragLastElement = null;
    }
  }

  // ignore the fact the base interpreter uses ptr + len but we use array...
  // @ts-ignore
  loadChild(array: number[]) {
    // iterate through each number and get that child
    let node = this.stack[this.stack.length - 1];

    for (let i = 0; i < array.length; i++) {
      let end = array[i];
      for (node = node.firstChild; end > 0; end--) {
        node = node.nextSibling;
      }
    }

    return node;
  }

  appendChildren(id: NodeId, many: number) {
    const root = this.nodes[id];
    const els = this.stack.splice(this.stack.length - many);

    for (let k = 0; k < many; k++) {
      root.appendChild(els[k]);
    }
  }

  handleEvent(event: Event, name: string, bubbles: boolean) {
    const target = event.target!;
    const realId = getTargetId(target)!;
    const contents = serializeEvent(event, target);

    // Handle the event on the virtualdom and then preventDefault if it also preventsDefault
    // Some listeners
    let body = {
      name: name,
      data: contents,
      element: realId,
      bubbles,
    };

    // Run any prevent defaults the user might've set
    // This is to support the prevent_default: "onclick" attribute that dioxus has had for a while, but is not necessary
    // now that we expose preventDefault to the virtualdom on desktop
    // Liveview will still need to use this
    this.preventDefaults(event);

    // liveview does not have synchronous event handling, so we need to send the event to the host
    if (this.liveview) {
      // Okay, so the user might've requested some files to be read
      if (
        target instanceof HTMLInputElement &&
        (event.type === "change" || event.type === "input")
      ) {
        if (target.getAttribute("type") === "file") {
          this.readFiles(target, contents, bubbles, realId, name);
          return;
        }
      }
    }
    const response = this.sendSerializedEvent(body);
    // capture/prevent default of the event if the virtualdom wants to
    if (response) {
      if (response.preventDefault) {
        event.preventDefault();
      } else {
        // Attempt to intercept if the event is a click and the default action was not prevented
        if (target instanceof Element && event.type === "click") {
          this.handleClickNavigate(event, target);
        }
      }

      if (response.stopPropagation) {
        event.stopPropagation();
      }
    }
  }

  sendSerializedEvent(body: {
    name: string;
    element: number;
    data: any;
    bubbles: boolean;
  }): EventSyncResult | void {
    if (this.liveview) {
      const message = this.serializeIpcMessage("user_event", body);
      this.ipc.postMessage(message);
    } else {
      // Run the event handler on the virtualdom
      return handleVirtualdomEventSync(this.eventsPath, JSON.stringify(body));
    }
  }

  // This should:
  // - prevent form submissions from navigating
  // - prevent anchor tags from navigating
  // - prevent buttons from submitting forms
  // - let the virtualdom attempt to prevent the event
  preventDefaults(event: Event) {
    if (event.type === "submit") {
      event.preventDefault();
    }
  }

  handleClickNavigate(event: Event, target: Element) {
    // todo call prevent default if it's the right type of event
    if (!this.intercept_link_redirects) {
      return;
    }

    // If the target is a form prevent the click event
    // from submitting the form
    let form = target.closest("form");
    if (target.tagName === "BUTTON" && (event.type == "submit" || form)) {
      event.preventDefault();
    }

    // If the target is an anchor tag, we want to intercept the click too, to prevent the browser from navigating
    let a_element = target.closest("a");
    if (a_element) {
      event.preventDefault();

      const href = a_element.getAttribute("href");
      if (href !== "" && href !== null && href !== undefined) {
        this.ipc.postMessage(
          this.serializeIpcMessage("browser_open", { href })
        );
      }
    }
  }

  enqueueBytes(bytes: ArrayBuffer) {
    this.queuedBytes.push(bytes);
  }

  flushQueuedBytes() {
    // drain the queuedBytes
    const byteArray = this.queuedBytes;
    this.queuedBytes = [];

    for (let bytes of byteArray) {
      // @ts-ignore
      this.run_from_bytes(bytes);
    }
  }

  // Run the edits the next animation frame
  rafEdits(headless: boolean, bytes: ArrayBuffer) {
    // In headless mode, the requestAnimationFrame callback is never called, so we need to run the bytes directly
    if (headless) {
      // @ts-ignore
      this.run_from_bytes(bytes);
      this.waitForRequest(headless);
    } else {
      this.enqueueBytes(bytes);
      requestAnimationFrame(() => {
        this.flushQueuedBytes();
        // With request animation frames, we use the next reqwest as a marker to know when the frame is done and it is safe to run effects
        this.waitForRequest(headless);
      });
    }
  }

  waitForRequest(headless: boolean) {
    fetch(new Request(this.editsPath))
      .then((response) => response.arrayBuffer())
      .then((bytes) => {
        this.rafEdits(headless, bytes);
      });
  }

  kickAllStylesheetsOnPage() {
    // If this function is being called and we have not explicitly set kickStylesheets to true, then we should
    // force kick the stylesheets, regardless if they have a dioxus attribute or not
    // This happens when any hotreload happens.
    let stylesheets = document.querySelectorAll("link[rel=stylesheet]");
    for (let i = 0; i < stylesheets.length; i++) {
      let sheet = stylesheets[i] as HTMLLinkElement;
      // Split up the url and add a extra random query param to force the browser to reload he asset
      const splitByQuery = sheet.href.split("?");
      let url = splitByQuery[0];
      let query = splitByQuery[1];
      if (!query) {
        query = "";
      }
      let queryParams = new URLSearchParams(query);
      // Delete the existing dx_force_reload entry if it exists
      queryParams.delete("dx_force_reload");
      // And add a new random dx_force_reload query param to force the browser to reload the asset
      queryParams.append("dx_force_reload", Math.random().toString());
      sheet.href = `${url}?${queryParams}`;
    }
  }

  //  A liveview only function
  // Desktop will intercept the event before it hits this
  async readFiles(
    target: HTMLInputElement,
    contents: SerializedEvent,
    bubbles: boolean,
    realId: NodeId,
    name: string
  ) {
    let files = target.files!;
    let file_contents: { [name: string]: number[] } = {};

    for (let i = 0; i < files.length; i++) {
      const file = files[i];
      file_contents[file.name] = Array.from(
        new Uint8Array(await file.arrayBuffer())
      );
    }

    contents.files = { files: file_contents };

    const message = this.sendSerializedEvent({
      name: name,
      element: realId,
      data: contents,
      bubbles,
    });

    this.ipc.postMessage(message);
  }
}

type EventSyncResult = {
  preventDefault: boolean;
  stopPropagation: boolean;
};

// This function sends the event to the virtualdom and then waits for the virtualdom to process it
//
// However, it's not really suitable for liveview, because it's synchronous and will block the main thread
// We should definitely consider using a websocket if we want to block... or just not block on liveview
// Liveview is a little bit of a tricky beast
function handleVirtualdomEventSync(
  endpoint: string,
  contents: string
): EventSyncResult {
  // Handle the event on the virtualdom and then process whatever its output was
  const xhr = new XMLHttpRequest();

  // Serialize the event and send it to the custom protocol in the Rust side of things
  xhr.open("POST", endpoint, false);
  xhr.setRequestHeader("Content-Type", "application/json");

  // hack for android since we CANT SEND BODIES (because wry is using shouldInterceptRequest)
  //
  // https://issuetracker.google.com/issues/119844519
  // https://stackoverflow.com/questions/43273640/android-webviewclient-how-to-get-post-request-body
  // https://developer.android.com/reference/android/webkit/WebViewClient#shouldInterceptRequest(android.webkit.WebView,%20android.webkit.WebResourceRequest)
  //
  // the issue here isn't that big, tbh, but there's a small chance we lose the event due to header max size (16k per header, 32k max)
  const contents_bytes = new TextEncoder().encode(contents);
  const contents_base64 = btoa(String.fromCharCode.apply(null, contents_bytes));
  xhr.setRequestHeader("dioxus-data", contents_base64);
  xhr.send();

  // Deserialize the response, and then prevent the default/capture the event if the virtualdom wants to
  return JSON.parse(xhr.responseText);
}

function getTargetId(target: EventTarget): NodeId | null {
  // Ensure that the target is a node, sometimes it's nota
  if (!(target instanceof Node)) {
    return null;
  }

  let ourTarget = target;
  let realId = null;

  while (realId == null) {
    if (ourTarget === null) {
      return null;
    }

    if (ourTarget instanceof Element) {
      realId = ourTarget.getAttribute(`data-dioxus-id`);
    }

    ourTarget = ourTarget.parentNode;
  }

  return parseInt(realId);
}
