/**
 * @vitest-environment jsdom
 */
import {
  afterEach,
  beforeEach,
  describe,
  expect,
  it,
  vi,
} from "vite-plus/test";

import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type { UserStoreOrResolver } from "../../user/index.js";
import { sortSnapshotsNewestFirst, VersioningExtension } from "./Versioning.js";
import type { VersionSnapshot } from "./Versioning.js";
import {
  createInMemoryPreviewController,
  createInMemoryVersioningEndpoints,
} from "./inMemoryVersioning.js";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function createEditor() {
  const editor = BlockNoteEditor.create();
  const div = document.createElement("div");
  editor.mount(div);
  return editor;
}

function getEditorText(editor: BlockNoteEditor<any, any, any>): string {
  return editor.prosemirrorState.doc.textContent;
}

function setEditorText(editor: BlockNoteEditor<any, any, any>, text: string) {
  editor.replaceBlocks(editor.document, [{ type: "paragraph", content: text }]);
}

/** Minimal snapshot factory for the sortSnapshotsNewestFirst unit test. */
function snap(
  id: string,
  createdAt: number,
  extra?: Partial<VersionSnapshot>,
): VersionSnapshot {
  return { id, createdAt, updatedAt: createdAt, ...extra };
}

/**
 * Wire up a real editor with the in-memory versioning adapter.
 *
 * Returns the extension instance, the editor, and helpers to seed snapshots
 * directly into the backend (bypassing the extension).
 */
