import {
  ActionDispatch,
  Suspense,
  useCallback,
  useDeferredValue,
  useEffect,
  useMemo,
  useReducer,
  useRef,
  useState,
} from "react";
import {
  ErrorMessage,
  Header,
  setupMonaco,
  useTheme,
  downloadZip,
} from "shared";
import { FileHandle, PositionEncoding, Workspace } from "ty_wasm";
import {
  copyAsMarkdown,
  copyAsMarkdownLink,
  persist,
  persistLocal,
  restore,
} from "./Editor/persist";
import { loader } from "@monaco-editor/react";
import tySchema from "../../../ty.schema.json";
import Chrome, { formatError } from "./Editor/Chrome";

export const SETTINGS_FILE_NAME = "ty.json";

export default function Playground() {
  const [theme, setTheme] = useTheme();
  const [version, setVersion] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [workspace, setWorkspace] = useState<Workspace | null>(null);
  const [files, dispatchFiles] = useReducer(filesReducer, INIT_FILES_STATE);

  const workspacePromiseRef = useRef<Promise<Workspace> | null>(null);
  if (workspacePromiseRef.current == null) {
    workspacePromiseRef.current = startPlayground().then((fetched) => {
      setVersion(fetched.version);
      const workspace = new Workspace("/", PositionEncoding.Utf16, {});
      restoreWorkspace(workspace, fetched.workspace, dispatchFiles, setError);
      setWorkspace(workspace);
      return workspace;
    });
  }
  // This is safe as this is only called once on startup.
  // We need useRef to avoid duplicate initialization when
  // running locally due to react rendering
  // everything twice in strict mode in debug builds.
  // eslint-disable-next-line react-hooks/refs
  const workspacePromise = workspacePromiseRef.current;

  const fileName = useMemo(() => {
    return (
      files.index.find((file) => file.id === files.selected)?.name ?? "lib.py"
    );
  }, [files.index, files.selected]);

  usePersistLocally(files);

  const handleShare = useCallback(async () => {
    const serialized = serializeFiles(files);

    if (serialized != null) {
      await persist(serialized);
    }
  }, [files]);

  const handleCopyMarkdown = useCallback(async () => {
    const serialized = serializeFiles(files);

    if (serialized != null) {
      await copyAsMarkdown(serialized);
    }
  }, [files]);

  const handleCopyMarkdownLink = useCallback(async () => {
    const serialized = serializeFiles(files);

    if (serialized != null) {
      await copyAsMarkdownLink(serialized);
    }
  }, [files]);

  const handleDownload = useCallback(async () => {
    const serialized = serializeFiles(files);

    if (serialized != null) {
      const downloadFiles = { ...serialized.files };

      if (SETTINGS_FILE_NAME in downloadFiles) {
        try {
          const toml = await import("smol-toml");
          const tomlContent = toml.stringify(
            JSON.parse(downloadFiles[SETTINGS_FILE_NAME]),
          );
          delete downloadFiles[SETTINGS_FILE_NAME];
          downloadFiles["ty.toml"] = tomlContent;
        } catch {
          // Keep the original JSON file if conversion fails.
        }
      }

      await downloadZip(downloadFiles, "ty-playground");
    }
  }, [files]);
  const handleFileAdded = useCallback((workspace: Workspace, name: string) => {
    let handle = null;

    if (name === SETTINGS_FILE_NAME) {
      updateOptions(workspace, "{}", setError);
    } else {
      handle = workspace.openFile(name, "");
    }

    dispatchFiles({ type: "add", name, handle, content: "" });
  }, []);

  const handleFileChanged = useCallback(
    (workspace: Workspace, content: string) => {
      if (files.selected == null) {
        return;
      }

      const handle = files.handles[files.selected];

      if (handle != null) {
        updateFile(workspace, handle, content, setError);
      } else if (fileName === SETTINGS_FILE_NAME) {
        updateOptions(workspace, content, setError);
      }

      dispatchFiles({
        type: "change",
        id: files.selected,
        content,
      });
    },
    [fileName, files.handles, files.selected],
  );

  const handleFileRenamed = useCallback(
    (workspace: Workspace, file: FileId, newName: string) => {
      if (newName.startsWith("/")) {
        setError("File names cannot start with '/'.");
        return;
      }
      if (newName.startsWith("vendored:")) {
        setError("File names cannot start with 'vendored:'.");
        return;
      }

      const handle = files.handles[file];
      let newHandle: FileHandle | null = null;
      if (handle == null) {
        updateOptions(workspace, null, setError);
      } else {
        workspace.closeFile(handle);
      }

      if (newName === SETTINGS_FILE_NAME) {
        updateOptions(workspace, files.contents[file], setError);
      } else {
        newHandle = workspace.openFile(newName, files.contents[file]);
      }

      dispatchFiles({ type: "rename", id: file, to: newName, newHandle });
    },
    [files.contents, files.handles],
  );

  const handleFileRemoved = useCallback(
    (workspace: Workspace, file: FileId) => {
      const handle = files.handles[file];
      if (handle == null) {
        updateOptions(workspace, null, setError);
      } else {
        workspace.closeFile(handle);
      }

      dispatchFiles({ type: "remove", id: file });
    },
    [files.handles],
  );

  const handleFileSelected = useCallback((file: FileId) => {
    dispatchFiles({ type: "selectFile", id: file });
  }, []);

  const handleVendoredFileSelected = useCallback((handle: FileHandle) => {
    dispatchFiles({ type: "selectVendoredFile", handle });
  }, []);

  const handleVendoredFileCleared = useCallback(() => {
    dispatchFiles({ type: "clearVendoredFile" });
  }, []);

  const handleReset = useCallback(() => {
    if (workspace == null) {
      return;
    }

    // Close all open files
    for (const file of files.index) {
      const handle = files.handles[file.id];

      if (handle != null) {
        try {
          workspace.closeFile(handle);
        } catch (e) {
          setError(formatError(e));
        }
      }
    }

    dispatchFiles({ type: "reset" });

    restoreWorkspace(workspace, DEFAULT_WORKSPACE, dispatchFiles, setError);
  }, [files.handles, files.index, workspace]);

  return (
    <main className="flex flex-col h-full bg-ayu-background dark:bg-ayu-background-dark">
      <Header
        theme={theme}
        tool="ty"
        version={version}
        onChangeTheme={setTheme}
        edit={files.revision}
        onShare={handleShare}
        onCopyMarkdownLink={handleCopyMarkdownLink}
        onCopyMarkdown={handleCopyMarkdown}
        onDownload={handleDownload}
        onReset={workspace == null ? undefined : handleReset}
      />

      <Suspense fallback={<Loading />}>
        <Chrome
          files={files}
          workspacePromise={workspacePromise}
          theme={theme}
          selectedFileName={fileName}
          onAddFile={handleFileAdded}
          onRenameFile={handleFileRenamed}
          onRemoveFile={handleFileRemoved}
          onSelectFile={handleFileSelected}
          onChangeFile={handleFileChanged}
          onSelectVendoredFile={handleVendoredFileSelected}
          onClearVendoredFile={handleVendoredFileCleared}
        />
      </Suspense>
      {error ? (
        <div
          style={{
            position: "fixed",
            left: "10%",
            right: "10%",
            bottom: "10%",
          }}
        >
          <ErrorMessage>{error}</ErrorMessage>
        </div>
      ) : null}
    </main>
  );
}

