import { basicSetup } from "codemirror";
import { EditorView, keymap, Decoration } from "@codemirror/view";
import {
  EditorState,
  StateEffect,
  StateField,
  EditorSelection,
  Compartment,
  RangeSet,
} from "@codemirror/state";
import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
import { rust, rustLanguage } from "@codemirror/lang-rust";
import { json, jsonLanguage } from "@codemirror/lang-json";
import { graphql, graphqlLanguage } from "cm6-graphql";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { githubDark } from "@ddietr/codemirror-themes/github-dark";
import { linter, lintGutter } from "@codemirror/lint";
import { language, syntaxTree } from "@codemirror/language";
import { autocompletion } from "@codemirror/autocomplete";
import { indentWithTab, deleteLine } from "@codemirror/commands";
import throttle from "lodash.throttle";
import { buildSchema } from "graphql";

import initWasm, {
  Oxc,
  OxcRunOptions,
  OxcParserOptions,
  OxcLinterOptions,
  OxcMinifierOptions,
  OxcFormatterOptions,
  OxcTypeCheckingOptions,
  graphql_schema_text,
} from "@oxc/wasm-web";

const placeholderText = `
import React, { useEffect, useRef } from 'react'

const DummyComponent:React.FC = () => {
  const ref = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (ref.current) ref.current.focus()
  }, [])

  return (
      <div>{Boolean(ref.current) ?? (
        <input type="text" ref={ref} />
      )}
      </div>
  )
}

export default DummyComponent
`.trim();

const STORAGE_KEY_CODE = "playground.code";
const STORAGE_KEY_QUERY = "playground.query";
const STORAGE_KEY_QUERY_ARGUMENTS = "playground.query_arguments";

const getStringFromStorage = (whatToGet) => {
  try {
    return localStorage.getItem(whatToGet);
  } catch (_e) {
    return "";
  }
};

const setStringToStorage = (whatToSet, value) => {
  try {
    localStorage.setItem(whatToSet, value);
  } catch (_e) {
    return;
  }
};

class Playground {
  oxc;

  runOptions;
  parserOptions;
  formatterOptions;
  linterOptions;
  minifierOptions;

  editor;
  viewer;
  queryResultsViewer;
  currentView = "ast"; // "ast" | "hir" | "format" | "minify" | "ir"
  languageConf;
  urlParams;
  viewerIsEditableConf;
  queryResultViewerIsEditableConf;
  showingQueryResultsOrArguments;

  constructor() {
    this.languageConf = new Compartment();
    this.urlParams = new URLParams();
    this.viewerIsEditableConf = new Compartment();
    this.queryResultViewerIsEditableConf = new Compartment();
    this.editor = this.initEditor();
    this.viewer = this.initViewer();
    this.queryResultsViewer = this.initQueryResultsViewer();
    this.showingQueryResultsOrArguments = "results";
  }

  initOxc() {
    this.oxc = new Oxc();
    this.runOptions = new OxcRunOptions();
    this.parserOptions = new OxcParserOptions();
    this.formatterOptions = new OxcFormatterOptions();
    this.linterOptions = new OxcLinterOptions();
    this.minifierOptions = new OxcMinifierOptions();
    this.typeCheckOptions = new OxcTypeCheckingOptions();

    this.runOxc(this.editor.state.doc.toString());
  }

  runOxc(text) {
    const sourceText = text;
    this.urlParams.updateCode(sourceText);
    this.oxc.sourceText = sourceText;
    this.updateView();
  }

  initEditor() {
    const stateListener = EditorView.updateListener.of((view) => {
      if (view.docChanged) {
        this.runOxc(view.state.doc.toString());
      }
    });

    const state = EditorState.create({
      extensions: [
        basicSetup,
        EditorView.lineWrapping,
        keymap.of([
          ...vscodeKeymap,
          indentWithTab,
          {
            key: "Delete",
            shift: deleteLine,
          },
        ]),
        javascript(),
        githubDark,
        lintGutter(),
        stateListener,
        autocompletion(),
        linter(
          () => {
            const diagnostics = this.oxc
              ? this.oxc.getDiagnostics().map((d) => ({
                  from: d.start,
                  to: d.end,
                  severity: d.severity.toLowerCase(),
                  message: d.message,
                }))
              : [];
            this.updatePanel(diagnostics);
            return diagnostics;
          },
          { delay: 0 }
        ),
      ],
      doc: this.urlParams.code || placeholderText,
    });

    return new EditorView({
      state,
      parent: document.querySelector("#editor"),
    });
  }

