import {
  CollaborationMessage,
  Command,
  CommandResult,
  CommandTypes,
  CoreCommand,
  CorePlugin,
  DispatchResult,
  EvaluationCommand,
  EvaluationPlugin,
  coreTypes,
} from "../../src";
import { MESSAGE_VERSION } from "../../src/constants";
import { toZone } from "../../src/helpers/zones";
import { Model } from "../../src/model";
import {
  corePluginRegistry,
  evaluationPluginRegistry,
  featurePluginRegistry,
  statefulUIPluginRegistry,
} from "../../src/plugins/plugin_registries";
import { UIPlugin } from "../../src/plugins/ui_plugin";
import { ModelConfig } from "../../src/types/model";
import { MockTransportService } from "../__mocks__/transport_service";
import { getTextXlsxFiles } from "../__xlsx__/read_demo_xlsx";
import { setupCollaborativeEnv } from "../collaborative/collaborative_helpers";
import {
  autoresizeColumns,
  copy,
  createSheet,
  evaluateCells,
  selectCell,
  setCellContent,
} from "../test_helpers/commands_helpers";
import {
  getCellContent,
  getCellRawContent,
  getCellText,
  getEvaluatedCell,
} from "../test_helpers/getters_helpers";
import { addTestPlugin, nextTick } from "../test_helpers/helpers";