export const DEFAULT_SETTINGS = JSON.stringify(
  {
    environment: {
      "python-version": "3.14",
    },
    rules: {
      "undefined-reveal": "ignore",
    },
  },
  null,
  4,
);

const DEFAULT_PROGRAM = `from typing import Literal

type Style = Literal["italic", "bold", "underline"]

# Add parameter annotations \`line: str, word: str, style: Style\` and a return
# type annotation \`-> str\` to see if you can find the mistakes in this program.

def with_style(line, word, style):
    if style == "italic":
        return line.replace(word, f"*{word}*")
    elif style == "bold":
        return line.replace(word, f"__{word}__")

    position = line.find(word)
    output = line + "\\n"
    output += " " * position
    output += "-" * len(word)


print(with_style("ty is a fast type checker for Python.", "fast", "underlined"))
`;

const DEFAULT_WORKSPACE = {
  files: {
    "main.py": DEFAULT_PROGRAM,
    "ty.json": DEFAULT_SETTINGS,
  },
  current: "main.py",
};

/**
 * Persists the files to local storage. This is done deferred to avoid too frequent writes.
 */
function usePersistLocally(files: FilesState): void {
  const deferredFiles = useDeferredValue(files);

  useEffect(() => {
    const serialized = serializeFiles(deferredFiles);
    if (serialized != null) {
      persistLocal(serialized);
    }
  }, [deferredFiles]);
}