function setup(opts?: {
  initialText?: string;
  withoutRestore?: boolean;
  withoutUpdateName?: boolean;
  resolveUsers?: UserStoreOrResolver;
}) {
  const editor = createEditor();
  setEditorText(editor, opts?.initialText ?? "initial doc");

  const endpoints = createInMemoryVersioningEndpoints();
  const preview = createInMemoryPreviewController(editor);

  if (opts?.withoutRestore) {
    (endpoints as any).restore = undefined;
  }
  if (opts?.withoutUpdateName) {
    (endpoints as any).rename = undefined;
  }

  const ext = VersioningExtension({
    endpoints,
    preview,
    getCurrentDocument: () => editor.document,
    resolveUsers: opts?.resolveUsers,
  })({ editor });

  /** Seed a snapshot into the backend by capturing the current editor doc. */
  const seed = async (text: string, name?: string) => {
    // Temporarily set editor text, create via endpoints, then restore.
    const savedBlocks = editor.document;
    setEditorText(editor, text);
    const blocks = editor.document;
    const snapshot = await endpoints.create!(blocks, { name });
    // Restore original text.
    editor.replaceBlocks(editor.document, savedBlocks);
    // Refresh the store so the extension can resolve the seeded snapshot by id
    // (preview/restore look snapshots up in the store, as the UI would after
    // listing).
    await ext.list();
    return snapshot;
  };

  return { ext, editor, endpoints, seed };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("sortSnapshotsNewestFirst", () => {
  it("sorts newest-first by createdAt", () => {
    const input = [snap("a", 100), snap("b", 300), snap("c", 200)];
    const sorted = sortSnapshotsNewestFirst(input);
    expect(sorted.map((s) => s.id)).toEqual(["b", "c", "a"]);
  });
});

describe("VersioningExtension", () => {
  let ctx: ReturnType<typeof setup>;

  beforeEach(() => {
    ctx = setup();
  });

  afterEach(() => {
    ctx.editor.unmount();
  });

  // -------------------------------------------------------------------------
  // Listing snapshots
  // -------------------------------------------------------------------------

  describe("listing snapshots", () => {
    it("populates the store from the backend, sorted newest-first", async () => {
      vi.useFakeTimers();

      // Seed snapshots with distinct timestamps directly via endpoints.
      await ctx.endpoints.create!([
        {
          id: "1",
          type: "paragraph" as const,
          content: "v1" as any,
          props: {} as any,
          children: [],
        },
      ]);
      vi.advanceTimersByTime(1000);
      await ctx.endpoints.create!([
        {
          id: "2",
          type: "paragraph" as const,
          content: "v2" as any,
          props: {} as any,
          children: [],
        },
      ]);
      vi.advanceTimersByTime(1000);
      await ctx.endpoints.create!([
        {
          id: "3",
          type: "paragraph" as const,
          content: "v3" as any,
          props: {} as any,
          children: [],
        },
      ]);

      const result = await ctx.ext.list();

      expect(result).toHaveLength(3);
      // Newest first: v3, v2, v1
      expect(result[0]!.createdAt).toBeGreaterThan(result[1]!.createdAt);
      expect(result[1]!.createdAt).toBeGreaterThan(result[2]!.createdAt);
      expect(ctx.ext.store.state.snapshots).toEqual(result);

      vi.useRealTimers();
    });

    it("reflects backend changes on subsequent calls", async () => {
      expect(await ctx.ext.list()).toEqual([]);

      await ctx.endpoints.create!([
        {
          id: "1",
          type: "paragraph" as const,
          content: "external" as any,
          props: {} as any,
          children: [],
        },
      ]);

      const after = await ctx.ext.list();
      expect(after).toHaveLength(1);
    });
  });

  // -------------------------------------------------------------------------
  // Creating snapshots
  // -------------------------------------------------------------------------

  describe("creating snapshots", () => {
    it("captures the current state and adds the snapshot to the store", async () => {
      setEditorText(ctx.editor, "my document content");

      const snapshot = await ctx.ext.create!({ name: "Draft 1" });

      expect(snapshot.name).toBe("Draft 1");
      expect(snapshot.id).toBeDefined();
      expect(ctx.ext.store.state.snapshots).toHaveLength(1);

      // The snapshot content should round-trip — verify by previewing.
      await ctx.ext.previewSnapshot(snapshot.id);
      expect(getEditorText(ctx.editor)).toBe("my document content");
    });

    it("maintains newest-first order when adding to existing snapshots", async () => {
      vi.useFakeTimers();

      // Seed an older snapshot.
      const old = await ctx.seed("old content", "Old");
      vi.advanceTimersByTime(1000);

      // List so the store knows about the seeded snapshot.
      await ctx.ext.list();

      const newer = await ctx.ext.create!({ name: "Newer" });

      expect(ctx.ext.store.state.snapshots[0]!.id).toBe(newer.id);
      expect(ctx.ext.store.state.snapshots[1]!.id).toBe(old.id);

      vi.useRealTimers();
    });
  });

  // -------------------------------------------------------------------------
  // Previewing snapshots
  // -------------------------------------------------------------------------

  describe("previewing snapshots", () => {
    it("shows a snapshot and tracks it in the store", async () => {
      const snap = await ctx.seed("snapshot content");

      await ctx.ext.previewSnapshot(snap.id);

      expect(ctx.ext.store.state.previewedSnapshotId).toBe(snap.id);
      expect(getEditorText(ctx.editor)).toBe("snapshot content");
    });

    it("supports comparing against an older snapshot", async () => {
      const _v1 = await ctx.seed("content v1");
      const v2 = await ctx.seed("content v2");

      // The in-memory preview controller doesn't render diffs, but the call
      // should succeed and show the primary snapshot content.
      await ctx.ext.previewSnapshot(v2.id, { compareTo: _v1.id });

      expect(getEditorText(ctx.editor)).toBe("content v2");
    });

    it("switching previews updates to the new snapshot", async () => {
      const s1 = await ctx.seed("content s1");
      const s2 = await ctx.seed("content s2");

      await ctx.ext.previewSnapshot(s1.id);
      expect(getEditorText(ctx.editor)).toBe("content s1");

      await ctx.ext.previewSnapshot(s2.id);
      expect(ctx.ext.store.state.previewedSnapshotId).toBe(s2.id);
      expect(getEditorText(ctx.editor)).toBe("content s2");
    });
  });

  // -------------------------------------------------------------------------
  // Exiting preview
  // -------------------------------------------------------------------------

  describe("exiting preview", () => {
    it("clears the preview state and restores the live document", async () => {
      setEditorText(ctx.editor, "live content");
      const snap = await ctx.seed("snapshot content");

      await ctx.ext.previewSnapshot(snap.id);
      expect(getEditorText(ctx.editor)).toBe("snapshot content");

      ctx.ext.exitPreview();

      expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
      expect(getEditorText(ctx.editor)).toBe("live content");
    });
  });

  // -------------------------------------------------------------------------
  // Restoring snapshots
  // -------------------------------------------------------------------------

  describe("restoring snapshots", () => {
    it("applies the snapshot content and exits any active preview", async () => {
      setEditorText(ctx.editor, "current doc");
      const snap = await ctx.seed("old content");

      // Enter preview first, then restore.
      await ctx.ext.previewSnapshot(snap.id);
      await ctx.ext.restore!(snap.id);

      expect(getEditorText(ctx.editor)).toBe("old content");
      expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();
    });

    it("picks up server-side backup snapshots after re-listing", async () => {
      const snap = await ctx.seed("original");
      await ctx.ext.list();

      await ctx.ext.restore!(snap.id);

      // The in-memory endpoints create a backup snapshot on restore.
      const updated = await ctx.ext.list();
      expect(updated.length).toBe(2);
      expect(updated.some((s) => s.restoredFromSnapshotId === snap.id)).toBe(
        true,
      );
    });

    it("reports restore as unavailable when endpoint omits it", () => {
      const noRestore = setup({ withoutRestore: true });
      expect(noRestore.ext.canRestore).toBe(false);
      expect(noRestore.ext.restore).toBeUndefined();
      noRestore.editor.unmount();
    });
  });

  // -------------------------------------------------------------------------
  // Updating snapshot names
  // -------------------------------------------------------------------------

  describe("updating snapshot names", () => {
    it("renames a snapshot in the store and backend", async () => {
      const snap = await ctx.seed("content", "Original");
      await ctx.ext.list();

      await ctx.ext.rename!(snap.id, "Renamed");

      // Store was updated optimistically.
      expect(ctx.ext.store.state.snapshots[0]!.name).toBe("Renamed");

      // Backend was also updated (verified via list).
      const list = await ctx.ext.list();
      expect(list.find((s) => s.id === snap.id)!.name).toBe("Renamed");
    });

    it("reports name updates as unavailable when endpoint omits it", () => {
      const noUpdate = setup({ withoutUpdateName: true });
      expect(noUpdate.ext.canRename).toBe(false);
      expect(noUpdate.ext.rename).toBeUndefined();
      noUpdate.editor.unmount();
    });
  });

  // -------------------------------------------------------------------------
  // User store (author resolution for `VersionSnapshot.by`)
  // -------------------------------------------------------------------------

  describe("user store", () => {
    it("exposes an empty user store when no resolveUsers is provided", async () => {
      expect(ctx.ext.userStore).toBeDefined();
      await ctx.ext.userStore.loadUsers(["u1"]);
      expect(ctx.ext.userStore.getUser("u1")).toBeUndefined();
    });

    it("builds a de-duped user store from a resolveUsers callback", async () => {
      const resolveUsers = vi.fn(async (ids: string[]) =>
        ids.map((id) => ({ id, username: `name-${id}`, avatarUrl: "" })),
      );
      const withUsers = setup({ resolveUsers });

      await withUsers.ext.userStore.loadUsers(["u1", "u2"]);
      expect(withUsers.ext.userStore.getUser("u1")?.username).toBe("name-u1");
      expect(withUsers.ext.userStore.getUser("u2")?.username).toBe("name-u2");

      // Already-cached ids are not re-fetched.
      await withUsers.ext.userStore.loadUsers(["u1"]);
      expect(resolveUsers).toHaveBeenCalledTimes(1);

      withUsers.editor.unmount();
    });

    it("passes `by` author ids through list() untouched", async () => {
      const editor = createEditor();
      const ext = VersioningExtension({
        endpoints: {
          list: async () => [snap("1", 100, { by: ["u1", "u2"] })],
          getContent: async () => [],
        },
        preview: createInMemoryPreviewController(editor),
        getCurrentDocument: () => editor.document,
      })({ editor });

      const result = await ext.list();

      // Raw ids are preserved — resolving them to user info is the view
      // layer's job (via `ext.userStore`), never the extension's.
      expect(result[0]!.by).toEqual(["u1", "u2"]);
      expect(result[0]!.secondaryLabel).toBeUndefined();
      expect(ext.store.state.snapshots).toEqual(result);

      editor.unmount();
    });
  });

  // -------------------------------------------------------------------------
  // End-to-end workflow
  // -------------------------------------------------------------------------

  describe("workflow: create, preview with diff, then restore", () => {
    it("handles the full version-history flow", async () => {
      vi.useFakeTimers();

      // 1. Create version 1.
      setEditorText(ctx.editor, "doc v1");
      const v1 = await ctx.ext.create!({ name: "Version 1" });

      vi.advanceTimersByTime(1000);

      // 2. Modify and create version 2.
      setEditorText(ctx.editor, "doc v2");
      const v2 = await ctx.ext.create!({ name: "Version 2" });
      expect(ctx.ext.store.state.snapshots[0]!.id).toBe(v2.id);

      // 3. Preview v1 with diff comparison against v2.
      await ctx.ext.previewSnapshot(v1.id, { compareTo: v2.id });
      expect(getEditorText(ctx.editor)).toBe("doc v1");

      // 4. Restore v1.
      await ctx.ext.restore!(v1.id);
      expect(getEditorText(ctx.editor)).toBe("doc v1");
      expect(ctx.ext.store.state.previewedSnapshotId).toBeUndefined();

      vi.useRealTimers();
    });
  });
});