  runQuery() {
    if (
      // run query, and put results in query result viewer pane
      this.currentLanguage() === "graphql" &&
      this.showingQueryResultsOrArguments === "results"
    ) {
      let queryResults = this.oxc.run_query(
        this.parserOptions,
        this.viewer.state.doc.toString(),
        getStringFromStorage(STORAGE_KEY_QUERY_ARGUMENTS) ?? {}
      );

      let output =
        typeof queryResults === "string"
          ? queryResults
          : JSON.stringify(queryResults, null, 2);

      let stateUpdate = this.queryResultsViewer.state.update({
        changes: {
          from: 0,
          to: this.queryResultsViewer.state.doc.length,
          insert: output,
        },
      });

      this.queryResultsViewer.dispatch(stateUpdate);
    }
  }

  initViewer() {
    return new EditorView({
      extensions: [
        linter(
          () => {
            try {
              this.runQuery();
            } finally {
              return [];
            }
          },
          { delay: 0 }
        ),
        linter(
          (data) => {
            try {
              if (this.currentLanguage() === "graphql") {
                setStringToStorage(
                  STORAGE_KEY_QUERY,
                  data.state.doc.toString()
                );
              }
            } finally {
              return [];
            }
          },
          { delay: 0 }
        ),
        basicSetup,
        keymap.of([
          ...vscodeKeymap,
          indentWithTab,
          {
            key: "Delete",
            shift: deleteLine,
          },
        ]),
        githubDark,
        EditorState.transactionExtender.of((tr) => {
          if (!tr.docChanged) return null;

          let ext;

          if (this.currentLanguage() === "graphql") {
            ext = EditorView.editable.of(true);
          } else {
            ext = EditorView.editable.of(false);
          }

          return {
            effects: this.viewerIsEditableConf.reconfigure(ext),
          };
        }),
        this.viewerIsEditableConf.of(EditorView.editable.of(false)),
        EditorView.lineWrapping,
        this.languageConf.of(javascript()),
        // Change language according to the current view
        EditorState.transactionExtender.of((tr) => {
          if (!tr.docChanged) return null;
          const currentLanguage = tr.startState.facet(language);
          let newLanguage;
          switch (this.currentLanguage()) {
            case "json":
              if (currentLanguage == jsonLanguage) return null;
              newLanguage = json();
              break;
            case "javascript":
              if (currentLanguage == javascriptLanguage) return null;
              newLanguage = javascript();
              break;
            case "rust":
              if (currentLanguage == rustLanguage) return null;
              newLanguage = rust();
              break;
            case "graphql":
              if (currentLanguage == graphqlLanguage) return null;
              newLanguage = graphql(buildSchema(graphql_schema_text()));
              break;
          }
          return {
            effects: this.languageConf.reconfigure(newLanguage),
          };
        }),
        EditorView.domEventHandlers({
          mouseover: this.highlightEditorFromViewer.bind(this),
        }),
        autocompletion(),
      ],
      parent: document.querySelector("#viewer"),
    });
  }