export type FileId = number;

export type ReadonlyFiles = Readonly<FilesState>;

interface FilesState {
  /**
   * The currently selected file that is shown in the editor.
   */
  selected: FileId | null;

  /**
   * The files in display order (ordering is sensitive)
   */
  index: ReadonlyArray<{ id: FileId; name: string }>;

  /**
   * The database file handles by file id.
   *
   * Files without a file handle are well-known files that are only handled by the
   * playground (e.g. ty.json)
   */
  handles: Readonly<{ [id: FileId]: FileHandle | null }>;

  /**
   * The content per file indexed by file id.
   */
  contents: Readonly<{ [id: FileId]: string }>;

  /**
   * The revision. Gets incremented every time files changes.
   */
  revision: number;

  /**
   * Revision identifying this playground. Gets incremented every time the
   * playground is reset.
   */
  playgroundRevision: number;

  nextId: FileId;

  /**
   * The currently viewed vendored/builtin file, if any.
   */
  currentVendoredFile: FileHandle | null;
}

export type FileAction =
  | {
      type: "add";
      handle: FileHandle | null;
      /// The file name
      name: string;
      content: string;
    }
  | {
      type: "change";
      id: FileId;
      content: string;
    }
  | { type: "rename"; id: FileId; to: string; newHandle: FileHandle | null }
  | {
      type: "remove";
      id: FileId;
    }
  | { type: "selectFile"; id: FileId }
  | { type: "selectFileByName"; name: string }
  | { type: "reset" }
  | {
      type: "selectVendoredFile";
      handle: FileHandle;
    }
  | { type: "clearVendoredFile" };

const INIT_FILES_STATE: ReadonlyFiles = {
  index: [],
  contents: Object.create(null),
  handles: Object.create(null),
  nextId: 0,
  revision: 0,
  selected: null,
  playgroundRevision: 0,
  currentVendoredFile: null,
};

function filesReducer(
  state: Readonly<FilesState>,
  action: FileAction,
): FilesState {
  switch (action.type) {
    case "add": {
      const { handle, name, content } = action;
      const id = state.nextId;
      return {
        ...state,
        selected: id,
        index: [...state.index, { id, name }],
        handles: { ...state.handles, [id]: handle },
        contents: { ...state.contents, [id]: content },
        nextId: state.nextId + 1,
        revision: state.revision + 1,
        currentVendoredFile: null, // Clear vendored file when adding new file
      };
    }

    case "change": {
      const { id, content } = action;
      return {
        ...state,
        contents: { ...state.contents, [id]: content },
        revision: state.revision + 1,
      };
    }

    case "remove": {
      const { id } = action;

      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { [id]: _content, ...contents } = state.contents;
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { [id]: _handle, ...handles } = state.handles;

      let selected = state.selected;

      if (state.selected === id) {
        const index = state.index.findIndex((file) => file.id === id);

        selected =
          index > 0 ? state.index[index - 1].id : state.index[index + 1].id;
      }

      return {
        ...state,
        selected,
        index: state.index.filter((file) => file.id !== id),
        contents,
        handles,
        revision: state.revision + 1,
        currentVendoredFile: null, // Clear vendored file when removing file
      };
    }
    case "rename": {
      const { id, to, newHandle } = action;

      const index = state.index.findIndex((file) => file.id === id);
      const newIndex = [...state.index];
      newIndex.splice(index, 1, { id, name: to });

      return {
        ...state,
        index: newIndex,
        handles: { ...state.handles, [id]: newHandle },
      };
    }

    case "selectFile": {
      const { id } = action;

      return {
        ...state,
        selected: id,
        currentVendoredFile: null, // Clear vendored file when selecting regular file
      };
    }

    case "selectFileByName": {
      const { name } = action;

      const selected =
        state.index.find((file) => file.name === name)?.id ?? null;

      return {
        ...state,
        selected,
        currentVendoredFile: null, // Clear vendored file when selecting regular file
      };
    }

    case "reset": {
      return {
        ...INIT_FILES_STATE,
        playgroundRevision: state.playgroundRevision + 1,
        revision: state.revision + 1,
      };
    }

    case "selectVendoredFile": {
      const { handle } = action;

      return {
        ...state,
        currentVendoredFile: handle,
      };
    }

    case "clearVendoredFile": {
      return {
        ...state,
        currentVendoredFile: null,
      };
    }
  }
}