describe("Model", () => {
  test("core plugin can refuse command from UI plugin", () => {
    class MyCorePlugin extends CorePlugin {
      allowDispatch(cmd: CoreCommand) {
        if (cmd.type === "UPDATE_CELL") {
          return CommandResult.CancelledForUnknownReason;
        }
        return CommandResult.Success;
      }
    }
    let result: DispatchResult | undefined = undefined;
    class MyUIPlugin extends UIPlugin {
      handle(cmd: Command) {
        if (cmd.type === "COPY") {
          result = this.dispatch("UPDATE_CELL", {
            col: 0,
            row: 0,
            sheetId: this.getters.getActiveSheetId(),
            content: "hello",
          });
        }
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);
    addTestPlugin(corePluginRegistry, MyCorePlugin);
    const model = new Model();
    copy(model, "A1");
    expect(result).toBeCancelledBecause(CommandResult.CancelledForUnknownReason);
  });

  test("core plugin cannot refuse command from core plugin", () => {
    let result: DispatchResult | undefined = undefined;
    class MyCorePlugin extends CorePlugin {
      allowDispatch(cmd: CoreCommand) {
        if (cmd.type === "UPDATE_CELL") {
          return CommandResult.CancelledForUnknownReason;
        }
        return CommandResult.Success;
      }
      handle(cmd: CoreCommand) {
        if (cmd.type === "CREATE_SHEET") {
          result = this.dispatch("UPDATE_CELL", {
            col: 0,
            row: 0,
            sheetId: cmd.sheetId,
            content: "Hello",
          });
        }
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);
    const model = new Model();
    createSheet(model, { sheetId: "42", position: 1 });
    expect(result).toBeSuccessfullyDispatched();
    expect(getCellText(model, "A1", "42")).toBe("Hello");
  });

  test("UI plugin cannot refuse command from UI plugin", () => {
    let result: DispatchResult | undefined = undefined;
    class MyUIPlugin extends UIPlugin {
      allowDispatch(cmd: Command) {
        if (cmd.type === "PASTE") {
          return CommandResult.CancelledForUnknownReason;
        }
        return CommandResult.Success;
      }
      handle(cmd: Command) {
        if (cmd.type === "COPY") {
          result = this.dispatch("PASTE", {
            target: [toZone("A2")],
          });
        } else if (cmd.type === "PASTE") {
          setCellContent(model, "A2", "copy&paste me");
        }
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);
    const model = new Model();
    copy(model, "A1");
    expect(result).toBeSuccessfullyDispatched();
    expect(getCellText(model, "A2")).toBe("copy&paste me");
  });

  test("UI plugins can refuse local core commands", () => {
    class MyUIPlugin extends UIPlugin {
      allowDispatch(cmd: Command) {
        if (cmd.type === "UPDATE_CELL") {
          return CommandResult.CancelledForUnknownReason;
        }
        return CommandResult.Success;
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);
    const model = new Model();

    setCellContent(model, "A1", "hello");
    expect(getCellContent(model, "A1")).toBe("");
  });

  test("Core plugins allowDispatch don't receive UI commands", () => {
    const receivedCommands: CommandTypes[] = [];
    class MyCorePlugin extends CorePlugin {
      allowDispatch(cmd: CoreCommand): CommandResult {
        receivedCommands.push(cmd.type);
        return CommandResult.Success;
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);
    const model = new Model();
    evaluateCells(model);
    expect(receivedCommands).not.toContain("EVALUATE_CELLS");
  });

  test("Core plugins handle don't receive UI commands", () => {
    const receivedCommands: CommandTypes[] = [];
    class MyCorePlugin extends CorePlugin {
      handle(cmd: CoreCommand) {
        receivedCommands.push(cmd.type);
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);
    const model = new Model();
    evaluateCells(model);
    expect(receivedCommands).not.toContain("EVALUATE_CELLS");
  });

  test("An evaluation plugin cannot dispatch non-evaluation commands", () => {
    class MyEvaluationPlugin extends EvaluationPlugin {
      handle(cmd: EvaluationCommand) {
        if (cmd.type === "CREATE_SHEET") {
          /**
           * TS ensure that the command is an evaluation command, but we want to
           * test that the runtime will throw an error if we try to dispatch a
           * non-evaluation command
           */
          //@ts-ignore
          this.dispatch("UPDATE_CELL", {
            col: 0,
            row: 0,
            sheetId: "sheetId",
            content: "hello",
          });
        }
      }
    }
    addTestPlugin(evaluationPluginRegistry, MyEvaluationPlugin);
    const model = new Model();
    expect(() => createSheet(model, { sheetId: "42" })).toThrow(
      "An evaluation plugin cannot dispatch non-evaluation commands (UPDATE_CELL)"
    );
  });

  test("Evaluation plugins allowDispatch don't receive UI commands", () => {
    const receivedCommands: CommandTypes[] = [];
    class MyEvaluationPlugin extends EvaluationPlugin {
      allowDispatch(cmd: EvaluationCommand): CommandResult {
        receivedCommands.push(cmd.type);
        return CommandResult.Success;
      }
    }
    addTestPlugin(evaluationPluginRegistry, MyEvaluationPlugin);
    const model = new Model();
    autoresizeColumns(model, [1]);
    selectCell(model, "A2");
    expect(receivedCommands).not.toContain("AUTORESIZE_COLUMNS");
    expect(receivedCommands).not.toContain("SELECT_CELL");
  });

  test("Evaluation plugins handle don't receive UI commands", () => {
    const receivedCommands: CommandTypes[] = [];
    class MyEvaluationPlugin extends EvaluationPlugin {
      handle(cmd: EvaluationCommand) {
        receivedCommands.push(cmd.type);
      }
    }
    addTestPlugin(evaluationPluginRegistry, MyEvaluationPlugin);
    const model = new Model();
    autoresizeColumns(model, [1]);
    selectCell(model, "A2");
    setCellContent(model, "A1", "hello");
    expect(receivedCommands).not.toContain("AUTORESIZE_COLUMNS");
    expect(receivedCommands).not.toContain("SELECT_CELL");
    // core and evaluation commands are still received
    expect(receivedCommands).toContain("UPDATE_CELL");
    expect(receivedCommands).toContain("START");
  });

  test("canDispatch method is exposed and works", () => {
    class MyCorePlugin extends CorePlugin {
      allowDispatch(cmd: CoreCommand) {
        if (cmd.type === "CREATE_SHEET") {
          return CommandResult.CancelledForUnknownReason;
        }
        return CommandResult.Success;
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);
    const model = new Model();
    expect(
      model.canDispatch("CREATE_SHEET", { sheetId: "42", position: 1, name: "Sheet42" })
    ).toBeCancelledBecause(CommandResult.CancelledForUnknownReason);
    expect(
      createSheet(model, { sheetId: "42", position: 1, name: "Sheet42" })
    ).toBeCancelledBecause(CommandResult.CancelledForUnknownReason);

    const sheetId = model.getters.getActiveSheetId();
    expect(
      model.canDispatch("UPDATE_CELL", { sheetId, col: 0, row: 0, content: "hey" })
    ).toBeSuccessfullyDispatched();
    expect(setCellContent(model, "A1", "hey")).toBeSuccessfullyDispatched();
    corePluginRegistry.remove("myCorePlugin");
  });

  test("Non evaluation command cannot be dispatch in a top level evaluation command", () => {
    class MyUIPlugin extends UIPlugin {
      handle(cmd: Command) {
        if (cmd.type === "EVALUATE_CELLS") {
          this.dispatch("UPDATE_CELL", {
            col: 0,
            row: 0,
            sheetId: this.getters.getActiveSheetId(),
            content: "hello",
          });
        }
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);
    const model = new Model();
    expect(() => evaluateCells(model)).toThrow(
      "A top level evaluation command cannot dispatch non-evaluation commands (UPDATE_CELL)"
    );
  });

  test("An evaluation command dispatched outside of a command loop triggers an update", async () => {
    class MyEvaluationPlugin extends EvaluationPlugin {
      handle(cmd: EvaluationCommand) {
        if (cmd.type === "START") {
          void Promise.resolve().then(() => this.dispatch("EVALUATE_CELLS"));
        }
      }
    }
    addTestPlugin(evaluationPluginRegistry, MyEvaluationPlugin);
    const model = new Model();
    const onUpdate = jest.fn();
    model.on("update", null, onUpdate);

    expect(onUpdate).not.toHaveBeenCalled();
    await nextTick();
    expect(onUpdate).toHaveBeenCalledTimes(1);
  });

  test("Cannot add UI plugin in the wrong registry", () => {
    class MyUIPlugin extends UIPlugin {}
    expect(() => addTestPlugin(corePluginRegistry, MyUIPlugin)).toThrow(
      "Plugin MyUIPlugin does not extend CorePlugin"
    );
    expect(() => addTestPlugin(evaluationPluginRegistry, MyUIPlugin)).toThrow(
      "Plugin MyUIPlugin does not extend EvaluationPlugin"
    );
    expect(() => addTestPlugin(featurePluginRegistry, MyUIPlugin)).not.toThrow();
    expect(() => addTestPlugin(statefulUIPluginRegistry, MyUIPlugin)).not.toThrow();
  });

  test("Cannot add Evaluation plugin in the wrong registry", () => {
    class MyEvaluationPlugin extends EvaluationPlugin {}
    expect(() => addTestPlugin(corePluginRegistry, MyEvaluationPlugin)).toThrow(
      "Plugin MyEvaluationPlugin does not extend CorePlugin"
    );
    expect(() => addTestPlugin(evaluationPluginRegistry, MyEvaluationPlugin)).not.toThrow();
    expect(() => addTestPlugin(featurePluginRegistry, MyEvaluationPlugin)).toThrow(
      "Plugin MyEvaluationPlugin does not extend UIPlugin"
    );
    expect(() => addTestPlugin(statefulUIPluginRegistry, MyEvaluationPlugin)).toThrow(
      "Plugin MyEvaluationPlugin does not extend UIPlugin"
    );
  });

  test("Cannot add Core plugin in the wrong registry", () => {
    class MyCorePlugin extends CorePlugin {}
    expect(() => addTestPlugin(corePluginRegistry, MyCorePlugin)).not.toThrow();
    expect(() => addTestPlugin(evaluationPluginRegistry, MyCorePlugin)).toThrow(
      "Plugin MyCorePlugin does not extend EvaluationPlugin"
    );
    expect(() => addTestPlugin(featurePluginRegistry, MyCorePlugin)).toThrow(
      "Plugin MyCorePlugin does not extend UIPlugin"
    );
    expect(() => addTestPlugin(statefulUIPluginRegistry, MyCorePlugin)).toThrow(
      "Plugin MyCorePlugin does not extend UIPlugin"
    );
  });

  test("Can open a model in readonly mode", () => {
    const model = new Model({}, { mode: "readonly" });
    expect(model.getters.isReadonly()).toBe(true);
  });

  test("Some commands are not dispatched in readonly mode", () => {
    const model = new Model({}, { mode: "readonly" });
    expect(setCellContent(model, "A1", "hello")).toBeCancelledBecause(CommandResult.Readonly);
  });

  test("Moving the selection is allowed in readonly mode", () => {
    const model = new Model({}, { mode: "readonly" });
    expect(selectCell(model, "A15")).toBeSuccessfullyDispatched();
  });

  test("Can add custom elements in the config of model", () => {
    const model = new Model({}, { custom: "42" } as unknown as ModelConfig);
    expect(model["config"]["custom"]).toBe("42");
  });

  test("type property in command payload is ignored", () => {
    const model = new Model();
    const payload = {
      col: 0,
      row: 0,
      sheetId: model.getters.getActiveSheetId(),
      content: "hello",
      type: "greeting",
    };
    model.dispatch("UPDATE_CELL", payload);
    expect(getCellRawContent(model, "A1")).toBe("hello");
  });

  test("Cannot add an already existing core getters", () => {
    class MyCorePlugin1 extends CorePlugin {
      static getters = ["getSomething"];

      getSomething() {}
    }

    class MyCorePlugin2 extends CorePlugin {
      static getters = ["getSomething"];

      getSomething() {}
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin1);
    addTestPlugin(corePluginRegistry, MyCorePlugin2);

    expect(() => new Model()).toThrow(`Getter "getSomething" is already defined.`);
  });

  test("Cannot add an already existing getters", () => {
    class MyUIPlugin1 extends UIPlugin {
      static getters = ["getSomething"];

      getSomething() {}
    }

    class MyUIPlugin2 extends UIPlugin {
      static getters = ["getSomething"];

      getSomething() {}
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin1);
    addTestPlugin(featurePluginRegistry, MyUIPlugin2);

    expect(() => new Model()).toThrow(`Getter "getSomething" is already defined.`);
  });

  test("Replayed commands are not send to UI plugins", () => {
    let numberCall = 0;
    //@ts-ignore
    coreTypes.add("MY_CMD_1");
    //@ts-ignore
    coreTypes.add("MY_CMD_2");
    class MyUIPlugin extends UIPlugin {
      handle(cmd: Command) {
        //@ts-ignore
        if (cmd.type === "MY_CMD_2") {
          if (this.getters.getCurrentClient().id === "bob") {
            numberCall++;
          }
        }
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);

    class MyCorePlugin extends CorePlugin {
      public readonly state: number = 0;
      handle(cmd: CoreCommand) {
        //@ts-ignore
        if (cmd.type === "MY_CMD_1") {
          this.history.update("state", 1);
          //@ts-ignore
          this.dispatch("MY_CMD_2");
        }
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);

    const { alice, bob, network } = setupCollaborativeEnv();
    network.concurrent(() => {
      setCellContent(alice, "A1", "Hello");
      //@ts-ignore
      bob.dispatch("MY_CMD_1");
    });
    expect(numberCall).toEqual(1);
  });

  test("Initial commands are not sent to UI plugins", () => {
    class MyUIPlugin extends UIPlugin {
      handle(cmd: Command) {
        if (cmd.type === "UPDATE_CELL") {
          throw new Error("Should not be called");
        }
      }
    }
    addTestPlugin(featurePluginRegistry, MyUIPlugin);
    const data = {
      sheets: [{ id: "sheet1" }],
      revisionId: "initialRevision",
    };
    const model = new Model(data, {}, [
      {
        type: "REMOTE_REVISION",
        nextRevisionId: "1",
        serverRevisionId: "initialRevision",
        commands: [
          {
            type: "UPDATE_CELL",
            sheetId: "sheet1",
            col: 0,
            row: 0,
            content: "=1+5",
          },
        ],
        clientId: "1",
        version: 1,
      },
    ]);
    expect(getEvaluatedCell(model, "A1").value).toBe(6);
  });

  test("Core commands which dispatch UPDATE_CELL should trigger evaluation", () => {
    //@ts-ignore
    coreTypes.add("MY_CMD_1");
    class MyCorePlugin extends CorePlugin {
      handle(cmd: CoreCommand) {
        //@ts-ignore
        if (cmd.type === "MY_CMD_1") {
          this.dispatch("UPDATE_CELL", {
            //@ts-ignore
            sheetId: cmd.sheetId,
            col: 0,
            row: 0,
            content: "=5",
          });
        }
      }
    }
    addTestPlugin(corePluginRegistry, MyCorePlugin);

    const { alice, bob, charlie } = setupCollaborativeEnv();
    setCellContent(alice, "A1", "=3");
    expect([alice, bob, charlie]).toHaveSynchronizedValue(
      (user) => getCellContent(user, "A1"),
      "3"
    );
    //@ts-ignore
    alice.dispatch("MY_CMD_1", { sheetId: alice.getters.getActiveSheetId() });
    expect([alice, bob, charlie]).toHaveSynchronizedValue(
      (user) => getCellContent(user, "A1"),
      "5"
    );
  });

  test("export formula with unbound zone stays unbound", () => {
    const modelData = {
      sheets: [
        {
          cells: {
            A1: "=SUM(A3:3)",
            A2: "=SUM(A3:A)",
          },
        },
      ],
    };
    const model = new Model(modelData);
    expect(model.exportData()).toMatchObject({
      sheets: [
        {
          cells: {
            A1: "=SUM(A3:3)",
            A2: { R: "A3:A" },
          },
        },
      ],
    });
  });

  test("it should snapshot when importing xlsx file", async () => {
    const transport = new MockTransportService();
    const spy = jest.spyOn(transport, "sendMessage");
    const xlsxData = await getTextXlsxFiles();
    new Model(xlsxData, {
      transportService: transport,
      client: { id: "test", name: "Test" },
    });
    expect(spy).toHaveBeenCalledWith({
      type: "SNAPSHOT",
      version: MESSAGE_VERSION,
      nextRevisionId: expect.any(String),
      serverRevisionId: "START_REVISION",
      data: expect.any(Object),
    });
  });

  test("it should not snapshot when importing xlsx file in readonly mode", async () => {
    const messages: CollaborationMessage[] = [];
    const transport = new MockTransportService();
    transport.onNewMessage("listener", (message) => messages.push(message));
    const xlsxData = await getTextXlsxFiles();
    new Model(xlsxData, {
      transportService: transport,
      client: { id: "test", name: "Test" },
      mode: "readonly",
    });
    expect(messages.map((m) => m.type)).not.toContain("SNAPSHOT_CREATED");
  });

  test("it should not snapshot when the session is left is shouldSnapshot false", async () => {
    const transport = new MockTransportService();
    const spy = jest.spyOn(transport, "sendMessage");
    const xlsxData = await getTextXlsxFiles();
    const model = new Model(xlsxData, {
      transportService: transport,
      client: { id: "test", name: "Test" },
    });
    await model.leaveSession({ shouldSnapshot: false });
    expect(spy).not.toHaveBeenCalledWith({
      type: "SNAPSHOT",
    });
  });
});