  initQueryResultsViewer() {
    return new EditorView({
      extensions: [
        basicSetup,
        githubDark,
        keymap.of([
          ...vscodeKeymap,
          indentWithTab,
          {
            key: "Delete",
            shift: deleteLine,
          },
        ]),
        json(),
        EditorState.transactionExtender.of((tr) => {
          if (!tr.docChanged) return null;

          let ext;

          if (this.showingQueryResultsOrArguments === "arguments") {
            ext = EditorView.editable.of(true);
          } else {
            ext = EditorView.editable.of(false);
          }

          return {
            effects: this.queryResultViewerIsEditableConf.reconfigure(ext),
          };
        }),
        this.queryResultViewerIsEditableConf.of(EditorView.editable.of(false)),
        linter(
          (data) => {
            if (this.showingQueryResultsOrArguments === "arguments") {
              try {
                let parsed = JSON.parse(data.state.doc.toString()); // parse so that if the json is invalid we will not save it because we will have thrown an error instead
                if (parsed) {
                  if (typeof parsed === "object") {
                    if (
                      Object.entries(parsed).some(
                        // todo: this only does depth 1, we should do depth n as in: inside arrays
                        (x) => typeof x[1] === "object" && !Array.isArray(x[1])
                      )
                    ) {
                      return [
                        {
                          from: 0,
                          to: data.state.doc.length,
                          message:
                            "This is invalid for query arguments. The arguments will not be saved until there is are no subobjects in the object.",
                          severity: "error",
                        },
                      ];
                    } else {
                      setStringToStorage(
                        STORAGE_KEY_QUERY_ARGUMENTS,
                        JSON.stringify(
                          JSON.parse(data.state.doc.toString()), // parse so that if the json is invalid we will not save it because we will have thrown an error instead
                          null,
                          2
                        )
                      );
                    }
                  } else {
                    return [
                      {
                        from: 0,
                        to: data.state.doc.length,
                        message:
                          "This is invalid for query arguments. The arguments will not be saved until there is an object at the top level.",
                        severity: "error",
                      },
                    ];
                  }
                } else {
                  return [
                    {
                      from: 0,
                      to: data.state.doc.length,
                      message:
                        "This is invalid for query arguments. The arguments will not be saved until there is an object at the top level.",
                      severity: "error",
                    },
                  ];
                }
              } catch {
                // invalid json in arguments view
                return [
                  {
                    from: 0,
                    to: data.state.doc.length,
                    message:
                      "This is invalid JSON. Will not be saved until it is valid.",
                    severity: "error",
                  },
                ];
              }
            }
            return [];
          },
          { delay: 0 }
        ),
        lintGutter(),
      ],
      parent: document.querySelector("#query-results-viewer"),
    });
  }

  run() {
    const start = new Date();
    this.oxc.run(
      this.runOptions,
      this.parserOptions,
      this.linterOptions,
      this.formatterOptions,
      this.minifierOptions,
      this.typeCheckOptions
    );
    const elapsed = new Date() - start;
    document.getElementById("duration").innerText = `${elapsed}ms`;
  }

  currentLanguage() {
    switch (this.currentView) {
      case "ir":
        return "rust";
      case "ast":
      case "hir":
        return "json";
      case "query":
        return "graphql";
      default:
        return "javascript";
    }
  }

  updatePanel(diagnostics) {
    const panel = document.getElementById("panel");
    panel.innerText = diagnostics
      .map((d) => {
        const emoji = {
          error: "❗",
          warning: "⚠️",
          advice: "ℹ️",
        }[d.severity.toLowerCase()];
        return `${emoji} ${d.message}`;
      })
      .join("\n\n");
    panel.scrollTop = panel.scrollHeight;
  }

  updateView(view) {
    view = view || this.currentView;
    this.currentView = view;

    document.getElementById("mangle").style.visibility = "hidden";
    document.getElementById("ir-copy").style.display = "none";
    document.getElementById("query-args-or-outputs").style.display = "none";
    document.getElementById("duration").style.display = "inline";
    document.getElementById("query-results-viewer").style.display = "none";
    this.runOptions.format = false;
    this.runOptions.hir = false;
    this.runOptions.minify = false;

    let text;
    switch (this.currentView) {
      case "ast":
        this.run();
        text = JSON.stringify(this.oxc.ast, null, 2);
        break;
      case "hir":
        this.runOptions.hir = true;
        this.run();
        text = JSON.stringify(this.oxc.hir, null, 2);
        break;
      case "ir":
        document.getElementById("ir-copy").style.display = "inline";
        this.runOptions.ir = true;
        this.run();
        text = this.oxc.ir;
        break;
      case "format":
        this.runOptions.format = true;
        this.run();
        text = this.oxc.formattedText;
        break;
      case "minify":
        document.getElementById("mangle").style.visibility = "visible";
        this.runOptions.minify = true;
        this.run();
        text = this.oxc.minifiedText;
        break;
      case "query":
        document.getElementById("query-args-or-outputs").style.display =
          "inline";
        document.getElementById("query-results-viewer").style.display =
          "inline";
        document.getElementById("duration").style.display = "none";
        let savedQuery = getStringFromStorage(STORAGE_KEY_QUERY);
        if (!savedQuery) {
          text = `
query {
  File {
    import {
      from_path @output

      specific_import @fold {
        original_name @output
      }

      default_import @fold {
        local_name @output
      }
    }
  }
}`.trim();
        } else {
          text = savedQuery;
        }
        break;
    }

    this.updateEditorText(this.viewer, text);
    this.runQuery();
  }