function serializeFiles(files: FilesState): {
  files: { [name: string]: string };
  current: string;
} | null {
  const serializedFiles = Object.create(null);
  let selected = null;

  for (const { id, name } of files.index) {
    serializedFiles[name] = files.contents[id];

    if (files.selected === id) {
      selected = name;
    }
  }

  if (selected == null) {
    return null;
  }

  return { files: serializedFiles, current: selected };
}

export interface InitializedPlayground {
  version: string;
  workspace: { files: { [name: string]: string }; current: string };
}

// Run once during startup. Initializes monaco, loads the wasm file, and restores the previous editor state.
async function startPlayground(): Promise<InitializedPlayground> {
  const ty = await import("ty_wasm");
  await ty.default();

  if (import.meta.env.DEV) {
    ty.initLogging(ty.LogLevel.Debug);
  } else {
    ty.initLogging(ty.LogLevel.Info);
  }

  const version = ty.version();
  const monaco = await loader.init();

  setupMonaco(monaco, {
    uri: "https://raw.githubusercontent.com/astral-sh/ruff/main/ty.schema.json",
    fileMatch: ["ty.json"],
    schema: tySchema,
  });

  const restored = await restore();

  const workspace = restored ?? DEFAULT_WORKSPACE;

  return {
    version,
    workspace,
  };
}

function updateOptions(
  workspace: Workspace | null,
  content: string | null,
  setError: (error: string | null) => void,
) {
  content = content ?? DEFAULT_SETTINGS;

  try {
    const settings = JSON.parse(content);
    workspace?.updateOptions(settings);
    setError(null);
  } catch (error) {
    setError(`Failed to update 'ty.json' options: ${formatError(error)}`);
  }
}

function updateFile(
  workspace: Workspace,
  handle: FileHandle,
  content: string,
  setError: (error: string | null) => void,
) {
  try {
    workspace.updateFile(handle, content);
    setError(null);
  } catch (error) {
    setError(`Failed to update file: ${formatError(error)}`);
  }
}

function Loading() {
  return (
    <div className="align-middle text-current text-center my-2 dark:text-white">
      Loading...
    </div>
  );
}

function restoreWorkspace(
  workspace: Workspace,
  state: {
    files: { [name: string]: string };
    current: string;
  },
  dispatchFiles: ActionDispatch<[FileAction]>,
  setError: (error: string | null) => void,
) {
  let hasSettings = false;

  // eslint-disable-next-line prefer-const
  for (let [name, content] of Object.entries(state.files)) {
    let handle = null;

    if (
      name === "knot.json" &&
      !Object.keys(state.files).includes(SETTINGS_FILE_NAME)
    ) {
      name = SETTINGS_FILE_NAME;
    }

    if (name === SETTINGS_FILE_NAME) {
      updateOptions(workspace, content, setError);
      hasSettings = true;
    } else {
      handle = workspace.openFile(name, content);
    }

    dispatchFiles({ type: "add", handle, content, name });
  }

  if (!hasSettings) {
    updateOptions(workspace, null, setError);
  }

  const selected =
    state.current === "knot.json" ? SETTINGS_FILE_NAME : state.current;

  dispatchFiles({
    type: "selectFileByName",
    name: selected,
  });
}