  changeBetweenQueryResultsAndQueryArgumentsView() {
    this.showingQueryResultsOrArguments =
      this.showingQueryResultsOrArguments === "results"
        ? "arguments"
        : "results";

    const { classList } = document.getElementById("query-args-or-outputs");
    switch (this.showingQueryResultsOrArguments) {
      case "results":
        this.runQuery();
        classList.add("query-button-red");
        classList.remove("query-button-green");
        break;
      case "arguments":
        this.updateEditorText(
          this.queryResultsViewer,
          getStringFromStorage(STORAGE_KEY_QUERY_ARGUMENTS) ?? "{}"
        );
        classList.add("query-button-green");
        classList.remove("query-button-red");
        break;
      default:
        throw new Error(
          `Unknown value for this.showingQueryResultsOrArguments: ${this.showingQueryResultsOrArguments}`
        );
    }
  }

  updateEditorText(instance, text) {
    const transaction = instance.state.update({
      changes: { from: 0, to: instance.state.doc.length, insert: text },
    });
    instance.dispatch(transaction);
  }

  highlightEditorRange(view, range) {
    if (range.from === 0 && range.to === 0) {
      return;
    }
    const addHighlight = StateEffect.define({
      map: ({ from, to }, change) => ({
        from: change.mapPos(from),
        to: change.mapPos(to),
      }),
    });
    const highlightField = StateField.define({
      create() {
        return Decoration.none;
      },
      update(highlights, tr) {
        highlights = RangeSet.empty;
        for (let e of tr.effects) {
          if (e.is(addHighlight)) {
            highlights = highlights.update({
              add: [Playground.highlightMark.range(e.value.from, e.value.to)],
            });
          }
        }
        return highlights;
      },
      provide: (f) => EditorView.decorations.from(f),
    });
    const effects = [addHighlight.of(range)];
    if (!view.state.field(highlightField, false)) {
      effects.push(
        StateEffect.appendConfig.of([highlightField, Playground.highlightTheme])
      );
    }
    view.dispatch({ effects });
  }

  getTextFromView(view, from, to) {
    return view.state.doc.sliceString(from, to);
  }

  static highlightMark = Decoration.mark({ class: "cm-highlight" });
  static highlightTheme = EditorView.baseTheme({
    ".cm-highlight": { background: "#3392FF44" },
  });

  // Highlight the editor by searching for `start` and `end` values.
  highlightEditorFromViewer(e, view) {
    if (this.currentLanguage() != "json") {
      return;
    }
    const pos = view.posAtCoords(e);
    const tree = syntaxTree(view.state);
    let cursor = tree.cursorAt(pos);
    // Go up and find the `type` key
    while (true) {
      if (view.state.doc.sliceString(cursor.from, cursor.to) == '"type"') {
        break;
      }
      if (!cursor.prev()) {
        break;
      }
    }
    // Go down and find the `start` and `end` keys
    let start, end;
    while (true) {
      if (
        !start &&
        this.getTextFromView(view, cursor.from, cursor.to) == '"start"'
      ) {
        cursor.next();
        start = this.getTextFromView(view, cursor.from, cursor.to);
      }
      if (
        !end &&
        this.getTextFromView(view, cursor.from, cursor.to) == '"end"'
      ) {
        cursor.next();
        end = this.getTextFromView(view, cursor.from, cursor.to);
      }
      if (start && end) {
        break;
      }
      if (!cursor.next()) {
        break;
      }
    }
    // if we didn't find a start or an end, return early
    if (start === undefined || end === undefined) return;

    this.highlightEditorRange(
      this.editor,
      EditorSelection.range(Number(start), Number(end))
    );
  }
}

// Code copied from Rome
// <https://github.com/rome/tools/blob/665bb9d810b4ebf4ea82b72df20ad79b8fa3a3d0/website/src/playground/utils.ts#L141-L181>
class URLParams {
  // Safari/Webkit/JSC/whatever only allows setting a URL 50 times within 30 seconds
  // set our maximum update frequency just under that to avoid any chance of hitting it
  static URL_UPDATE_THROTTLE = 30000 / 40;

  params;
  code;

  constructor() {
    this.params = new URLSearchParams(window.location.search);
    this.code = this.params.has("code")
      ? this.decodeCode(this.params.get("code"))
      : getStringFromStorage(STORAGE_KEY_CODE);
  }

  updateCode = throttle(
    (code) => {
      this.code = this.encodeCode(code);
      this.params.set("code", this.code);
      const url = `${window.location.protocol}//${window.location.host}${
        window.location.pathname
      }?${this.params.toString()}`;
      window.history.replaceState({ path: url }, "", url);
      setStringToStorage(STORAGE_KEY_CODE, code);
    },
    URLParams.URL_UPDATE_THROTTLE,
    { trailing: true }
  );

  // See https://developer.mozilla.org/en-US/docs/Web/API/btoa#unicode_strings
  encodeCode(code) {
    return btoa(this.toBinary(code));
  }

  decodeCode(encoded) {
    try {
      return this.fromBinary(atob(encoded));
    } catch {
      return encoded;
    }
  }

  // convert a Unicode string to a string in which
  // each 16-bit unit occupies only one byte
  toBinary(input) {
    const codeUnits = new Uint16Array(input.length);
    for (let i = 0; i < codeUnits.length; i++) {
      codeUnits[i] = input.charCodeAt(i);
    }

    const charCodes = new Uint8Array(codeUnits.buffer);
    let result = "";
    for (let i = 0; i < charCodes.byteLength; i++) {
      result += String.fromCharCode(charCodes[i]);
    }
    return result;
  }

  fromBinary(binary) {
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < bytes.length; i++) {
      bytes[i] = binary.charCodeAt(i);
    }
    const charCodes = new Uint16Array(bytes.buffer);
    let result = "";
    for (let i = 0; i < charCodes.length; i++) {
      result += String.fromCharCode(charCodes[i]);
    }
    return result;
  }
}

async function main() {
  const playground = new Playground();

  await initWasm();

  playground.initOxc();

  window.setTimeout(function () {
    playground.editor.focus();
  }, 0);

  document.getElementById("loading").remove();

  document.getElementById("ast").onclick = () => {
    playground.updateView("ast");
  };

  document.getElementById("hir").onclick = () => {
    playground.updateView("hir");
  };

  document.getElementById("ir").onclick = () => {
    playground.updateView("ir");
  };

  document.getElementById("ir-copy").onclick = () => {
    navigator.clipboard.writeText(playground.oxc.ir);
  };

  // document.getElementById("format").onclick = () => {
  // playground.updateView("format");
  // };

  document.getElementById("minify").onclick = function () {
    playground.updateView("minify");
  };

  document.getElementById("query").onclick = () => {
    playground.updateView("query");
  };

  document.getElementById("query-args-or-outputs").onclick = () => {
    playground.changeBetweenQueryResultsAndQueryArgumentsView();
    if (playground.showingQueryResultsOrArguments === "results") {
      document.getElementById("query-args-or-outputs").innerText =
        "Show Query Arguments";
    } else {
      document.getElementById("query-args-or-outputs").innerText =
        "Show Query Results";
    }
  };

  document.getElementById("syntax").onchange = function () {
    const checked = document.getElementById("syntax-checkbox").checked;
    playground.runOptions.syntax = checked;
    // Need to repaint the editor to clear the rendered linter diagnostics
    const sourceText = playground.oxc.sourceText;
    playground.updateEditorText(playground.editor, "");
    playground.updateView();
    playground.updateEditorText(playground.editor, sourceText);
  };

  document.getElementById("lint").onchange = function () {
    const checked = document.getElementById("lint-checkbox").checked;
    playground.runOptions.lint = checked;
    // Need to repaint the editor to clear the rendered linter diagnostics
    const sourceText = playground.oxc.sourceText;
    playground.updateEditorText(playground.editor, "");
    playground.updateView();
    playground.updateEditorText(playground.editor, sourceText);
  };

  document.getElementById("mangle").onchange = function () {
    const checked = document.getElementById("mangle-checkbox").checked;
    playground.minifierOptions.mangle = checked;
    playground.updateView("minify");
  };

  document.getElementById("type-check").onchange = function () {
    const checked = document.getElementById("type-check-checkbox").checked;
    playground.runOptions.type_check = checked;
    playground.updateView();
  };
}

main();
